From 59f66af98bf1d2e6fd61d9146c9f7dc0e2804d01 Mon Sep 17 00:00:00 2001 From: david Date: Sun, 23 Jun 2024 15:28:23 +0200 Subject: [PATCH] feat: zodiac pool roboter --- README.md | 10 + pollect/__init__.py | 2 +- pollect/libs/api/Serializable.py | 40 ++++ pollect/libs/api/__init__.py | 0 pollect/libs/zodiac/Models.py | 303 ++++++++++++++++++++++++++++ pollect/libs/zodiac/ZodiacApi.py | 153 ++++++++++++++ pollect/libs/zodiac/__init__.py | 0 pollect/sources/ZodiacPoolSource.py | 64 ++++++ tests/test_ZodiacApi.py | 13 ++ 9 files changed, 584 insertions(+), 1 deletion(-) create mode 100644 pollect/libs/api/Serializable.py create mode 100644 pollect/libs/api/__init__.py create mode 100644 pollect/libs/zodiac/Models.py create mode 100644 pollect/libs/zodiac/ZodiacApi.py create mode 100644 pollect/libs/zodiac/__init__.py create mode 100644 pollect/sources/ZodiacPoolSource.py create mode 100644 tests/test_ZodiacApi.py diff --git a/README.md b/README.md index 51f7055..b240d41 100644 --- a/README.md +++ b/README.md @@ -300,6 +300,16 @@ of openhab (since it doesn't include all items). |-------|----------------| | url | URL to openhab | +## Zodiac Pool Cleaner `ZodiacPool` +Provides metrics about the current state and remaining duration of the cleaning +cycle. This source has been tested with the Zodiac Alpha 63 IQ and might +also work with other Zodiac devices. + +| Param | Desc | +|----------|---------------------| +| user | Username | +| password | Password | + ## Audi MMI `MMI` Connects to the audi MMI backend and collects data. Requires the [audi api](https://github.com/davidgiga1993/AudiAPI) diff --git a/pollect/__init__.py b/pollect/__init__.py index 8ece8b1..58d478a 100644 --- a/pollect/__init__.py +++ b/pollect/__init__.py @@ -1 +1 @@ -__version__ = '1.1.21' +__version__ = '1.2.0' diff --git a/pollect/libs/api/Serializable.py b/pollect/libs/api/Serializable.py new file mode 100644 index 0000000..07cd576 --- /dev/null +++ b/pollect/libs/api/Serializable.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from typing import Dict, List + + +class Serializeable: + def __init__(self): + self._data: Dict[str, any] = {} + + @staticmethod + def deserialize_from_data(data: Dict[str, any], dto: Serializeable | List[Serializeable]) -> any: + if isinstance(dto, list): + if not isinstance(data, list): + raise ValueError(f'Expected list, but got {data}') + + dto_list = [] + dto_type = type(dto[0]) + for sub in data: + dto = dto_type() + dto.deserialize(sub) + dto_list.append(dto) + return dto_list + + dto.deserialize(data) + return dto + + def deserialize(self, data: Dict[str, any]): + self._data = data + for key, default_val in self.__dict__.items(): + if key == '_data': + continue + + if isinstance(default_val, Serializeable): + default_val.deserialize(data.get(key, {})) + continue + + self.__dict__[key] = data.get(key, default_val) + + def get_raw(self) -> Dict[str, any]: + return self._data diff --git a/pollect/libs/api/__init__.py b/pollect/libs/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pollect/libs/zodiac/Models.py b/pollect/libs/zodiac/Models.py new file mode 100644 index 0000000..7dcbd8c --- /dev/null +++ b/pollect/libs/zodiac/Models.py @@ -0,0 +1,303 @@ +import base64 +import json +import time +from typing import Dict, Optional + +from pollect.libs.api.Serializable import Serializeable + + +class SystemInfo(Serializeable): + def __init__(self): + super().__init__() + self.id: int = 0 + self.serial_number: str = '' + self.created_at: str = '' + self.updated_at: str = '' + self.name: str = '' + self.device_type: str = '' + self.owner_id: str = '' + self.updating: bool = False + self.firmware_version: Optional[str] = None + self.target_firmware_version: Optional[str] = None + + +class Credentials(Serializeable): + def __init__(self): + super().__init__() + self.AccessKeyId: str = '' + self.SecretKey: str = '' + self.Expiration: str = '' + self.IdentityId: str = '' + + +class OAuthPool(Serializeable): + def __init__(self): + super().__init__() + self.AccessToken: str = '' + self.ExpiresIn: int = 0 + self.TokenType: str = 'Bearer' + self.RefreshToken: str = '' + self.IdToken: str = '' + + +class LoginReply(Serializeable): + def __init__(self): + super().__init__() + self.username: str = '' + self.email: str = '' + self.first_name: str = '' + self.last_name: str = '' + self.address: str = '' + self.address_1: str = '' + self.address_2: str = '' + self.city: str = '' + self.state: str = '' + self.country: str = '' + self.postal_code: str = '' + self.id: int = 0 + self.authentication_token: str = '' + self.session_id: str = '' + self.created_at: str = '' + self.updated_at: str = '' + self.time_zone: str = '' + self.phone: str = '' + self.opt_in_1: str = '' + self.opt_in_2: str = '' + self.role: str = '' + self.cognitoPool: Dict[str, str] = {} + self.credentials: Credentials = Credentials() + self.userPoolOAuth: OAuthPool = OAuthPool() + + def is_logged_in(self) -> bool: + return self.userPoolOAuth.ExpiresIn != 0 + + def is_expired(self) -> bool: + # Very crude JWT parsing + jwt_payload_b64 = self.userPoolOAuth.IdToken.split('.')[1] + missing_padding = len(jwt_payload_b64) % 4 + if missing_padding: + jwt_payload_b64 += '=' * (4 - missing_padding) + jwt_payload = base64.b64decode(jwt_payload_b64) + jwt_payload = json.loads(jwt_payload) + exp = jwt_payload.get('exp', 0) + return time.time() >= exp + + +class PoolCleanerInfo(Serializeable): + def __init__(self): + super().__init__() + self.deviceId: str = '' + self.state = PoolCleanerState() + self.ts: int = 0 + + +class PoolCleanerState(Serializeable): + def __init__(self): + super().__init__() + self.reported = ReportedPoolCleanerState() + + +class ReportedPoolCleanerState(Serializeable): + def __init__(self): + super().__init__() + self.aws = AwsState() + self.equipment = Equipment() + self.dt: str = '' + """ + Device type(?) + cb: Battery powered device, + vr: Line powered device + """ + + self.vt: str = '' + """ + Some sort of part number? + """ + + +class Equipment(Serializeable): + def __init__(self): + super().__init__() + self.robot = Robot() + + +class ProgramCycles: + WATERLINE = 0 + QUICK_CLEAN = 1 + SMART_CLEAN = 2 + DEEP_CLEAN = 3 + CUSTOM = 4 + + +class Robot(Serializeable): + def __init__(self): + super().__init__() + self.equipmentId: str = '' + self.errorCode: int = 0 + self.errorState: int = 0 + self.canister: int = 0 + self.durations = CycleDurations() + self.state: int = 0 + """ + State of the device + 0: Stopped + 1: Running + 2: Remote control + """ + self.prCyc: int = 0 + """ + See PROGRAM_CYCLES + """ + + self.stepper: int = 0 + self.stepperAdjTime: int = 0 + self.totalHours: int = 0 + self.customCyc: int = 0 + self.customIntensity: int = 0 + self.cycleStartTime: int = 0 + """ + Unix timestamp when the clean cycle was started + """ + + self.firstSmrtFlag: int = 0 + self.liftControl: int = 0 + self.logger: int = 0 + self.repeat: int = 0 + + self.rmt_ctrl: int = 0 + """ + Indicates if remote control is enabled + """ + + self.scanTimeDuration: int = 0 + + # Schedules + self.schConf0Enable: int = 0 + self.schConf0Hour: int = 0 + self.schConf0Min: int = 0 + self.schConf0Prt: int = 0 + self.schConf0WDay: int = 0 + self.schConf1Enable: int = 0 + self.schConf1Hour: int = 0 + self.schConf1Min: int = 0 + self.schConf1Prt: int = 0 + self.schConf1WDay: int = 0 + self.schConf2Enable: int = 0 + self.schConf2Hour: int = 0 + self.schConf2Min: int = 0 + self.schConf2Prt: int = 0 + self.schConf2WDay: int = 0 + self.schConf3Enable: int = 0 + self.schConf3Hour: int = 0 + self.schConf3Min: int = 0 + self.schConf3Prt: int = 0 + self.schConf3WDay: int = 0 + self.schConf4Enable: int = 0 + self.schConf4Hour: int = 0 + self.schConf4Min: int = 0 + self.schConf4Prt: int = 0 + self.schConf4WDay: int = 0 + self.schConf5Enable: int = 0 + self.schConf5Hour: int = 0 + self.schConf5Min: int = 0 + self.schConf5Prt: int = 0 + self.schConf5WDay: int = 0 + self.schConf6Enable: int = 0 + self.schConf6Hour: int = 0 + self.schConf6Min: int = 0 + self.schConf6Prt: int = 0 + self.schConf6WDay: int = 0 + + def get_remaining_time(self) -> int: + """ + Returns the number of seconds until the cleaning cycle completes + """ + duration_sec = self.get_duration() * 60 + delta_sec = time.time() - self.cycleStartTime + return round(duration_sec - delta_sec) + + def get_duration(self) -> int: + """ + Returns the duration of the current program cycle. + :return: Duration in minutes + """ + if self.prCyc == ProgramCycles.WATERLINE: + return self.durations.waterTim + if self.prCyc == ProgramCycles.QUICK_CLEAN: + return self.durations.quickTim + if self.prCyc == ProgramCycles.SMART_CLEAN: + if self.firstSmrtFlag != 0: + return self.durations.firstSmartTim + return self.durations.smartTim + if self.prCyc == ProgramCycles.DEEP_CLEAN: + return self.durations.deepTim + if self.prCyc == ProgramCycles.CUSTOM: + return self.durations.customTim + raise ValueError(f'No duration for program cycle {self.prCyc}') + + def is_running(self) -> bool: + return self.state != 0 + + +class CycleDurations(Serializeable): + def __init__(self): + super().__init__() + self.customTim: int = 0 + """ + Custom cycle duration in minutes + """ + + self.deepTim: int = 0 + """ + Deep clean cycle duration in minutes + """ + + self.firstSmartTim: int = 0 + """ + First-time smart clean cycle duration in minutes + """ + + self.smartTim: int = 0 + """ + Smart clean cycle duration in minutes + """ + + self.quickTim: int = 0 + """ + Quick clean cycle duration in minutes + """ + + self.waterTim: int = 0 + """ + Waterline duration in minutes + """ + + +class AwsState(Serializeable): + STATUS_CONNECTED = 'connected' + STATUS_DISCONNECTED = 'disconnected' + + def __init__(self): + super().__init__() + self.session_id: str = '' + self.status: str = '' + self.timestamp: int = 0 + + +class BasicCommand: + FAILURE_VALUE = "FF" + REQUEST_DESTINATION = "0A" + RESPONSE_DESTINATION = "00" + SUCCESS_VALUE = "01" + + +class GetCleanerStatusCommand: + def __init__(self): + self.command = 'GetCleanerStatus' + self.raw_value = '11' + + def get_hex_for_request(self, a: any, b: str) -> str: + return self.request_command() + + def request_command(self) -> str: + return BasicCommand.REQUEST_DESTINATION + self.raw_value diff --git a/pollect/libs/zodiac/ZodiacApi.py b/pollect/libs/zodiac/ZodiacApi.py new file mode 100644 index 0000000..086eae9 --- /dev/null +++ b/pollect/libs/zodiac/ZodiacApi.py @@ -0,0 +1,153 @@ +import hashlib +import hmac +import time +from typing import Dict, TypeVar, List +from urllib.parse import urlencode + +import requests + +from pollect.libs.api.Serializable import Serializeable +from pollect.libs.zodiac.Models import LoginReply, PoolCleanerInfo, SystemInfo + +T = TypeVar('T') + + +class ZodiacApi: + """ + Zodiac API + """ + + API_KEY = 'EOOEMOW4YR6QNB07' + API_SECRET_KEY = 'cj7iYKjiKxOqiLcN65PffA' + + SHADOW_URL = 'https://prod.zodiac-io.com' + API_URL = 'https://r-api.iaqualink.net' + PRM_URL = 'https://prm.iaqualink.net' + + user: LoginReply + + def __init__(self): + self.user = LoginReply() + + def login(self, email: str, password: str) -> LoginReply: + dto = self._post(f'{self.SHADOW_URL}/users/v1/login', { + 'apiKey': self.API_KEY, + 'email': email, + 'password': password, + }, LoginReply()) + self.user = dto + return dto + + def refresh_auth(self) -> LoginReply: + body = { + 'email': self.user.email, + 'refresh_token': self.user.userPoolOAuth.RefreshToken + } + dto = self._post(f'{self.SHADOW_URL}/users/v1/refresh', body, LoginReply()) + self.user = dto + return dto + + def get_device_info(self, serial_nr: str) -> PoolCleanerInfo: + """ + Returns details about a device + :param serial_nr: Serial number of the device + :return: Details + """ + self._require_auth() + id_token = self.user.userPoolOAuth.IdToken + user_id = self.user.id + url = f'{self.SHADOW_URL}/devices/v2/{serial_nr}/shadow' + data = self._get(url, PoolCleanerInfo(), query={ + 'signature': self._sign(f'{serial_nr.upper()},{user_id}'), + }, headers={ + 'Authorization': id_token + }) + return data + + def get_system_list_v2(self) -> List[SystemInfo]: + """ + Returns all available devices + :return: List of devices + """ + self._require_auth() + unix_time = round(time.time()) + user_id = self.user.id + id_token = self.user.userPoolOAuth.IdToken + sign = self._sign(f'{user_id},{unix_time}') + + url = f'{self.PRM_URL}/v2/devices.json?user_id={user_id}&signature={sign}×tamp={unix_time}' + data = self._get(url, [SystemInfo()], headers={ + 'api_key': self.API_KEY, + 'Authorization': id_token + }) + return data + + def execute_command(self, serial_nr: str, command: str, use_v2: bool = True): + """ + Untested - This isn't used by the tested device. + """ + self._require_auth() + user_id = self.user.id + if use_v2: + id_token = self.user.userPoolOAuth.IdToken + url = f'{self.API_URL}/devices/v2/{serial_nr}/execute_read_command.json' + unix_time = round(time.time()) + sign = self._sign(f'{serial_nr},{user_id},{unix_time}') + data = self._post(url, { + 'user_id': user_id, + 'command': '/command', + 'signature': sign, + 'timestamp': unix_time, + 'params': f'request={command}&timeout=800' + }, Serializeable(), headers={ + 'api_key': self.API_KEY, + 'Authorization': id_token + }) + else: + auth_token = self.user.authentication_token + query_params = { + 'api_key': self.API_KEY, + 'authentication_token': auth_token, + 'user_id': user_id, + 'command': '/command', + 'params': f'request={command}&timeout=800' + } + url = f'https://r-api.iaqualink.net/devices/{serial_nr}/execute_read_command.json' + data = self._post(url, {}, Serializeable(), query=query_params, headers={ + "Accept": "application/json" + }) + + return data + + def _get(self, path: str, dto: T, query=None, headers=None) -> T: + if query is not None: + path += '?' + urlencode(query) + reply = requests.get(path, headers=headers) + self._handle_reply(reply) + data = reply.json() + return Serializeable.deserialize_from_data(data, dto) + + def _post(self, path: str, payload: Dict[str, any], dto: T, query=None, headers=None) -> T: + if query is not None: + path += '?' + urlencode(query) + reply = requests.post(path, json=payload, headers=headers) + self._handle_reply(reply) + data = reply.json() + return Serializeable.deserialize_from_data(data, dto) + + def _sign(self, content: str) -> str: + key = bytes(self.API_SECRET_KEY, 'UTF-8') + message = bytes(content, 'UTF-8') + digester = hmac.new(key, message, hashlib.sha1) + signature = digester.digest() + return ''.join('{:02x}'.format(x) for x in signature) + + def _handle_reply(self, reply): + if reply.status_code != 200: + raise ValueError(f'Invalid reply: {reply.status_code} - {reply.content}') + + def _require_auth(self): + if not self.user.is_logged_in(): + raise ValueError('User is not logged in') + if self.user.is_expired(): + self.refresh_auth() diff --git a/pollect/libs/zodiac/__init__.py b/pollect/libs/zodiac/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pollect/sources/ZodiacPoolSource.py b/pollect/sources/ZodiacPoolSource.py new file mode 100644 index 0000000..1ba632b --- /dev/null +++ b/pollect/sources/ZodiacPoolSource.py @@ -0,0 +1,64 @@ +import json +import os +from typing import Optional, List + +from pollect.core.ValueSet import ValueSet, Value +from pollect.libs.zodiac.Models import LoginReply +from pollect.libs.zodiac.ZodiacApi import ZodiacApi +from pollect.sources.Source import Source + + +class ZodiacPoolSource(Source): + """ + Metrics about zodiac pool cleaners. + Tested with Zodiac Alpha 63 IQ + """ + AUTH_FILE = "zodiac-token.json" + + def __init__(self, config): + super().__init__(config) + self._user = config['user'] + self._password = config['password'] + self.api = ZodiacApi() + self.expires_in = 0 + + def setup(self, global_conf): + super().setup(global_conf) + + # Restore auth + if not os.path.isfile(self.AUTH_FILE): + self.api.login(self._user, self._password) + self._persist_auth() + return + with open(self.AUTH_FILE, "r") as f: + login_data = json.load(f) + + reply = LoginReply() + reply.deserialize(login_data) + self.api.user = reply + + def _probe(self) -> Optional[ValueSet] or List[ValueSet]: + values = ValueSet(labels=['device_serial']) + for device in self.api.get_system_list_v2(): + state = self.api.get_device_info(device.serial_number) + robot = state.state.reported.equipment.robot + + values.add(Value(robot.state, [device.serial_number], 'state')) + values.add(Value(robot.prCyc, [device.serial_number], 'program_cycle')) + values.add(Value(robot.errorCode, [device.serial_number], 'error_code')) + + remaining = -1 + if robot.is_running(): + remaining = robot.get_remaining_time() + + values.add(Value(remaining, [device.serial_number], 'remaining_time')) + self._persist_auth() + return values + + def _persist_auth(self): + if self.api.user.userPoolOAuth.ExpiresIn == self.expires_in: + return + + self.expires_in = self.api.user.userPoolOAuth.ExpiresIn + with open(self.AUTH_FILE, "w") as f: + json.dump(self.api.user.get_raw(), f) diff --git a/tests/test_ZodiacApi.py b/tests/test_ZodiacApi.py new file mode 100644 index 0000000..f96600a --- /dev/null +++ b/tests/test_ZodiacApi.py @@ -0,0 +1,13 @@ +import time +import unittest + +from pollect.libs.zodiac.Models import Robot, ProgramCycles + + +class TestZodiacApi(unittest.TestCase): + def test_duration(self): + robot = Robot() + robot.cycleStartTime = (time.time() - 60 * 60) # 1h ago + robot.prCyc = ProgramCycles.SMART_CLEAN + robot.durations.smartTim = 160 + self.assertEquals(100, round(robot.get_remaining_time()/60)) \ No newline at end of file