Compare commits

...

8 Commits

Author SHA1 Message Date
Andrius Štikonas 1121045275 Update README to adjust for API changes. 2024-01-21 17:04:00 +00:00
Mehmet Can 91c95149a3 V2.0 2023-11-21 18:45:04 +00:00
Andrius Štikonas 2305ac05bd Fix flake8 linting issues. 2023-10-25 01:41:35 +01:00
Vlad Firoiu 5ebf6c8aa1 CLI: allow websocket connection by specifying api token. 2023-10-24 18:53:25 +01:00
Andrius Štikonas d881f73fde Bump version to 1.1. 2023-05-12 00:02:45 +01:00
Andrius Štikonas e0c591f6f8 Bump version to 1.0. 2023-05-04 18:42:48 +01:00
MindrustUK 7d57078906 Update 'neohubapi/neohub.py'
Removed unneeded enumeration
2023-05-04 17:53:41 +01:00
MindrustUK 3ad3a73bb8 Adding Engineering data to Live Data
Adds Engineers data to Live Data to be surfaced in Home-Assistant to be able to correctly act against DEVICE_TYPE.
2023-05-04 17:42:51 +01:00
5 changed files with 182 additions and 88 deletions

View File

@ -34,8 +34,9 @@ async def run():
# Or, for a websocket connection:
# hub = neohub.Neohub(port=4243, token='xxx-xxxxxxx')
system = await hub.get_system()
hub_data, devices = await hub.get_live_data()
for device in devices['thermostats']:
hub_data = await hub.get_devices_data()
devices = hub_data['neo_devices']
for device in devices:
print(f"Temperature in zone {device.name}: {device.temperature}")
await device.identify()
@ -54,5 +55,5 @@ $ neohub_cli.py help # Shows all commands
$ neohub_cli.py help set_time # Displays help for the set_time function
$ neohub_cli.py --hub_ip=myneohub set_time "2021-01-31 15:43:00" # Specify times like this
$ neohub_cli.py --hub_ip=myneohub set_lock 1234 "Living Room" # Name NeoStats like this.
$ neohub_cli.py --hub_ip=myneohub --hub_token=XXX get_system # Get system variables with websocket connection
```

View File

@ -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,27 +444,9 @@ class NeoHub:
"""
message = {"GET_LIVE_DATA": 0}
live_data = await self._send(message)
hub_data = await self._send(message)
devices = hub_data.devices
delattr(hub_data, "devices")
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):
"""
@ -598,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
@ -611,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

View File

@ -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}

View File

@ -35,8 +35,8 @@ class NeoHubCLIArgumentError(Error):
class NeoHubCLI(object):
"""A runner for neohub_cli operations."""
def __init__(self, command, args, hub_ip=None, hub_port=4242):
self._hub = NeoHub(host=hub_ip, port=hub_port)
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
@ -90,9 +90,9 @@ class NeoHubCLI(object):
arg_types = []
for p in sig.parameters:
a = sig.parameters[p].annotation
if type(a) == type:
if isinstance(a, type):
arg_types.append(a)
elif type(a) == list:
elif isinstance(a, list):
if a[0] == NeoStat:
arg_types.append(a[0])
else:
@ -203,14 +203,14 @@ class NeoHubCLI(object):
if special_case:
return special_case(raw_result, output_format)
if type(raw_result) == bool:
if isinstance(raw_result, bool):
return 'Command Succeeded' if raw_result else 'Command Failed'
if type(raw_result) in (int, str):
return f'{raw_result}'
if type(raw_result) != types.SimpleNamespace:
if type(raw_result) == dict:
if not isinstance(raw_result, types.SimpleNamespace):
if isinstance(raw_result, dict):
raw_result = types.SimpleNamespace(**raw_result)
else:
raise NeoHubCLIInternalError(
@ -224,7 +224,7 @@ class NeoHubCLI(object):
"""
if type(val) in (ScheduleFormat, Weekday):
return val.value
elif type(val) == NeoStat:
elif isinstance(val, NeoStat):
return f'[NeoStat: {val.name}]'
else:
return val
@ -281,6 +281,7 @@ async def main():
argp.add_argument('--hub_ip', help='IP address of NeoHub', 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,7 +292,8 @@ async def main():
args.command,
args.arg,
hub_ip=args.hub_ip,
hub_port=args.hub_port)
hub_port=int(args.hub_port),
token=args.token)
m = await nhc.callable()
if m:
result = await m()

View File

@ -11,7 +11,7 @@ with open("README.md", "r", encoding="utf-8") as fh:
setuptools.setup(
name="neohubapi",
version="0.9",
version="2.0",
description="Async library to communicate with Heatmiser NeoHub 2 API.",
url="https://gitlab.com/neohubapi/neohubapi/",
author="Andrius Štikonas",