from sys import platform
import json
import time
import logging
import requests
from struct import unpack, pack
MDNS_TEMPLATE = r'http://{0}:{1}@eagle-{0}.local/cgi-bin/post_manager'
ADDR_TEMPLATE = r'http://{0}:{1}@{2}/cgi-bin/post_manager'
CMD_TOP_TEMPLATE = r'''\n
{0!s}\n
JSON'''
_LOGGER = logging.getLogger(__name__)
# Options
SAFETY_ON = True
# Enumerations
if SAFETY_ON:
PROTOCOL_VALS = ('ZigBee',)
STATUS_VALS = ('Initializing', 'Network', 'Discovery', 'Joining',
'Join: Fail', 'Join: Success', 'Authenticating',
'Authenticating: Success', 'Authenticating: Fail',
'Connected', 'Disconnected', 'Rejoining')
YESNO_VALS = ('Y', 'N')
PRIORITY_VALS = ('Low', 'Medium', 'High', 'Critical')
QUEUE_VALS = ('Active', 'Cancel Pending')
EVENT_VALS = ('', 'time', 'message', 'price', 'summation', 'demand',
'scheduled_prices', 'profile_data', 'billing_period',
'block_period')
TARGET_VALS = ('Zigbee', 'Eagle', 'All')
if platform in ('linux', 'unix'):
EPOCH_DELTA = 946684800
else:
EPOCH_DELTA = 0
del platform
class Eagle(object):
def __init__(self, cloud_id, install_code, address=None):
self._headers = {'Content-Type': 'application/xml'}
if address is not None:
self.addr = ADDR_TEMPLATE.format(cloud_id, install_code, address)
else:
self.addr = MDNS_TEMPLATE.format(cloud_id, install_code)
def make_cmd(self, command, **kws):
cmd_str = CMD_TOP_TEMPLATE.format(command)
for k, v in kws.items():
cmd_str += '<{0}>{1!s}{0}>\n'.format(k, v)
cmd_str += '\n'
return cmd_str
def post_cmd(self, command, **kws):
post_data = self.make_cmd(command, **kws)
response = requests.post(self.addr,
headers=self._headers,
data=post_data)
if response.status_code == 503:
response = ATTEMPT_TO_UNSTICK_EAGLE(
self, command, response.text, **kws)
if response.status_code != 200:
response.raise_for_status()
response_text = TEMP_RESPONSE_FIX(response.text)
try:
data = json.loads(response_text)
except ValueError as error:
raise ValueError("Invalid JSON format: %s", error)
process_data(data)
return data
def get_network_info(self):
return self.post_cmd('get_network_info')
def list_network(self):
raise NotImplementedError() # No JSON support for this command
def get_network_status(self):
return self.post_cmd('get_network_status')
def get_instantaneous_demand(self):
return self.post_cmd('get_instantaneous_demand')
def get_price(self):
return self.post_cmd('get_price')
def get_message(self):
return self.post_cmd('get_message')
def confirm_message(self): # Needs argument: ID
raise NotImplementedError('eagle100 is read-only.')
def get_current_summation(self):
return self.post_cmd('get_current_summation')
def get_history_data(self, start_time, end_time=None, frequency=None):
kw = {'StartTime': hex(int(start_time - EPOCH_DELTA))}
if end_time is not None:
kw['EndTime'] = hex(int(end_time - EPOCH_DELTA))
if frequency is not None:
if SAFETY_ON:
if frequency > 0xffff or frequency < 0:
raise ValueError(
'frequency must be between 0 and 65535 seconds')
kw['Frequency'] = hex(int(frequency))
return self.post_cmd('get_history_data', **kw)
def set_schedule(self): # Needs arguments: Event, Frequency, Enabled
raise NotImplementedError('eagle100 is read-only.')
def get_schedule(self, event=None):
if event is None:
event = ''
if SAFETY_ON and event not in EVENT_VALS:
raise ValueError('\'{}\' is not a valid event'.format(event))
return self.post_cmd('get_schedule', Event=event)
def reboot(self): # Needs argument: Target
raise NotImplementedError('eagle100 is read-only.')
def get_demand_peaks(self):
return self.post_cmd('get_demand_peaks')
def process_data(d):
'''
Given a response dict from the EAGLE, interpret common data
types / apply conversions.
'''
# Handle nested dictionaries
for v in d.values():
if isinstance(v, dict):
process_data(v)
elif isinstance(v, list):
for vi in v:
process_data(vi)
# Summation and demand conversion
if 'Multiplier' in d:
convert_demand(d)
if 'Price' in d:
convert_price(d)
if 'TimeStamp' in d:
d['TimeStamp'] = time.localtime(int(d['TimeStamp'], 0) + EPOCH_DELTA)
def convert_demand(d):
'Parse values and remove extraneous keys from demand responses.'
factor = max(int(d['Multiplier'], 0), 1) / max(int(d['Divisor'], 0), 1)
n_dec = int(d['DigitsRight'], 0)
if 'Demand' in d:
demand = int(d['Demand'], 0)
bytes_demand = pack('>I', demand)
new_int_demand = unpack('>i', bytes_demand)[0]
d['Demand'] = round(new_int_demand*factor, n_dec)
else:
d['SummationDelivered'] = round(
int(d['SummationDelivered'], 0)*factor, n_dec)
d['SummationReceived'] = round(
int(d['SummationReceived'], 0)*factor, n_dec)
del d['Multiplier']
del d['Divisor']
del d['DigitsRight']
del d['DigitsLeft']
del d['SuppressLeadingZero']
def convert_price(d):
'Perform inplace hex-to-value conversion for price values.'
d['Price'] = int(d['Price'], 0) / 10**int(d['TrailingDigits'], 0)
d['Currency'] = int(d['Currency'], 0)
del d['TrailingDigits']
def TEMP_RESPONSE_FIX(s):
'''
The EAGLE API provides malformed responses for some commands. This
function tries to fix them (until the firmware is patched).
'''
if s.startswith('\"HistoryData\"') or s.startswith('\"ScheduleList\"'):
return '{' + s + '}'
return s
def ATTEMPT_TO_UNSTICK_EAGLE(self, command, orig_response, **kws):
'''
By the process of trial and error...
It seems that sending a 'get_historical_data' request using the alternate
cgi_manager endpoint (same endpoint used by the web pages), we can
"unstick" the server and in a few seconds it will start responding to
requests again.
'''
# First we do a short pause and simply try again
time.sleep(1)
post_data = self.make_cmd(command, **kws)
response = requests.post(self.addr,
headers=self._headers,
data=post_data)
if response.status_code == 200:
_LOGGER.warning(
"Eagle success after retry (original response: %s)", orig_response)
return response
# Try to unstick by issuing the same get_historical_data command used by the web page
post_data = self.make_cmd("get_historical_data", **kws)
alt_address = self.addr.replace("post_manager", "cgi_manager")
response = requests.post(alt_address,
headers=self._headers,
data=post_data)
if response.status_code == 200:
# If that was successful (but probably an empty response), wait a bit for it to recover
# Eagle seems to take some time to 'wake up' from the bad state
time.sleep(15)
# Re-issue the original command
post_data = self.make_cmd(command, **kws)
response = requests.post(self.addr,
headers=self._headers,
data=post_data)
if response.status_code == 200:
_LOGGER.warning(
"Eagle 'unstick' successful: %s (original response: %s)", response.text, orig_response)
else: # Note: at this point it is possible it's working again but requires a bit more time
_LOGGER.error("Eagle 'unstick' failed: %s [%s], (original response: %s)",
response.text, response.status_code, orig_response)
else:
_LOGGER.error(
"Eagle 'unstick' attempt failed: %s [%s]", response.text, response.status_code)
return response
# notes
# More recent API (1.1) supports command list_network
# list_network does not return JSON
# gets info on all network interfaces
#
# When running locally, API Commands to not actually require MAC address
# Since this is python3 compatible, we assumme int / int = float
# the EAGLE's Epoch is 01-01-2000, rather than unix's 1970.
# Each command needs:
# POST with
## headers: auth
## body: xml
#
# COMMAND
# JSON
#
#