From 10cfb35b64b3f68576cd98ec676c6a57d17c9591 Mon Sep 17 00:00:00 2001 From: Paul Manzotti Date: Sat, 23 Sep 2023 13:52:40 +0100 Subject: [PATCH] Issues with unknown devices or errors are now handled --- geniushubclient/__init__.py | 33 +-- geniushubclient/const.py | 25 --- geniushubclient/issue.py | 79 +++++++ geniushubclient/session.py | 1 - ghclient.py | 40 ++-- tests/issues_v3_conversion/__init__.py | 0 .../genius_issue_data_test.py | 208 ++++++++++++++++++ 7 files changed, 314 insertions(+), 72 deletions(-) create mode 100644 geniushubclient/issue.py create mode 100644 tests/issues_v3_conversion/__init__.py create mode 100644 tests/issues_v3_conversion/genius_issue_data_test.py diff --git a/geniushubclient/__init__.py b/geniushubclient/__init__.py index 512c4f1..9206731 100644 --- a/geniushubclient/__init__.py +++ b/geniushubclient/__init__.py @@ -10,8 +10,9 @@ from datetime import datetime as dt from typing import Dict, List, Tuple # Any, Optional, Set -from .const import HUB_SW_VERSIONS, ISSUE_DESCRIPTION, ISSUE_TEXT, ZONE_MODE +from .const import HUB_SW_VERSIONS, ZONE_MODE from .device import GeniusDevice +from .issue import GeniusIssue from .session import GeniusService from .zone import GeniusZone, natural_sort @@ -148,36 +149,13 @@ def populate_objects( entities.append(entity) return entities, {e.id: e for e in entities} - def convert_issue(raw_json) -> Dict: - """Convert a issues's v3 JSON to the v1 schema.""" - description = ISSUE_DESCRIPTION.get(raw_json["id"], raw_json["id"]) - level = ISSUE_TEXT.get(raw_json["level"], str(raw_json["level"])) - - if "{zone_name}" in description: - zone_name = raw_json["data"]["location"] - if "{device_type}" in description: - # don't use nodeHash, it won't pick up (e.g. DCR - Channel 1) - # vice_type = DEVICE_HASH_TO_TYPE[raw_json["data"]["nodeHash"]] - device_type = self.device_by_id[raw_json["data"]["nodeID"]].data["type"] - - if "{zone_name}" in description and "{device_type}" in description: - description = description.format( - zone_name=zone_name, device_type=device_type - ) - elif "{zone_name}" in description: - description = description.format(zone_name=zone_name) - elif "{device_type}" in description: - description = description.format(device_type=device_type) - - return {"description": description, "level": level} - if self.api_version == 1: self._sense_mode = None # currently, no way to tell else: # self.api_version == 3: manager = [z for z in self._zones if z["iID"] == 0][0] self._sense_mode = bool(manager["lOptions"] & ZONE_MODE.Other) - # TODO: this looks dodgy: replacing rather than updating entitys + # TODO: this looks dodgy: replacing rather than updating entities self.zone_objs, self.zone_by_id = populate_objects( self._zones, "iID", self.zone_by_id, GeniusZone ) @@ -198,7 +176,10 @@ def convert_issue(raw_json) -> Dict: self.issues = self._issues self.version = self._version else: # self.api_version == 3: - self.issues = [convert_issue(raw_json) for raw_json in self._issues] + self.issues = [ + GeniusIssue(raw_json, self.device_by_id).data + for raw_json in self._issues + ] self.version = { "hubSoftwareVersion": self._version, "earliestCompatibleAPI": "https://my.geniushub.co.uk/v1", diff --git a/geniushubclient/const.py b/geniushubclient/const.py index e086a3c..48fa8bc 100644 --- a/geniushubclient/const.py +++ b/geniushubclient/const.py @@ -88,31 +88,6 @@ TPI=2048, ) # from app.js, search for '.ZoneFlags = {' -ISSUE_TEXT = {0: "information", 1: "warning", 2: "error"} -ISSUE_DESCRIPTION = { - "manager:no_boiler_controller": "The hub does not have a boiler controller assigned", - "manager:no_boiler_comms": "The hub has lost communication with the boiler controller", - "manager:no_temp": "The hub does not have a valid temperature", - "manager:weather": "Unable to fetch the weather data", # correct - "manager:weather_data": "Weather data -", - "zone:using_weather_temp": "{zone_name} is currently using the outside temperature", # correct - "zone:using_assumed_temp": "{zone_name} is currently using the assumed temperature", - "zone:tpi_no_temp": "{zone_name} currently has no valid temperature", # correct - "node:no_comms": "The {device_type} has lost communication with the Hub", - "node:not_seen": "The {device_type} in {zone_name} can not been found by the Hub", # correct - "node:low_battery": "The battery for the {device_type} in {zone_name} is dead and needs to be replaced", # correct - "node:warn_battery": "The battery for the {device_type} is low", - "node:assignment_limit_exceeded": "{device_type} has been assigned to too many zones", # for DCR channels -} # from app.js, search for: "node:, "zone:, "manager: - -# Example errors -# {'id': 'node:low_battery', 'level': 2, 'data': {'location': 'Room 2.2', -# 'nodeHash': '0x00000002A0107FFF', 'nodeID': '27', 'batteryLevel': 255}} -# {'id': 'node:not_seen', 'level': 2, 'data': {'location': 'Kitchen', -# 'nodeHash': '0x0000000000000000', 'nodeID': '4'}} -# {'id': 'zone:tpi_no_temp', 'level': 2, 'data': {'location': 'Temp'}} -# {'id': 'zone:using_weather_temp', 'level': 1, 'data': {'location': 'Test Rad'}} - IDAY_TO_DAY = { 0: "sunday", 1: "monday", diff --git a/geniushubclient/issue.py b/geniushubclient/issue.py new file mode 100644 index 0000000..570549e --- /dev/null +++ b/geniushubclient/issue.py @@ -0,0 +1,79 @@ +"""Python client library for the Genius Hub API.""" + +import logging +from typing import Dict + +_LOGGER = logging.getLogger(__name__) + + +class GeniusIssue: + """Class to hold information on any issues the hub is reporting""" + + # Example errors + # {'id': 'node:low_battery', 'level': 2, 'data': {'location': 'Room 2.2', + # 'nodeHash': '0x00000002A0107FFF', 'nodeID': '27', 'batteryLevel': 255}} + # {'id': 'node:not_seen', 'level': 2, 'data': {'location': 'Kitchen', + # 'nodeHash': '0x0000000000000000', 'nodeID': '4'}} + # {'id': 'zone:tpi_no_temp', 'level': 2, 'data': {'location': 'Temp'}} + # {'id': 'zone:using_weather_temp', 'level': 1, 'data': {'location': 'Test Rad'}} + + def __init__(self, raw_json, device_by_id) -> None: + self.id = id + self._raw = raw_json + self._device_by_id = device_by_id + + self._data = {} + self._issue_level = {0: "information", 1: "warning", 2: "error"} + self._issue_description = { + "manager:no_boiler_controller": "The hub does not have a boiler controller assigned", + "manager:no_boiler_comms": "The hub has lost communication with the boiler controller", + "manager:no_temp": "The hub does not have a valid temperature", + "manager:weather": "Unable to fetch the weather data", # correct + "manager:weather_data": "Weather data -", + "zone:using_weather_temp": "{zone_name} is currently using the outside temperature", # correct + "zone:using_assumed_temp": "{zone_name} is currently using the assumed temperature", + "zone:tpi_no_temp": "{zone_name} currently has no valid temperature", # correct + "node:no_comms": "The {device_type} has lost communication with the Hub", + "node:not_seen": "The {device_type} in {zone_name} can not been found by the Hub", # correct + "node:low_battery": "The battery for the {device_type} in {zone_name} is dead and needs to be replaced", # correct + "node:warn_battery": "The battery for the {device_type} is low", + "node:assignment_limit_exceeded": "{device_type} has been assigned to too many zones", # for DCR channels + } # from app.js, search for: "node:, "zone:, "manager: + + @property + def data(self) -> Dict: + def convert_issue(raw_json) -> Dict: + """Convert a issues's v3 JSON to the v1 schema.""" + unknown_error_message = ( + "Unknown error for {device_type} in {zone_name} returned by hub: " + + raw_json["id"] + ) + description = self._issue_description.get( + raw_json["id"], unknown_error_message + ) + level = self._issue_level.get(raw_json["level"], str(raw_json["level"])) + + if "{zone_name}" in description: + zone_name = raw_json["data"]["location"] + if "{device_type}" in description: + # don't use nodeHash, it won't pick up (e.g. DCR - Channel 1) + # vice_type = DEVICE_HASH_TO_TYPE[raw_json["data"]["nodeHash"]] + device_id = raw_json["data"]["nodeID"] + if device_id in self._device_by_id.keys(): + device = self._device_by_id[device_id] + device_type = device.data["type"] + else: + device_type = "Unknown device" + + if "{zone_name}" in description and "{device_type}" in description: + description = description.format( + zone_name=zone_name, device_type=device_type + ) + elif "{zone_name}" in description: + description = description.format(zone_name=zone_name) + elif "{device_type}" in description: + description = description.format(device_type=device_type) + + return {"description": description, "level": level} + + return convert_issue(self._raw) diff --git a/geniushubclient/session.py b/geniushubclient/session.py index 05ddd6d..f55967b 100644 --- a/geniushubclient/session.py +++ b/geniushubclient/session.py @@ -14,7 +14,6 @@ class GeniusService: """Handle all communication to the Genius Hub.""" def __init__(self, hub_id, username=None, password=None, session=None) -> None: - self._session = session if session else aiohttp.ClientSession() if username or password: # use the v3 Api diff --git a/ghclient.py b/ghclient.py index e97a7b1..5501559 100644 --- a/ghclient.py +++ b/ghclient.py @@ -146,25 +146,6 @@ def _parse_args(): async def main(loop): """Return the JSON as requested.""" - args = _parse_args() - # print(args) - if args is None: - return - - if args.debug_mode > 0: - import ptvsd - - print(f"Debugging is enabled, listening on: {DEBUG_ADDR}:{DEBUG_PORT}.") - ptvsd.enable_attach(address=(DEBUG_ADDR, DEBUG_PORT)) - - if args.debug_mode > 1: - print("Waiting for debugger to attach...") - ptvsd.wait_for_attach() - print("Debugger is attached!") - - if args.debug_mode > 2: - breakpoint() - # Option of providing test data (as list of Dicts), or leave both as None if FILE_MODE: with open("raw_zones.json", mode="r") as fh: @@ -177,6 +158,25 @@ async def main(loop): session = None hub = GeniusTestHub(zones_json=z, device_json=d, debug=True) else: + args = _parse_args() + # print(args) + if args is None: + return + + if args.debug_mode > 0: + import ptvsd + + print(f"Debugging is enabled, listening on: {DEBUG_ADDR}:{DEBUG_PORT}.") + ptvsd.enable_attach(address=(DEBUG_ADDR, DEBUG_PORT)) + + if args.debug_mode > 1: + print("Waiting for debugger to attach...") + ptvsd.wait_for_attach() + print("Debugger is attached!") + + if args.debug_mode > 2: + breakpoint() + session = aiohttp.ClientSession() hub = GeniusHub( hub_id=args.hub_id, @@ -186,7 +186,7 @@ async def main(loop): debug=args.debug_mode, ) - hub.verbosity = args.verbosity + hub.verbosity = args.verbosity await hub.update() # initialise: enumerate all zones, devices & issues # ait hub.update() # for testing, do twice in a row to check for no duplicates diff --git a/tests/issues_v3_conversion/__init__.py b/tests/issues_v3_conversion/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/issues_v3_conversion/genius_issue_data_test.py b/tests/issues_v3_conversion/genius_issue_data_test.py new file mode 100644 index 0000000..6aa8eb0 --- /dev/null +++ b/tests/issues_v3_conversion/genius_issue_data_test.py @@ -0,0 +1,208 @@ +""" + Tests for the GeniusZone class + """ + +import unittest +from unittest.mock import Mock + +from geniushubclient.issue import GeniusIssue + + +class GeniusIssueTests(unittest.TestCase): + """ + Test for the GeniusIssue Class, override data. + """ + + _id = "Issue Id" + _device_id = 12 + _zone_name = "Kitchen" + _description = "The {device_type} in {zone_name} can not been found by the Hub" + _device_type = "Room Sensor" + + _general_errors = { + "manager:no_boiler_controller": "The hub does not have a boiler controller assigned", + "manager:no_boiler_comms": "The hub has lost communication with the boiler controller", + "manager:no_temp": "The hub does not have a valid temperature", + "manager:weather": "Unable to fetch the weather data", # correct + "manager:weather_data": "Weather data -", + } # from app.js, search for: "node:, "zone:, "manager: + + _zone_errors = { + "zone:using_weather_temp": "{zone_name} is currently using the outside temperature", # correct + "zone:using_assumed_temp": "{zone_name} is currently using the assumed temperature", + "zone:tpi_no_temp": "{zone_name} currently has no valid temperature", # correct + } # from app.js, search for: "node:, "zone:, "manager: + + _device_errors = { + "node:no_comms": "The {device_type} has lost communication with the Hub", + "node:warn_battery": "The battery for the {device_type} is low", + "node:assignment_limit_exceeded": "{device_type} has been assigned to too many zones", # for DCR channels + } # from app.js, search for: "node:, "zone:, "manager: + + _zone_and_device_errors = { + "node:not_seen": "The {device_type} in {zone_name} can not been found by the Hub", # correct + "node:low_battery": "The battery for the {device_type} in {zone_name} is dead and needs to be replaced", # correct + } # from app.js, search for: "node:, "zone:, "manager: + + raw_json = { + "data": { + "location": _zone_name, + "nodeHash": "0x0000000200040005", + "nodeID": _device_id, + }, + "id": "node:not_seen", + "level": 2, + } + + def setUp(self): + zone = Mock() + zone.data = {} + zone.data["type"] = self._device_type + self.zone = zone + + def test_when_unknown_error_for_known_device_then_issue_message_correct( + self, + ): # noqa: E501 + "Check that unknown errors for known devices are handled" + + device_by_id = {self._device_id: self.zone} + + unknown_error_id = "error:who_knows" + self.raw_json["id"] = unknown_error_id + genius_issue = GeniusIssue(self.raw_json, device_by_id) + + error_message = ( + "Unknown error for {device_type} in {zone_name} returned by hub: {error_id}" + ) + self.assertEqual( + genius_issue.data["description"], + error_message.format( + device_type=self._device_type, + zone_name=self._zone_name, + error_id=unknown_error_id, + ), + ) + + def test_when_unknown_error_for_unknown_device_then_issue_message_correct( + self, + ): # noqa: E501 + "Check that unknown errors for unknown devices are handled" + + device_by_id = {1: ""} + + unknown_error_id = "error:who_knows" + self.raw_json["id"] = unknown_error_id + genius_issue = GeniusIssue(self.raw_json, device_by_id) + + error_message = ( + "Unknown error for {device_type} in {zone_name} returned by hub: {error_id}" + ) + self.assertEqual( + genius_issue.data["description"], + error_message.format( + device_type="Unknown device", + zone_name=self._zone_name, + error_id=unknown_error_id, + ), + ) + + def test_when_general_error_then_issue_message_correct(self): # noqa: E501 + "Check that general errors are handled" + + device_by_id = {self._device_id: self.zone} + + for error_id, error_message in self._general_errors.items(): + with self.subTest(error_id=error_id, error_message=error_message): + self.raw_json["id"] = error_id + genius_issue = GeniusIssue(self.raw_json, device_by_id) + + self.assertEqual(genius_issue.data["description"], error_message) + + def test_when_zone_error_then_issue_message_correct( + self, + ): # noqa: E501 + "Check that zone errors are handled" + + device_by_id = {self._device_id: self.zone} + + for error_id, error_message in self._zone_errors.items(): + with self.subTest(error_id=error_id, error_message=error_message): + self.raw_json["id"] = error_id + genius_issue = GeniusIssue(self.raw_json, device_by_id) + + self.assertEqual( + genius_issue.data["description"], + error_message.format(zone_name=self._zone_name), + ) + + def test_when_device_error_for_known_device_then_issue_message_correct( + self, + ): # noqa: E501 + "Check that device errors for known devices are handled" + + device_by_id = {self._device_id: self.zone} + + for error_id, error_message in self._device_errors.items(): + with self.subTest(error_id=error_id, error_message=error_message): + self.raw_json["id"] = error_id + genius_issue = GeniusIssue(self.raw_json, device_by_id) + + self.assertEqual( + genius_issue.data["description"], + error_message.format(device_type=self._device_type), + ) + + def test_when_device_error_for_unknown_device_then_issue_message_correct( + self, + ): # noqa: E501 + "Check that device errors for unknown devices are handled" + + device_by_id = {1: ""} + + for error_id, error_message in self._device_errors.items(): + with self.subTest(error_id=error_id, error_message=error_message): + self.raw_json["id"] = error_id + genius_issue = GeniusIssue(self.raw_json, device_by_id) + + self.assertEqual( + genius_issue.data["description"], + error_message.format(device_type="Unknown device"), + ) + + def test_when_zone_and_device_error_for_known_device_then_issue_message_correct( + self, + ): # noqa: E501 + "Check that zone and device errors for known devices are handled" + + device_by_id = {self._device_id: self.zone} + + for error_id, error_message in self._zone_and_device_errors.items(): + with self.subTest(error_id=error_id, error_message=error_message): + self.raw_json["id"] = error_id + genius_issue = GeniusIssue(self.raw_json, device_by_id) + + self.assertEqual( + genius_issue.data["description"], + error_message.format( + device_type=self._device_type, zone_name=self._zone_name + ), + ) + + def test_when_zone_and_device_error_for_unknown_device_then_issue_message_correct( + self, + ): # noqa: E501 + "Check that zone and device errors for unknown devices are handled" + + device_by_id = {1: ""} + + for error_id, error_message in self._zone_and_device_errors.items(): + with self.subTest(error_id=error_id, error_message=error_message): + self.raw_json["id"] = error_id + genius_issue = GeniusIssue(self.raw_json, device_by_id) + + self.assertEqual( + genius_issue.data["description"], + error_message.format( + device_type="Unknown device", zone_name=self._zone_name + ), + )