From 91c95149a3ea9a0bfda0450a6c6d509826e24ee9 Mon Sep 17 00:00:00 2001 From: Mehmet Can Date: Tue, 21 Nov 2023 18:45:04 +0000 Subject: [PATCH] V2.0 --- neohubapi/neohub.py | 133 +++++++++++++++++++++++++++++------------- neohubapi/neostat.py | 125 +++++++++++++++++++++++---------------- scripts/neohub_cli.py | 13 +++-- setup.py | 2 +- 4 files changed, 174 insertions(+), 99 deletions(-) diff --git a/neohubapi/neohub.py b/neohubapi/neohub.py index c675e28..77bbe9c 100644 --- a/neohubapi/neohub.py +++ b/neohubapi/neohub.py @@ -34,7 +34,7 @@ class NeoHub: def __init__(self, host='Neo-Hub', port=4242, request_timeout=60, request_attempts=1, token=None): self._logger = logging.getLogger('neohub') self._host = host - self._port = port + self._port = int(port) self._request_timeout = request_timeout self._request_attempts = request_attempts self._token = token @@ -67,7 +67,7 @@ class NeoHub: async def _send(self, message, expected_reply=None): last_exception = None writer = None - for attempt in range(1, self._request_attempts+1): + for attempt in range(1, self._request_attempts + 1): try: if self._token is not None: # Websocket connection on port 4243, introduced in v3 of the API on 12/12/21 @@ -98,7 +98,7 @@ class NeoHub: self._logger.debug("Using legacy connection") reader, writer = await asyncio.open_connection(self._host, self._port) data = await asyncio.wait_for( - self._send_message(reader, writer, message), timeout=self._request_timeout) + self._send_message(reader, writer, message), timeout=self._request_timeout) json_string = data.decode('utf-8') self._logger.debug(f"Received message: {json_string}") @@ -133,6 +133,15 @@ class NeoHub: raise last_exception return False + def _devices_to_device_ids(self, devices: [NeoStat]): + """ + Returns the list of device ids + """ + try: + return [x.device_id for x in devices] + except (TypeError, AttributeError): + raise NeoHubUsageError('devices must be a list of NeoStat objects') + def _devices_to_names(self, devices: [NeoStat]): """ Returns the list of device names @@ -290,9 +299,9 @@ class NeoHub: result = await self._send(message) result.start = datetime.datetime.strptime( - result.start.strip(), "%a %b %d %H:%M:%S %Y") if result.start else None + result.start.strip(), "%a %b %d %H:%M:%S %Y") if result.start else None result.end = datetime.datetime.strptime( - result.end.strip(), "%a %b %d %H:%M:%S %Y") if result.end else None + result.end.strip(), "%a %b %d %H:%M:%S %Y") if result.end else None return result async def cancel_holiday(self): @@ -435,44 +444,9 @@ class NeoHub: """ message = {"GET_LIVE_DATA": 0} - hub_data = await self._send(message) + live_data = await self._send(message) - # We need the engineers data to get things like Device Type, Sensor Mode etc. - get_eng_data_msg = {"GET_ENGINEERS": 0} - eng_hub_data = await self._send(get_eng_data_msg) - - devices = hub_data.devices - delattr(hub_data, "devices") - - # Combine the live data and engineers data to produce a full dataset to work from. - # Start by working through each device in the devices - for device in devices: - - # Find matching device ID in Engineers data - for key, value in eng_hub_data.__dict__.items(): - if device.DEVICE_ID == value.DEVICE_ID: - - # Now add the engineers data dictionary entries to the existing device dictionary. - for x in value.__dict__.items(): - setattr(device, x[0], x[1]) - - thermostat_list = list(filter(lambda device: hasattr(device, 'THERMOSTAT') and device.THERMOSTAT, devices)) - timeclock_list = list(filter(lambda device: hasattr(device, 'TIMECLOCK') and device.TIMECLOCK, devices)) - - thermostats = [] - timeclocks = [] - - for thermostat in thermostat_list: - thermostats.append(NeoStat(self, thermostat)) - - for timeclock in timeclock_list: - timeclocks.append(NeoStat(self, timeclock)) - - devices = {} - devices['thermostats'] = thermostats - devices['timeclocks'] = timeclocks - - return hub_data, devices + return live_data async def permit_join(self, name, timeout_s: int = 120): """ @@ -615,6 +589,32 @@ class NeoHub: result = await self._send(message, reply) return result + async def set_manual(self, state: bool, devices: [NeoStat]): + """ + Controls the timeclock built into the neoplug, can be enabled and disabled to allow for manual operation. + This function works only with NeoPlugs and does not work on NeoStats that are in timeclock mode. + """ + + names = self._devices_to_names(devices) + message = {"MANUAL_ON" if state else "MANUAL_OFF": names} + reply = {"result": "manual on" if state else "manual off"} + + result = await self._send(message, reply) + return result + + async def set_hold(self, temperature: int, hours: int, minutes: int, devices: [NeoStat]): + """ + Tells a thermostat to maintain the current temperature for a fixed time. + """ + + names = self._devices_to_names(devices) + ids = self._devices_to_device_ids(devices) + message = {"HOLD": [{"temp": temperature, "hours": hours, "minutes": minutes, "id": f"{ids}"}, names]} + reply = {"result": "temperature on hold"} + + result = await self._send(message, reply) + return result + async def set_timer_hold(self, state: bool, minutes: int, devices: [NeoStat]): """ Turns the output of timeclock on or off for certain duration @@ -628,3 +628,52 @@ class NeoHub: result = await self._send(message, reply) return result + + async def get_engineers(self): + """ + Get engineers data + """ + message = {"GET_ENGINEERS": 0} + + data = await self._send(message) + return data + + async def get_devices_data(self): + """ + Returns live data from hub and all devices + """ + + # Get live data from Neohub + live_hub_data = await self.get_live_data() + + # Get the engineers data from the Neohub for things like Device Type, Sensor Mode etc. + eng_hub_data = await self.get_engineers() + + # Remove the devices node from the hub_data. + devices = live_hub_data.devices + delattr(live_hub_data, "devices") + + neo_devices = [] + + # Iterate through all devices in list returned by the get_live_data and enrich this object with the attributes + # from engineers data. + for device in devices: + # Find matching device ID in Engineers data + for engineers_data_key, engineers_data_value in eng_hub_data.__dict__.items(): + # If we found a matching device ID combine the engineering data into the live data + if device.DEVICE_ID == engineers_data_value.DEVICE_ID: + for x in engineers_data_value.__dict__.items(): + # FLOOR_LIMIT is a duplicate key, do not overwrite this. + if x[0] == "FLOOR_LIMIT": + setattr(device, "ENG_FLOOR_LIMIT", x[1]) + else: + setattr(device, x[0], x[1]) + + for neo_device in devices: + neo_devices.append(NeoStat(self, neo_device)) + + devices = { + 'neo_devices': neo_devices + } + + return devices diff --git a/neohubapi/neostat.py b/neohubapi/neostat.py index e2d7c93..873283f 100644 --- a/neohubapi/neostat.py +++ b/neohubapi/neostat.py @@ -23,51 +23,56 @@ class NeoStat(SimpleNamespace): self._hub = hub self._simple_attrs = ( - 'active_level', - 'active_profile', - 'available_modes', - 'away', - 'cool_on', - 'cool_temp', - 'current_floor_temperature', - 'date', - 'device_id', - 'fan_control', - 'fan_speed', - 'floor_limit', - 'hc_mode', - 'heat_mode', - 'heat_on', - 'hold_cool', - 'fan_control', - 'fan_speed', - 'hc_mode', - 'heat_mode', - 'heat_on', - 'hold_cool', - 'hold_off', - 'hold_on', - 'hold_temp', - 'hold_time', # This is updated below. - 'holiday', - 'lock', - 'low_battery', - 'manual_off', - 'modelock', - 'modulation_level', - 'offline', - 'pin_number', - 'preheat_active', - 'prg_temp', - 'prg_timer', - 'standby', - 'switch_delay_left', # This is updated below. - 'temporary_set_flag', - 'time', # This is updated below. - 'timer_on', - 'window_open', - 'write_count' - ) + 'active_level', + 'active_profile', + 'available_modes', + 'away', + 'cool_on', + 'cool_temp', + 'current_floor_temperature', + 'date', + 'device_id', + 'device_type', + 'fan_control', + 'fan_speed', + 'floor_limit', + 'hc_mode', + 'heat_mode', + 'heat_on', + 'fan_control', + 'fan_speed', + 'hc_mode', + 'heat_mode', + 'heat_on', + 'hold_cool', + 'hold_hours', + 'hold_mins', + 'hold_off', + 'hold_on', + 'hold_state', + 'hold_temp', + 'hold_time', # This is updated below. + 'holiday', + 'lock', + 'low_battery', + 'manual_off', + 'modelock', + 'modulation_level', + 'offline', + 'pin_number', + 'preheat_active', + 'prg_temp', + 'prg_timer', + 'sensor_mode', + 'standby', + 'stat_version', + 'switch_delay_left', # This is updated below. + 'temporary_set_flag', + 'time', # This is updated below. + 'timer_on', + 'window_open', + 'write_count' + ) for a in self._simple_attrs: data_attr = a.upper() @@ -92,11 +97,31 @@ class NeoStat(SimpleNamespace): _switch_delay_left = datetime.strptime(self.switch_delay_left, "%H:%M") self.switch_delay_left = timedelta( - hours=_switch_delay_left.hour, - minutes=_switch_delay_left.minute) + hours=_switch_delay_left.hour, + minutes=_switch_delay_left.minute) _time = datetime.strptime(self.time, "%H:%M") self.time = timedelta(hours=_time.hour, minutes=_time.minute) + # 35 Degrees appears to be the hard limit in app, 5 degrees appears to be the hard lower limit. + self.max_temperature_limit = 35 + self.min_temperature_limit = 5 + + # Ensure that TIMECLOCKS are correctly reported + if hasattr(self._data_, 'TIMECLOCK'): + self.time_clock_mode = True + else: + self.time_clock_mode = False + + # Adding an attribute to deal with battery powered or not. + if self.device_type in [2, 5, 13, 14]: + self.battery_powered = True + else: + self.battery_powered = False + + if self.device_type == 1: + # We're dealing with a NeoStat V1 there's a known bug in the API, so we must patch the hc_mode + self.hc_mode = "HEATING" + def __str__(self): """ String representation. @@ -105,14 +130,14 @@ class NeoStat(SimpleNamespace): for elem in dir(self): if not callable(getattr(self, elem)) and not elem.startswith('_'): data_elem.append(elem) - out = 'HeatMiser NeoStat (%s):\n' % (self.name) + out = f'HeatMiser NeoStat {self.name}:\n' for elem in data_elem: - out += ' - %s: %s\n' % (elem, getattr(self, elem)) + out += f' - {elem}: {getattr(self, elem)}\n' return out async def identify(self): """ - Flashes red LED light + Flashes Devices LED light """ message = {"IDENTIFY_DEV": self.name} diff --git a/scripts/neohub_cli.py b/scripts/neohub_cli.py index 18366ca..f1caf5a 100755 --- a/scripts/neohub_cli.py +++ b/scripts/neohub_cli.py @@ -35,9 +35,8 @@ class NeoHubCLIArgumentError(Error): class NeoHubCLI(object): """A runner for neohub_cli operations.""" - def __init__(self, command, args, hub_ip=None, hub_token=None): - hub_port = 4242 if hub_token is None else 4243 - self._hub = NeoHub(host=hub_ip, port=hub_port, token=hub_token) + def __init__(self, command, args, hub_ip=None, hub_port=4242, token=None): + self._hub = NeoHub(host=hub_ip, port=hub_port, token=token) self._command = command self._args = args # live data cached from the neohub. We assume this data will remain current @@ -280,7 +279,9 @@ class NeoHubCLI(object): async def main(): argp = argparse.ArgumentParser(description='CLI to neohub devices') argp.add_argument('--hub_ip', help='IP address of NeoHub', default=None) - argp.add_argument('--hub_token', help='API token', default=None) + argp.add_argument( + '--hub_port', help='Port number of NeoHub to talk to', default=4242) + argp.add_argument('--token', help='Token', default=None) argp.add_argument('--format', help='Output format', default='list') argp.add_argument('command', help='Command to issue') argp.add_argument('arg', help='Arguments to command', nargs='*') @@ -291,8 +292,8 @@ async def main(): args.command, args.arg, hub_ip=args.hub_ip, - hub_token=args.hub_token, - ) + hub_port=int(args.hub_port), + token=args.token) m = await nhc.callable() if m: result = await m() diff --git a/setup.py b/setup.py index 457bfb2..58b9166 100755 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ with open("README.md", "r", encoding="utf-8") as fh: setuptools.setup( name="neohubapi", - version="1.1", + version="2.0", description="Async library to communicate with Heatmiser NeoHub 2 API.", url="https://gitlab.com/neohubapi/neohubapi/", author="Andrius Štikonas",