diff --git a/.gitignore b/.gitignore index 68a46fc7..0a3fbbf3 100755 --- a/.gitignore +++ b/.gitignore @@ -63,4 +63,4 @@ target/ .AppleDouble # VSCode Configs -.vscode/ \ No newline at end of file +.vscode/ diff --git a/CHANGELOG.md b/CHANGELOG.md index a7ba2ceb..79e5290e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,15 @@ ## CHANGELOG -### [v2.0.3] - Minor Updates, Connection Events and Timestamps +### [v2.0.3] - Fix Property Updates, Add Timestamps, Unused Status Handling + +#### Changed / Fixed + +- Changed the default Status Property (`ST`) unit of measurement (UOM) to `ISY_PROP_NOT_SET = "-1"`: Some NodeServer and Z-Wave nodes do not make use of the `ST` (or status) property in the ISY and only report `aux_properties`; in addition, most NodeServer nodes do not report the `ST` property when all nodes are retrieved, they only report it when queried directly or in the Event Stream. Previously, there was no way to differentiate between Insteon Nodes that don't have a valid status yet (after ISY reboot) and the other types of nodes that don't report the property correctly since they both reported `ISY_VALUE_UNKNOWN`. The `ISY_PROP_NOT_SET` allows differentiation between the two conditions based on having a valid UOM or not. Fixes #98. +- Rewrite the Node status update receiver: currently, when a Node's status is updated, the `formatted` property is not updated and the `uom`/`prec` are updated with separate functions from outside of the Node's class. This updates the receiver to pass a `NodeProperty` instance into the Node, and allows the Node to update all of it's properties if they've changed, before reporting the status change to the subscribers. This makes the `formatted` property actually useful. #### Added +- Added `*.last_update` and `*.last_changed` properties which are UTC Datetime Timestamps, to allow detection of stale data. Fixes #99 - Add connection events for the Event Stream to allow subscription and callbacks. Attach a callback with `isy.connection_events(callback)` and receive a string with the event detail. See `constants.py` for events starting with prefix `ES_`. ### [v2.0.2] - Version 2.0 Initial Release diff --git a/examples/connection_test.py b/examples/connection_test.py index 52369b92..c11142ca 100644 --- a/examples/connection_test.py +++ b/examples/connection_test.py @@ -8,8 +8,6 @@ from pyisy import ISY from pyisy.connection import Connection -_LOGGER = logging.getLogger(__name__) - # CONFIGURATION OPTIONS: ADDRESS = "my.isy.io" # IP address or hostname of your ISY PORT = 443 # Port number to use for connection @@ -19,12 +17,16 @@ TLS_VER = 1.1 # TLS Version: 1.1 or 1.2, 0 for HTTP WEBROOT = "" # Optional, for Advanced ISY Portal use +LOG_LEVEL = logging.DEBUG +# LOG_LEVEL = 5 # (Verbose) Use this level for printing event stream socket messages +LOG_FORMAT = "%(asctime)s %(levelname)s [%(name)s] %(message)s" +LOG_DATE_FORMAT = "%Y-%m-%d %H:%M:%S" + +_LOGGER = logging.getLogger(__name__) def main(arguments): """Execute primary loop.""" - fmt = "%(asctime)s %(levelname)s [%(name)s] %(message)s" - datefmt = "%Y-%m-%d %H:%M:%S" - logging.basicConfig(format=fmt, datefmt=datefmt, level=logging.DEBUG) + logging.basicConfig(format=LOG_FORMAT, datefmt=LOG_DATE_FORMAT, level=LOG_LEVEL) # Test the connection to ISY controller. try: @@ -62,17 +64,20 @@ def main(arguments): _LOGGER.info("ISY connected: %s", isy.connected) - # Print a representation of all the Nodes - _LOGGER.debug(repr(isy.nodes)) - - if isy.connected: - # Connect to the Event Stream and print events in the Debug Log. - isy.auto_update = True - else: + if not isy.connected: _LOGGER.error( "Failed to connect to the ISY, please adjust settings and try again." ) return + # Print a representation of all the Nodes + _LOGGER.debug(repr(isy.nodes)) + + # Get the rest of the detailed status information from the ISY + # not originally reported. This includes statuses for all NodeServer nodes. + isy.nodes.update() + + # Connect to the Event Stream and print events in the Debug Log. + isy.auto_update = True try: while True: diff --git a/pyisy/connection.py b/pyisy/connection.py index 1f26f849..7b89e044 100644 --- a/pyisy/connection.py +++ b/pyisy/connection.py @@ -23,6 +23,7 @@ URL_PING, URL_PROGRAMS, URL_RESOURCES, + URL_STATUS, URL_SUBFOLDERS, URL_VARIABLES, VAR_INTEGER, @@ -197,6 +198,13 @@ def get_nodes(self): req_url = self.compile_url([URL_NODES], {URL_MEMBERS: XML_FALSE}) result = self.request(req_url) return result + return result + + def get_status(self): + """Fetch the status of nodes/groups/scenes from the ISY.""" + req_url = self.compile_url([URL_STATUS]) + result = self.request(req_url) + return result def get_variable_defs(self): """Fetch the list of variables from the ISY.""" diff --git a/pyisy/constants.py b/pyisy/constants.py index 038ccfe8..16d9a7e8 100644 --- a/pyisy/constants.py +++ b/pyisy/constants.py @@ -34,6 +34,7 @@ ES_RECONNECTING = "reconnecting" ISY_VALUE_UNKNOWN = -1 * float("inf") +ISY_PROP_NOT_SET = "-1" XML_ERRORS = (AttributeError, KeyError, ValueError, TypeError, IndexError, ExpatError) XML_PARSE_ERROR = "ISY Could not parse response, poorly formatted XML." @@ -49,6 +50,8 @@ ATTR_ID = "id" ATTR_INIT = "init" ATTR_INSTANCE = "instance" +ATTR_LAST_CHANGED = "last_changed" +ATTR_LAST_UPDATE = "last_update" ATTR_NODE_DEF_ID = "nodeDefId" ATTR_PARENT = "parentId" ATTR_PRECISION = "prec" @@ -332,9 +335,11 @@ UOM_FAN_MODES = "99" UOM_CLIMATE_MODES = "98" UOM_CLIMATE_MODES_ZWAVE = "67" +UOM_DOUBLE_TEMP = "101" UOM_FRIENDLY_NAME = { "1": "A", + "2": "", # Binary / On-Off "3": "btu/h", "4": "°C", "5": "cm", @@ -413,6 +418,7 @@ "90": "Hz", "91": "°", "92": "° South", + "100": "", "101": "° (x2)", "102": "kWs", "103": "$", @@ -428,8 +434,8 @@ "113": "", # raw 3-byte signed value "114": "", # raw 4-byte signed value "116": "mi", - "117": "mb", - "118": "hpa", + "117": "mbar", + "118": "hPa", "119": "Wh", "120": "in/day", } diff --git a/pyisy/events.py b/pyisy/events.py index 517c905a..c8050b1f 100644 --- a/pyisy/events.py +++ b/pyisy/events.py @@ -1,5 +1,4 @@ """ISY Event Stream.""" -import datetime import socket import ssl from threading import Thread, ThreadError @@ -26,7 +25,7 @@ VERBOSE, ) from .eventreader import ISYEventReader, ISYMaxConnections, ISYStreamDataError -from .helpers import attr_from_xml, value_from_xml +from .helpers import attr_from_xml, now, value_from_xml class EventStream: @@ -95,7 +94,7 @@ def _route_message(self, msg): elif self._loaded == ES_INITIALIZING: self._loaded = ES_LOADED self.isy.connection_events.notify(ES_LOADED) - self._lasthb = datetime.datetime.now() + self._lasthb = now() self._hbwait = int(value_from_xml(xmldoc, ATTR_ACTION)) self.isy.log.debug("ISY HEARTBEAT: %s", self._lasthb.isoformat()) elif cntrl == PROP_STATUS: # NODE UPDATE @@ -207,7 +206,7 @@ def connected(self): def heartbeat_time(self): """Return the last ISY Heartbeat time.""" if self._lasthb is not None: - return (datetime.datetime.now() - self._lasthb).seconds + return (now() - self._lasthb).seconds return 0.0 def _lost_connection(self, delay=0): diff --git a/pyisy/helpers.py b/pyisy/helpers.py index 4f2be679..9390ddd3 100644 --- a/pyisy/helpers.py +++ b/pyisy/helpers.py @@ -11,6 +11,7 @@ ATTR_VALUE, INSTEON_RAMP_RATES, ISY_EPOCH_OFFSET, + ISY_PROP_NOT_SET, ISY_VALUE_UNKNOWN, PROP_BATTERY_LEVEL, PROP_RAMP_RATE, @@ -37,7 +38,7 @@ def parse_xml_properties(xmldoc): """ aux_props = {} state_set = False - state = NodeProperty(PROP_STATUS) + state = NodeProperty(PROP_STATUS, uom=ISY_PROP_NOT_SET) props = xmldoc.getElementsByTagName(TAG_PROPERTY) if not props: @@ -143,6 +144,17 @@ def ntp_to_system_time(timestamp): return datetime.datetime.fromtimestamp(timestamp - ntp_delta) +def now(): + """Get the current system time. + + Note: this module uses naive datetimes because the + ISY is highly inconsistent with time conventions + and does not present enough information to accurately + mangage DST without significant guessing and effort. + """ + return datetime.datetime.now() + + class EventEmitter: """Event Emitter class.""" @@ -183,7 +195,13 @@ class NodeProperty(dict): """Class to hold result of a control event or node aux property.""" def __init__( - self, control, value=ISY_VALUE_UNKNOWN, prec="0", uom="", formatted=None + self, + control, + value=ISY_VALUE_UNKNOWN, + prec="0", + uom="", + formatted=None, + address=None, ): """Initialize an control result or aux property.""" super().__init__( @@ -193,8 +211,14 @@ def __init__( prec=prec, uom=uom, formatted=(formatted if formatted is not None else value), + address=address, ) + @property + def address(self): + """Report the address of the node with this property.""" + return self["address"] + @property def control(self): """Report the event control string.""" @@ -223,8 +247,9 @@ def formatted(self): def __str__(self): """Return just the event title to prevent breaking changes.""" return ( - f"NodeProperty('{self.control}': value='{self.value}' " - f"prec='{self.prec}' uom='{self.uom}' formatted='{self.formatted}')" + f"NodeProperty('{self.address}': control='{self.control}', " + f"value='{self.value}', prec='{self.prec}', " + f"uom='{self.uom}', formatted='{self.formatted}')" ) __repr__ = __str__ diff --git a/pyisy/nodes/__init__.py b/pyisy/nodes/__init__.py index 2b34bb47..6b3a0957 100755 --- a/pyisy/nodes/__init__.py +++ b/pyisy/nodes/__init__.py @@ -6,6 +6,7 @@ ATTR_ACTION, ATTR_CONTROL, ATTR_FLAG, + ATTR_ID, ATTR_INSTANCE, ATTR_NODE_DEF_ID, ATTR_PRECISION, @@ -17,6 +18,7 @@ NODE_CHANGED_ACTIONS, PROP_COMMS_ERROR, PROP_RAMP_RATE, + PROP_STATUS, PROTO_INSTEON, PROTO_NODE_SERVER, PROTO_ZIGBEE, @@ -35,7 +37,6 @@ TAG_PRIMARY_NODE, TAG_TYPE, UOM_SECONDS, - URL_STATUS, XML_ERRORS, XML_PARSE_ERROR, XML_TRUE, @@ -190,17 +191,26 @@ def __reversed__(self): def update_received(self, xmldoc): """Update nodes from event stream message.""" address = value_from_xml(xmldoc, TAG_NODE) + + node = self.get_by_id(address) + if not node: + self.isy.log.debug( + "Received a node update for node %s but could not find a record of this " + "node. Please try restarting the module if the problem persists, this " + "may be due to a new node being added to the ISY since last restart.", + address, + ) + return value = value_from_xml(xmldoc, ATTR_ACTION) value = int(value) if value != "" else ISY_VALUE_UNKNOWN prec = attr_from_xml(xmldoc, ATTR_ACTION, ATTR_PRECISION, "0") uom = attr_from_xml(xmldoc, ATTR_ACTION, ATTR_UNIT_OF_MEASURE, "") - node = self.get_by_id(address) - if not node: - return - # Check if UOM/PREC have changed or were not set: - node.update_precision(prec) - node.update_uom(uom) - node.status = value + formatted = value_from_xml(xmldoc, TAG_FORMATTED) + + # Process the action and value if provided in event data. + node.update_state( + NodeProperty(PROP_STATUS, value, prec, uom, formatted, address) + ) self.isy.log.debug("ISY Updated Node: " + address) def control_message_received(self, xmldoc): @@ -215,13 +225,6 @@ def control_message_received(self, xmldoc): # If there is no node associated with the control message ignore it return - # Process the action and value if provided in event data. - value = value_from_xml(xmldoc, ATTR_ACTION, 0) - value = int(value) if value != "" else ISY_VALUE_UNKNOWN - prec = attr_from_xml(xmldoc, ATTR_ACTION, ATTR_PRECISION, "0") - uom = attr_from_xml(xmldoc, ATTR_ACTION, ATTR_UNIT_OF_MEASURE, "") - formatted = value_from_xml(xmldoc, TAG_FORMATTED) - node = self.get_by_id(address) if not node: self.isy.log.debug( @@ -232,10 +235,18 @@ def control_message_received(self, xmldoc): ) return + # Process the action and value if provided in event data. + node.update_last_update() + value = value_from_xml(xmldoc, ATTR_ACTION, 0) + value = int(value) if value != "" else ISY_VALUE_UNKNOWN + prec = attr_from_xml(xmldoc, ATTR_ACTION, ATTR_PRECISION, "0") + uom = attr_from_xml(xmldoc, ATTR_ACTION, ATTR_UNIT_OF_MEASURE, "") + formatted = value_from_xml(xmldoc, TAG_FORMATTED) + if cntrl == PROP_RAMP_RATE: value = INSTEON_RAMP_RATES.get(value, value) uom = UOM_SECONDS - node_property = NodeProperty(cntrl, value, prec, uom, formatted) + node_property = NodeProperty(cntrl, value, prec, uom, formatted, address) if ( cntrl == PROP_COMMS_ERROR and value == 0 @@ -244,9 +255,9 @@ def control_message_received(self, xmldoc): # Clear a previous comms error del node.aux_properties[PROP_COMMS_ERROR] elif cntrl not in EVENT_PROPS_IGNORED: - node.aux_properties[cntrl] = node_property + node.update_property(node_property) node.control_events.notify(node_property) - self.isy.log.debug("ISY Node Control Event: %s %s", address, node_property) + self.isy.log.debug("ISY Node Control Event: %s", node_property) def node_changed_received(self, xmldoc): """Handle Node Change/Update events from an event stream message.""" @@ -372,17 +383,52 @@ def parse(self, xml): self.isy.log.debug("ISY Loaded %s", ntype) def update(self, wait_time=0): + """ + Update the status and properties of the nodes in the class. + + This calls the "/rest/status" endpoint. + + | wait_time: [optional] Amount of seconds to wait before updating + """ + if wait_time: + sleep(wait_time) + xml = self.isy.conn.get_status() + + if xml is None: + self.isy.log.warning("ISY Failed to update nodes.") + return + + try: + xmldoc = minidom.parseString(xml) + except XML_ERRORS: + self.isy.log.error("%s: Nodes", XML_PARSE_ERROR) + return False + + for feature in xmldoc.getElementsByTagName(TAG_NODE): + address = feature.attributes[ATTR_ID].value + state, aux_props = parse_xml_properties(feature) + + if address in self.addresses: + self.get_by_id(address).update(xmldoc=feature) + continue + + self.isy.log.info("ISY Updated Node Statuses.") + + def update_nodes(self, wait_time=0): """ Update the contents of the class. + This calls the "/rest/nodes" endpoint. + | wait_time: [optional] Amount of seconds to wait before updating """ - sleep(wait_time) - xml = self.isy.conn.request(self.isy.conn.compile_url([URL_STATUS])) - if xml is not None: - self.parse(xml) - else: + if wait_time: + sleep(wait_time) + xml = self.isy.conn.get_nodes() + if xml is None: self.isy.log.warning("ISY Failed to update nodes.") + return + self.parse(xml) def insert(self, address, nname, nparent, nobj, ntype): """ diff --git a/pyisy/nodes/group.py b/pyisy/nodes/group.py index dbe60d71..c05be41e 100755 --- a/pyisy/nodes/group.py +++ b/pyisy/nodes/group.py @@ -1,5 +1,6 @@ """Representation of groups (scenes) from an ISY.""" from ..constants import ISY_VALUE_UNKNOWN, PROTO_GROUP +from ..helpers import now from .nodebase import NodeBase @@ -66,6 +67,7 @@ def group_all_on(self, value): """Set the current node state and notify listeners.""" if self._all_on != value: self._all_on = value + self._last_changed = now() # Re-publish the current status. Let users pick up the all on change. self.status_events.notify(self._status) return self._all_on @@ -80,8 +82,9 @@ def protocol(self): """Return the protocol for this entity.""" return PROTO_GROUP - def update(self, wait_time=0, hint=None, xmldoc=None): + def update(self, event=None, wait_time=0, hint=None, xmldoc=None): """Update the group with values from the controller.""" + self._last_update = now() valid_nodes = [ node for node in self.members diff --git a/pyisy/nodes/node.py b/pyisy/nodes/node.py index 703b3fbf..a84e83b9 100755 --- a/pyisy/nodes/node.py +++ b/pyisy/nodes/node.py @@ -27,7 +27,7 @@ XML_ERRORS, XML_PARSE_ERROR, ) -from ..helpers import EventEmitter, parse_xml_properties +from ..helpers import EventEmitter, NodeProperty, now, parse_xml_properties from .nodebase import NodeBase @@ -189,7 +189,7 @@ def zwave_props(self): """Return the Z-Wave Properties (used for Z-Wave devices).""" return self._zwave_props - def update(self, wait_time=0, hint=None, xmldoc=None): + def update(self, event=None, wait_time=0, hint=None, xmldoc=None): """Update the value of the node from the controller.""" if not self.isy.auto_update and not xmldoc: sleep(wait_time) @@ -212,23 +212,40 @@ def update(self, wait_time=0, hint=None, xmldoc=None): self.isy.log.warning("ISY could not update node: %s", self._id) return + self._last_update = now() state, aux_props = parse_xml_properties(xmldoc) self._aux_properties.update(aux_props) - self._uom = state.uom if state.uom != "" else self._uom - self._prec = state.prec if state.prec != "0" else self._prec - self._formatted = state.formatted - self.status = state.value + self.update_state(state) self.isy.log.debug("ISY updated node: %s", self._id) - def update_precision(self, value): - """Set the unit of measurement if not provided initially.""" - if value and self._prec != value: - self._prec = value + def update_state(self, state): + """Update the various state properties when received.""" + if not isinstance(state, NodeProperty): + self.isy.log.error("Could not update state values. Invalid type provided.") + return + changed = False + self._last_update = now() + + if state.prec != self._prec: + self._prec = state.prec + changed = True + + if state.uom != self._uom: + self._uom = state.uom + changed = True + + if state.formatted != self._formatted: + self._formatted = state.formatted + changed = True + + if state.value != self.status: + self.status = state.value + # Let Status setter throw event + return - def update_uom(self, value): - """Set the unit of measurement if not provided initially.""" - if value and self._uom != value: - self._uom = value + if changed: + self._last_changed = now() + self.status_events.notify(self.status_feedback) def get_command_value(self, uom, cmd): """Check against the list of UOM States if this is a valid command.""" diff --git a/pyisy/nodes/nodebase.py b/pyisy/nodes/nodebase.py index 1188ebd4..ca052922 100755 --- a/pyisy/nodes/nodebase.py +++ b/pyisy/nodes/nodebase.py @@ -2,6 +2,9 @@ from xml.dom import minidom from ..constants import ( + ATTR_LAST_CHANGED, + ATTR_LAST_UPDATE, + ATTR_STATUS, CMD_BEEP, CMD_BRIGHTEN, CMD_DIM, @@ -18,6 +21,7 @@ METHOD_COMMAND, NODE_FAMILY_ID, PROP_ON_LEVEL, + TAG_ADDRESS, TAG_DESCRIPTION, TAG_IS_LOAD, TAG_LOCATION, @@ -29,7 +33,7 @@ XML_PARSE_ERROR, XML_TRUE, ) -from ..helpers import EventEmitter, value_from_xml +from ..helpers import EventEmitter, NodeProperty, now, value_from_xml class NodeBase: @@ -56,6 +60,8 @@ def __init__( self._notes = None self._primary_node = pnode self._status = status + self._last_update = now() + self._last_changed = now() self.isy = nodes.isy self.status_events = EventEmitter() @@ -92,6 +98,16 @@ def is_load(self): self._notes = self.parse_notes() return self._notes[TAG_IS_LOAD] + @property + def last_changed(self): + """Return the UTC Time of the last status change for this node.""" + return self._last_changed + + @property + def last_update(self): + """Return the UTC Time of the last update for this node.""" + return self._last_update + @property def location(self): """Return the location of the node from it's notes.""" @@ -131,9 +147,20 @@ def status(self, value): """Set the current node state and notify listeners.""" if self._status != value: self._status = value - self.status_events.notify(self._status) + self._last_changed = now() + self.status_events.notify(self.status_feedback) return self._status + @property + def status_feedback(self): + """Return information for a status change event.""" + return { + TAG_ADDRESS: self.address, + ATTR_STATUS: self._status, + ATTR_LAST_CHANGED: self._last_changed, + ATTR_LAST_UPDATE: self._last_update, + } + def parse_notes(self): """Parse the notes for a given node. @@ -164,9 +191,37 @@ def parse_notes(self): TAG_LOCATION: location, } - def update(self, wait_time=0, hint=None, xmldoc=None): + def update(self, event=None, wait_time=0, hint=None, xmldoc=None): """Update the group with values from the controller.""" - pass + self.update_last_update() + + def update_property(self, prop): + """Update an aux property for the node when received.""" + if not isinstance(prop, NodeProperty): + self.isy.log.error( + "Could not update property value. Invalid type provided." + ) + return + self.update_last_update() + + aux_prop = self.aux_properties.get(prop.control) + if aux_prop and aux_prop == prop: + return + self.aux_properties[prop.control] = prop + self.update_last_changed() + self.status_events.notify(self.status_feedback) + + def update_last_changed(self, timestamp=None): + """Set the UTC Time of the last status change for this node.""" + if timestamp is None: + timestamp = now() + self._last_changed = timestamp + + def update_last_update(self, timestamp=None): + """Set the UTC Time of the last update for this node.""" + if timestamp is None: + timestamp = now() + self._last_update = timestamp def send_cmd(self, cmd, val=None, uom=None, query=None): """Send a command to the device.""" @@ -202,7 +257,7 @@ def send_cmd(self, cmd, val=None, uom=None, query=None): hint = 255 elif cmd in [CMD_OFF, CMD_OFF_FAST]: hint = 0 - self.update(UPDATE_INTERVAL, hint=hint) + self.update(wait_time=UPDATE_INTERVAL, hint=hint) return True def beep(self): diff --git a/pyisy/programs/__init__.py b/pyisy/programs/__init__.py index 9c55441f..ddc42cee 100755 --- a/pyisy/programs/__init__.py +++ b/pyisy/programs/__init__.py @@ -1,15 +1,14 @@ """Init for management of ISY Programs.""" -from datetime import datetime from time import sleep from xml.dom import minidom +from dateutil import parser + from ..constants import ( ATTR_ID, ATTR_PARENT, ATTR_STATUS, EMPTY_TIME, - MILITARY_TIME, - STANDARD_TIME, TAG_ENABLED, TAG_FOLDER, TAG_NAME, @@ -23,10 +22,9 @@ XML_OFF, XML_ON, XML_PARSE_ERROR, - XML_STRPTIME_YY, XML_TRUE, ) -from ..helpers import attr_from_element, value_from_xml +from ..helpers import attr_from_element, now, value_from_xml from ..nodes import NodeIterator as ProgramIterator from .folder import Folder from .program import Program @@ -166,13 +164,11 @@ def update_received(self, xmldoc): pobj.ran_else += 1 if f"<{TAG_PRGM_RUN}>" in xml: - pobj.last_run = datetime.strptime( - value_from_xml(xmldoc, TAG_PRGM_RUN), XML_STRPTIME_YY - ) + pobj.last_run = parser.parse(value_from_xml(xmldoc, TAG_PRGM_RUN)) if f"<{TAG_PRGM_FINISH}>" in xml: - pobj.last_finished = datetime.strptime( - value_from_xml(xmldoc, TAG_PRGM_FINISH), XML_STRPTIME_YY + pobj.last_finished = parser.parse( + value_from_xml(xmldoc, TAG_PRGM_FINISH) ) if XML_ON in xml or XML_OFF in xml: @@ -198,7 +194,7 @@ def parse(self, xml): except XML_ERRORS: self.isy.log.error("%s: Programs", XML_PARSE_ERROR) else: - plastup = datetime.now() + plastup = now() # get nodes features = xmldoc.getElementsByTagName(TAG_PROGRAM) @@ -212,7 +208,7 @@ def parse(self, xml): if attr_from_element(feature, TAG_FOLDER) == XML_TRUE: # folder specific parsing ptype = TAG_FOLDER - data = {"pstatus": pstatus} + data = {"pstatus": pstatus, "plastup": plastup} else: # program specific parsing @@ -221,18 +217,12 @@ def parse(self, xml): # last run time plastrun = value_from_xml(feature, "lastRunTime", EMPTY_TIME) if plastrun != EMPTY_TIME: - plastrun = datetime.strptime( - plastrun, - MILITARY_TIME if self.isy.clock.military else STANDARD_TIME, - ) + plastrun = parser.parse(plastrun) # last finish time plastfin = value_from_xml(feature, "lastFinishTime", EMPTY_TIME) if plastfin != EMPTY_TIME: - plastfin = datetime.strptime( - plastfin, - MILITARY_TIME if self.isy.clock.military else STANDARD_TIME, - ) + plastfin = parser.parse(plastfin) # enabled, run at startup, running penabled = bool(attr_from_element(feature, TAG_ENABLED) == XML_TRUE) diff --git a/pyisy/programs/folder.py b/pyisy/programs/folder.py index 620d0cb2..9b4b4a94 100644 --- a/pyisy/programs/folder.py +++ b/pyisy/programs/folder.py @@ -1,5 +1,8 @@ """ISY Program Folders.""" from ..constants import ( + ATTR_LAST_CHANGED, + ATTR_LAST_UPDATE, + ATTR_STATUS, CMD_DISABLE, CMD_ENABLE, CMD_RUN, @@ -7,11 +10,12 @@ CMD_RUN_THEN, CMD_STOP, PROTO_FOLDER, + TAG_ADDRESS, TAG_FOLDER, UPDATE_INTERVAL, URL_PROGRAMS, ) -from ..helpers import EventEmitter +from ..helpers import EventEmitter, now class Folder: @@ -30,9 +34,11 @@ class Folder: dtype = TAG_FOLDER - def __init__(self, programs, address, pname, pstatus): + def __init__(self, programs, address, pname, pstatus, plastup): """Initialize the Folder class.""" self._id = address + self._last_update = plastup + self._last_changed = now() self._name = pname self._programs = programs self._status = pstatus @@ -48,6 +54,30 @@ def address(self): """Return the program or folder ID.""" return self._id + @property + def last_changed(self): + """Return the last time the program was changed in this module.""" + return self._last_changed + + @last_changed.setter + def last_changed(self, value): + """Set the last time the program was changed.""" + if self._last_changed != value: + self._last_changed = value + return self._last_changed + + @property + def last_update(self): + """Return the last time the program was updated.""" + return self._last_update + + @last_update.setter + def last_update(self, value): + """Set the last time the program was updated.""" + if self._last_update != value: + self._last_update = value + return self._last_update + @property def leaf(self): """Get the leaf property.""" @@ -76,6 +106,16 @@ def status(self, value): self.status_events.notify(self._status) return self._status + @property + def status_feedback(self): + """Return information for a status change event.""" + return { + TAG_ADDRESS: self.address, + ATTR_STATUS: self._status, + ATTR_LAST_CHANGED: self._last_changed, + ATTR_LAST_UPDATE: self._last_update, + } + def update(self, wait_time=UPDATE_INTERVAL, data=None): """ Update the status of the program. @@ -84,6 +124,7 @@ def update(self, wait_time=UPDATE_INTERVAL, data=None): | wait_time: [optional] Seconds to wait before updating. """ if data is not None: + self._last_changed = now() self.status = data["pstatus"] return self._programs.update(wait_time=wait_time, address=self._id) diff --git a/pyisy/programs/program.py b/pyisy/programs/program.py index 5508e383..484f9750 100644 --- a/pyisy/programs/program.py +++ b/pyisy/programs/program.py @@ -44,11 +44,10 @@ def __init__( prunning, ): """Initialize a Program class.""" - super(Program, self).__init__(programs, address, pname, pstatus) + super(Program, self).__init__(programs, address, pname, pstatus, plastup) self._enabled = penabled self._last_finished = plastfin self._last_run = plastrun - self._last_update = plastup self._ran_else = 0 self._ran_then = 0 self._run_at_startup = pstartrun @@ -90,18 +89,6 @@ def last_run(self, value): self._last_run = value return self._last_run - @property - def last_update(self): - """Return the last time the program was updated.""" - return self._last_update - - @last_update.setter - def last_update(self, value): - """Set the last time the program was updated.""" - if self._last_update != value: - self._last_update = value - return self._last_update - @property def protocol(self): """Return the protocol for this entity.""" diff --git a/pyisy/variables/__init__.py b/pyisy/variables/__init__.py index 6da8efe9..d7f45e50 100644 --- a/pyisy/variables/__init__.py +++ b/pyisy/variables/__init__.py @@ -1,8 +1,9 @@ """ISY Variables.""" -from datetime import datetime from time import sleep from xml.dom import minidom +from dateutil import parser + from ..constants import ( ATTR_ID, ATTR_INIT, @@ -14,9 +15,8 @@ TAG_VARIABLE, XML_ERRORS, XML_PARSE_ERROR, - XML_STRPTIME, ) -from ..helpers import attr_from_element, attr_from_xml, value_from_xml +from ..helpers import attr_from_element, attr_from_xml, now, value_from_xml from .variable import Variable EMPTY_VARIABLE_RESPONSES = [ @@ -122,7 +122,7 @@ def parse(self, xml): init = value_from_xml(feature, ATTR_INIT) val = value_from_xml(feature, ATTR_VAL) ts_raw = value_from_xml(feature, ATTR_TS) - t_s = datetime.strptime(ts_raw, XML_STRPTIME) + t_s = parser.parse(ts_raw) vname = self.vnames[vtype].get(vid, "") vobj = self.vobjs[vtype].get(vid) @@ -160,13 +160,12 @@ def update_received(self, xmldoc): except KeyError: return # this is a new variable that hasn't been loaded + vobj.last_update = now() if f"<{ATTR_INIT}>" in xml: vobj.init = int(value_from_xml(xmldoc, ATTR_INIT)) else: vobj.status = int(value_from_xml(xmldoc, ATTR_VAL)) - vobj.last_edited = datetime.strptime( - value_from_xml(xmldoc, ATTR_TS), XML_STRPTIME - ) + vobj.last_edited = parser.parse(value_from_xml(xmldoc, ATTR_TS)) self.isy.log.debug("ISY Updated Variable: %s.%s", str(vtype), str(vid)) @@ -185,11 +184,11 @@ def __getitem__(self, val): return self.vobjs[self.root][val] except (ValueError, KeyError) as err: raise KeyError(f"Unrecognized variable id: {val}") from err - else: - for vid, vname in self.vnames[self.root]: - if vname == val: - return self.vobjs[self.root][vid] - raise KeyError(f"Unrecognized variable name: {val}") + + for vid, vname in self.vnames[self.root]: + if vname == val: + return self.vobjs[self.root][vid] + raise KeyError(f"Unrecognized variable name: {val}") def __setitem__(self, val, value): """Handle the setitem function for the Class.""" diff --git a/pyisy/variables/variable.py b/pyisy/variables/variable.py index c01f2e9d..ce6f4ad8 100644 --- a/pyisy/variables/variable.py +++ b/pyisy/variables/variable.py @@ -1,13 +1,18 @@ """Manage variables from the ISY.""" from ..constants import ( ATTR_INIT, + ATTR_LAST_CHANGED, + ATTR_LAST_UPDATE, ATTR_SET, + ATTR_STATUS, + ATTR_TS, PROTO_INT_VAR, PROTO_STATE_VAR, + TAG_ADDRESS, URL_VARIABLES, VAR_INTEGER, ) -from ..helpers import EventEmitter +from ..helpers import EventEmitter, now class Variable: @@ -35,6 +40,8 @@ def __init__(self, variables, vid, vtype, vname, init, status, ts): self._id = vid self._init = init self._last_edited = ts + self._last_update = now() + self._last_changed = now() self._name = vname self._status = status self._type = vtype @@ -65,11 +72,15 @@ def init(self, value): """Set the initial state and notify listeners.""" if self._init != value: self._init = value - self.status_events.notify( - {"status": self._status, "init": self._init, "ts": self._last_edited} - ) + self._last_changed = now() + self.status_events.notify(self.status_feedback) return self._init + @property + def last_changed(self): + """Return the UTC Time of the last status change for this node.""" + return self._last_changed + @property def last_edited(self): """Return the last edit time.""" @@ -82,6 +93,18 @@ def last_edited(self, value): self._last_edited = value return self._last_edited + @property + def last_update(self): + """Return the UTC Time of the last update for this node.""" + return self._last_update + + @last_update.setter + def last_update(self, value): + """Set the last update time.""" + if self._last_update != value: + self._last_update = value + return self._last_update + @property def protocol(self): """Return the protocol for this entity.""" @@ -102,11 +125,22 @@ def status(self, value): """Set the current node state and notify listeners.""" if self._status != value: self._status = value - self.status_events.notify( - {"status": self._status, "init": self._init, "ts": self._last_edited} - ) + self._last_changed = now() + self.status_events.notify(self.status_feedback) return self._status + @property + def status_feedback(self): + """Return information for a status change event.""" + return { + TAG_ADDRESS: self.address, + ATTR_STATUS: self._status, + ATTR_INIT: self._init, + ATTR_TS: self._last_edited, + ATTR_LAST_CHANGED: self._last_changed, + ATTR_LAST_UPDATE: self._last_update, + } + @property def vid(self): """Return the Variable ID.""" @@ -118,6 +152,7 @@ def update(self, wait_time=0): | wait_time: Seconds to wait before updating. """ + self._last_update = now() self._variables.update(wait_time) def set_init(self, val):