import base64 import random import requests import string from Crypto.Cipher import AES from Crypto.Util.Padding import pad from datetime import datetime import logging import time # This key is NOT private. It is found in the JavaScript code of the Hanna Cloud webapp at https://www.hannacloud.com DEFAULT_ENCRYPTION_KEY = "MzJmODBmMDU0ZTAyNDFjYWM0YTVhOGQxY2ZlZTkwMDM=" class HannaCloudClient: """Client for interacting with the HannaCloud API.""" def __init__(self): """ Initialize the HannaCloud API client. """ self.base_url = "https://www.hannacloud.com/api" self.key_base64 = DEFAULT_ENCRYPTION_KEY self.headers = {'Accept': '*/*', 'content-type': 'application/json'} self.access_token = None self.email = None self.password = None logging.basicConfig(level=logging.INFO) def _make_request(self, method, endpoint, **kwargs): """ Internal method to make HTTP requests to the HannaCloud API. Args: method (str): HTTP method (e.g., 'POST'). endpoint (str): API endpoint. **kwargs: Additional arguments for requests.request. Returns: dict: The 'data' field from the API response, or an empty dict if not present. If the response is not JSON, it will be returned as is. Raises: requests.HTTPError: If the HTTP request fails. """ def _execute_request(): self.headers['authorization'] = f"Bearer {self.access_token}" headers = {**self.headers, **kwargs.get('headers', {})} url = f'{self.base_url}/{endpoint}' return requests.request(method=method, url=url, headers=headers, **kwargs) response = _execute_request() logging.debug(f"{method} {self.base_url}/{endpoint} {response.status_code}") if response.status_code == 403: logging.info("Authentication failed: 403. Re-authenticating.") self.authenticate(self.email, self.password) response = _execute_request() try: response.raise_for_status() except requests.HTTPError as e: if response.status_code == 404: raise DeviceNotFoundError(f"Resource not found: {endpoint}") from e elif response.status_code == 401: raise AuthenticationError("Invalid credentials or token expired") from e elif response.status_code == 403: raise AuthenticationError("Access forbidden - insufficient permissions") from e elif response.status_code >= 500: raise APIError(f"Server error: {response.status_code}", response.status_code) from e else: raise APIError(f"HTTP {response.status_code}: {e}", response.status_code) from e try: return response.json().get('data', {}) except ValueError as e: raise APIError(f"Invalid JSON response: {e}", response_data=response.text) from e def hanna_encrypt(self, plaintext: str) -> str: """ Encrypts the given plaintext using AES CBC mode with a random IV and a base64-encoded key. Args: plaintext (str): The text to encrypt. Returns: str: The IV and the encrypted data (as hex), separated by a colon. """ # Decode the base64-encoded key to bytes key = base64.b64decode(self.key_base64) # Generate a random IV choices = string.ascii_letters + string.digits iv = ''.join(random.choice(choices) for _ in range(16)).encode() # Create a new AES cipher with the key and IV cipher = AES.new(key, AES.MODE_CBC, iv) # Pad the plaintext to the block size padded = pad(plaintext.encode(), AES.block_size) # Encrypt the padded plaintext encrypted = cipher.encrypt(padded) # Return the IV and the encrypted data (as hex), separated by a colon return f"{iv.decode()}:{encrypted.hex()}" def authenticate(self, email: str, password: str) \ -> tuple[str, str]: """ Authenticates the user with the given email and password. Args: email (str): The user's email address. password (str): The user's password. Returns: Tuple[str, str]: The access token and refresh token. Raises: ValueError: If authentication fails or tokens are missing. """ self.email = email self.password = password json_data = { 'operationName': 'Login', 'variables': { 'email': self.hanna_encrypt(email), 'password': self.hanna_encrypt(password), 'userLanguage': 'English', 'source': 'web', }, 'query': ( """ query Login($email: String!, $password: String!, $userLanguage: String!, $source: String) { login( email: $email password: $password language: $userLanguage source: $source ) { token tokenType } } """ ), } max_retries, retry_delay, retries = 3, 1, 0 while retries < max_retries: response = self._make_request('POST', 'auth', json=json_data) if response.get("login", {}): break retries += 1 time.sleep(retry_delay) if not response.get("login", {}): raise AuthenticationError("Authentication failed: 'login' key missing in response.") for token in response['login']: if token.get('tokenType') == 'accessToken': self.access_token = token.get('token') return self.access_token def is_authenticated(self) -> bool: """ Check if the client is authenticated. Returns: bool: True if authenticated, False otherwise. """ return self.access_token is not None def _require_authentication(self): """ Require authentication for protected operations. Raises: AuthenticationError: If not authenticated. """ if not self.is_authenticated(): raise AuthenticationError("Authentication required. Call authenticate() first.") def get_last_device_reading(self, device_id: str): """ Retrieves the last reading for the specified device(s). Args: device_id (str): The device ID to get readings for. Returns: list: Last device readings. Raises: DeviceNotFoundError: If no readings are found for the device. """ self._require_authentication() if not device_id: raise ValidationError("device_id cannot be empty") json_data = { 'operationName': 'GetLastDeviceReading', 'variables': {'deviceIds': [device_id]}, 'query': ( """ query GetLastDeviceReading($deviceIds: [String!]) { lastDeviceReadings(deviceIds: $deviceIds) { DID DT messages } } """ ) } response = self._make_request('POST', 'graphql', json=json_data) readings = response.get('lastDeviceReadings', [[]])[0] if not readings: raise DeviceNotFoundError(f"No readings found for device {device_id}") self.reading = readings self.alarms = readings.get('messages', {}).get('alarms', []) self.warnings = readings.get('messages', {}).get('warnings', []) self.errors = readings.get('messages', {}).get('errors', []) self.status = readings.get('messages', {}).get('status', {}) self.parameters = readings.get('messages', {}).get('parameters', {}) return readings def get_devices(self): """ Retrieves a list of devices for the user. Returns: dict: Device information. """ self._require_authentication() json_data = { "operationName": "Devices", "variables": { "modelGroups": [ "BL12x", "BL13x", "BL13xs" ], "deviceLogs": True }, "query": ( """ query Devices($modelGroups: [String!], $deviceLogs: Boolean!) { devices(modelGroups: $modelGroups, deviceLogs: $deviceLogs) { _id DID DM modelGroup DT DINFO { deviceName deviceVersion userId emailId assignedUsers { emailId __typename } tankId tankName __typename } parentId childDevices { DID __typename } dashboardViewStatus deviceOrder secondaryUser reportedSettings status lastUpdated message deviceName batteryStatus __typename } } """ ) } response = self._make_request('POST', 'graphql', json=json_data) devices = response.get('devices', []) for device in devices: sy = device.get("reportedSettings", {}).get("SY") device['manufacturer'] = sy.split(",")[0] device['name'] = f"{device.get('DINFO', {}).get('deviceName')}" device['serial_number'] = sy.split(",")[4] device['sw_version'] = "".join(sy.split(",")[2:4]).replace("/", "/") return devices def get_user(self): """ Retrieves information about the current user. Returns: dict: User information. """ self._require_authentication() json_data = { "operationName": "getUser", "variables": {}, "query": ( """ query getUser { currentUser { _id fName lName regDate emailId notificationSetting aesObjectId lang tempUnit timeFormat dateFormat dashboardView blDeviceSorting __typename } } """ ) } response = self._make_request('POST', 'graphql', json=json_data) return response.get('currentUser', {}) def get_device_log_history(self, device_id: str, from_dt: datetime = None, to_dt: datetime = None): """ Retrieves the device log history for a given device and date range. Args: device_id (str): The device ID. from_dt (datetime): Start date for log history. to_dt (datetime): End date for log history. Returns: list: Device log history data. Raises: DeviceLogError: If no device log history is found. """ self._require_authentication() if not device_id: raise ValidationError("device_id cannot be empty") if from_dt and to_dt and from_dt > to_dt: raise ValidationError("from_dt cannot be later than to_dt") from_dt = from_dt or datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) to_dt = to_dt or datetime.now() json_data = { "operationName": "deviceLogHistory", "variables": { "deviceId": device_id, "from": from_dt.isoformat(), "to": to_dt.isoformat(), "count": 10000 }, "query": ( """ query deviceLogHistory($deviceId: String!, $from: String!, $to: String!) { deviceLogHistory(deviceId: $deviceId, from: $from, to: $to) { data endDate startDate parameterNames __typename } } """ ) } response = self._make_request('POST', 'graphql', json=json_data) device_log_history = response.get('deviceLogHistory', []) if not device_log_history: raise DeviceLogError(f"No device log history found for device {device_id}") return device_log_history def set_remote_hold(self, device_id: str, setting: bool): """ Sets the remote hold (disable pumps) setting for a device. Args: device_id (str): The device ID. setting (bool): The remote hold setting. Raises: RemoteHoldError: If the remote hold operation fails. """ self._require_authentication() if not device_id: raise ValidationError("device_id cannot be empty") if not isinstance(setting, bool): raise ValidationError("setting must be a boolean value") json_data = { "operationName": "RemoteHold", "variables": {"deviceId": device_id, "remoteHold": setting}, "query": ( """ mutation RemoteHold($deviceId: String!, $remoteHold: Boolean!) { deviceRemoteHold(deviceId: $deviceId, remoteHold: $remoteHold) { data __typename } } """ ) } response = self._make_request('POST', 'graphql', json=json_data) if response.get('deviceRemoteHold', {}).get('data', {}).get('message') != 'remoteHoldSuccess': raise RemoteHoldError("Remote hold operation failed. Check device status and permissions.") return response.get('deviceRemoteHold', {}) class HannaCloudError(Exception): """Base exception for HannaCloud API errors.""" pass class AuthenticationError(HannaCloudError): """Exception raised for authentication errors.""" pass class DeviceNotFoundError(HannaCloudError): """Exception raised when a device is not found.""" pass class APIError(HannaCloudError): """Exception raised for general API errors.""" def __init__(self, message, status_code=None, response_data=None): super().__init__(message) self.status_code = status_code self.response_data = response_data class RemoteHoldError(HannaCloudError): """Exception raised when remote hold operation fails.""" pass class DeviceLogError(HannaCloudError): """Exception raised when device log operations fail.""" pass class ValidationError(HannaCloudError): """Exception raised for validation errors.""" pass