From 7ee01e99308938118e862dd62bfaafa78a7d6d7a Mon Sep 17 00:00:00 2001 From: Jonathan Bydendyk Date: Thu, 8 Oct 2026 18:00:41 +0200 Subject: [PATCH 1/3] Added new rainpoint api integration --- .../apps/device/integrations/rainpoint.py | 153 ++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 iotserver/apps/device/integrations/rainpoint.py diff --git a/iotserver/apps/device/integrations/rainpoint.py b/iotserver/apps/device/integrations/rainpoint.py new file mode 100644 index 0000000..13a6d4a --- /dev/null +++ b/iotserver/apps/device/integrations/rainpoint.py @@ -0,0 +1,153 @@ +import hashlib +import secrets + +import requests +from django.conf import settings +from django.core.cache import cache + +READING_MAP = { + 'rain_gauge': { + 'address': 1, + 'model_code': 87, + 'fields': {'rainfall_mm_hour': 'rainfall'}, + }, + 'pool_thermometer': { + 'address': 2, + 'model_code': 268, + 'fields': {'temperature_c': 'pool_temperature'}, + }, + 'temperature_sensor': { + 'address': 3, + 'model_code': 262, + 'fields': { + 'temperature_c': 'temperature', + 'humidity_pct': 'humidity', + }, + }, +} + + +class RainPoint: + """Read-only RainPoint Home integration via the HomGar cloud API.""" + + def __init__(self, hub_id: str, cache_timeout: int = None) -> None: + self.config = settings.INTEGRATIONS['rainpoint'] + self.hub_id = hub_id + self.cache_timeout = cache_timeout or self.config['cache_timeout'] + self.cache_prefix = f'{self.__module__}.{self.__class__.__name__}' + self.request_headers = { + 'lang': 'en', + 'appCode': self.config['app_code'], + } + + def _authenticate(self): + cache_key = f'{self.cache_prefix}.auth' + cached_auth_token = cache.get(cache_key) + if cached_auth_token: + return cached_auth_token + + auth_url = f"{self.config['base_url']}/auth/basic/app/login" + request_data = { + 'areaCode': self.config['area_code'], + 'phoneOrEmail': self.config['email'], + 'password': hashlib.md5( + self.config['password'].encode() + ).hexdigest(), + 'deviceId': secrets.token_hex(16), + } + response = requests.post( + url=auth_url, headers=self.request_headers, json=request_data + ) + response.raise_for_status() + auth_data = response.json()['data'] + + token_timeout = int(auth_data['tokenExpired']) + cache.set(cache_key, auth_data['token'], timeout=token_timeout) + + return auth_data['token'] + + def _decode_sensor(self, value, model_code): + if not isinstance(value, str) or not value.startswith(('10#', '11#')): + return {} + + data = bytes.fromhex(value.split('#', 1)[1]) + offset = 1 if value.startswith('11#') else 0 + datapoints = {} + while offset < len(data): + header = data[offset] + offset += 1 + if not header & 0x80: + continue + code = (header >> 2) & 0x1F + length = (header & 0x03) + 1 + if code == 31: + if offset >= len(data): + raise ValueError('Truncated RainPoint datapoint code') + code = data[offset] + offset += 1 + else: + code += 8 + if offset + length > len(data): + raise ValueError('Truncated RainPoint datapoint value') + datapoints[code] = data[offset : offset + length] + offset += length + + readings = {} + if model_code == 87: + if 4 in datapoints: + readings['rainfall_mm_hour'] = ( + int.from_bytes(datapoints[4], 'little') / 10 + ) + elif model_code in (262, 268): + if 9 in datapoints: + fahrenheit = ( + int.from_bytes(datapoints[9], 'little', signed=True) / 10 + ) + readings['temperature_c'] = round((fahrenheit - 32) * 5 / 9, 1) + if model_code == 262 and 10 in datapoints: + readings['humidity_pct'] = int.from_bytes( + datapoints[10], 'little' + ) + return readings + + @property + def statuses(self): + cache_key = f'{self.cache_prefix}.statuses:{self.hub_id}' + cached_statuses = cache.get(cache_key) + if cached_statuses is not None: + return cached_statuses + + token = self._authenticate() + headers = self.request_headers.copy() + headers['auth'] = token + + response = requests.get( + url=f"{self.config['base_url']}/app/device/getDeviceStatus", + params={'mid': self.hub_id}, + headers=headers, + ) + response.raise_for_status() + statuses_data = response.json()['data'] + + entries = { + entry['id']: entry + for entry in (statuses_data or {}).get('subDeviceStatus', []) + } + statuses = {} + for reading in READING_MAP.values(): + status_id = f'D{int(reading["address"]):02d}' + entry = entries.get(status_id, {}) + readings = self._decode_sensor( + entry.get('value'), reading['model_code'] + ) + statuses.update( + { + output: readings[field] + for field, output in reading['fields'].items() + if field in readings + } + ) + + cache.set(cache_key, statuses, timeout=self.cache_timeout) + + return statuses From 1f3d3fa73bc6a738ff25ba3b9cb5524d66c46f94 Mon Sep 17 00:00:00 2001 From: Jonathan Bydendyk Date: Thu, 8 Oct 2026 18:02:01 +0200 Subject: [PATCH 2/3] Added setting for rainpoint integration --- .env.example | 10 ++++++++++ iotserver/settings.py | 12 ++++++++++++ 2 files changed, 22 insertions(+) diff --git a/.env.example b/.env.example index 94988a3..a0fb57d 100644 --- a/.env.example +++ b/.env.example @@ -32,6 +32,16 @@ IOTSERVER_OLARM_API_KEY='olarm-api-key' IOTSERVER_OLARM_DEVICE_ID='olarm-device-id' IOTSERVER_OLARM_CACHE_TIMEOUT='60' +IOTSERVER_RAINPOINT_EMAIL='rainpoint-api@example.com' +IOTSERVER_RAINPOINT_PASSWORD='rainpoint-password' +IOTSERVER_RAINPOINT_APP_CODE='2' +IOTSERVER_RAINPOINT_AREA_CODE='27' +IOTSERVER_RAINPOINT_HUB_ID='361481' +IOTSERVER_RAINPOINT_RAIN_GAUGE_ADDRESS='1' +IOTSERVER_RAINPOINT_POOL_THERMOMETER_ADDRESS='2' +IOTSERVER_RAINPOINT_TEMPERATURE_SENSOR_ADDRESS='3' +IOTSERVER_RAINPOINT_CACHE_TIMEOUT='60' + IOTSERVER_GOOGLEMAPS_APIKEY='googlemaps-key' IOTSERVER_WEBREPL_PORT='8266' diff --git a/iotserver/settings.py b/iotserver/settings.py index e796d31..00dff9f 100644 --- a/iotserver/settings.py +++ b/iotserver/settings.py @@ -223,6 +223,18 @@ os.environ.get('IOTSERVER_SOLARMAN_CACHE_TIMEOUT', 60 * 5) ), }, + 'rainpoint': { + 'base_url': os.environ.get( + 'IOTSERVER_RAINPOINT_BASE_URL', 'https://region3.homgarus.com' + ), + 'email': os.environ.get('IOTSERVER_RAINPOINT_EMAIL', None), + 'password': os.environ.get('IOTSERVER_RAINPOINT_PASSWORD', None), + 'app_code': os.environ.get('IOTSERVER_RAINPOINT_APP_CODE', '2'), + 'area_code': os.environ.get('IOTSERVER_RAINPOINT_AREA_CODE', '27'), + 'cache_timeout': int( + os.environ.get('IOTSERVER_RAINPOINT_CACHE_TIMEOUT', 60) + ), + }, 'olarm': { 'base_url': os.environ.get( 'IOTSERVER_OLARM_BASE_URL', From 3ff665043a39745ca644da8f02f1c6958b0e6551 Mon Sep 17 00:00:00 2001 From: Jonathan Bydendyk Date: Thu, 8 Oct 2026 18:02:26 +0200 Subject: [PATCH 3/3] Added a rule to read rainpoint device statuses --- .../tests/integrations/test_rainpoint.py | 350 ++++++++++++++++++ .../apps/device/tests/utils/test_rules.py | 24 ++ iotserver/apps/device/utils/rules.py | 12 + 3 files changed, 386 insertions(+) create mode 100644 iotserver/apps/device/tests/integrations/test_rainpoint.py diff --git a/iotserver/apps/device/tests/integrations/test_rainpoint.py b/iotserver/apps/device/tests/integrations/test_rainpoint.py new file mode 100644 index 0000000..61bcaff --- /dev/null +++ b/iotserver/apps/device/tests/integrations/test_rainpoint.py @@ -0,0 +1,350 @@ +import hashlib + +import pytest +import requests + +from iotserver.apps.device.integrations.rainpoint import RainPoint + + +@pytest.fixture +def client(mocker): + settings = mocker.patch( + 'iotserver.apps.device.integrations.rainpoint.settings' + ) + settings.INTEGRATIONS = { + 'rainpoint': { + 'base_url': 'https://example.com', + 'app_code': '2', + 'email': 'api@example.com', + 'password': 'password', + 'area_code': '27', + 'hub_id': '361481', + 'cache_timeout': 60, + } + } + return RainPoint('361481') + + +@pytest.fixture +def mock_cache(mocker): + cache = mocker.patch('iotserver.apps.device.integrations.rainpoint.cache') + cache.get.return_value = None + return cache + + +@pytest.fixture +def mock_request(mocker): + mock = mocker.patch('iotserver.apps.device.integrations.rainpoint.requests') + mock.post.return_value.json.return_value = { + 'code': 0, + 'data': {'token': 'token', 'tokenExpired': 3600}, + } + mock.get.return_value.json.return_value = { + 'code': 0, + 'data': {'subDeviceStatus': []}, + } + return mock + + +@pytest.mark.parametrize('custom_mapping', [False, True]) +def test_statuses_uses_reading_map(mocker, mock_cache, custom_mapping): + settings = mocker.patch( + 'iotserver.apps.device.integrations.rainpoint.settings' + ) + settings.INTEGRATIONS = { + 'rainpoint': { + 'base_url': 'https://example.com', + 'app_code': '2', + 'cache_timeout': 60, + } + } + rainpoint = RainPoint('361481') + mocker.patch.object(rainpoint, '_authenticate', return_value='token') + request = mocker.patch( + 'iotserver.apps.device.integrations.rainpoint.requests.get' + ) + if custom_mapping: + mocker.patch( + 'iotserver.apps.device.integrations.rainpoint.READING_MAP', + { + 'custom_sensor': { + 'address': 7, + 'model_code': 262, + 'fields': {'humidity_pct': 'custom_humidity'}, + } + }, + ) + request.return_value.json.return_value = { + 'data': { + 'subDeviceStatus': [ + {'id': 'D01', 'value': '10#FD040F00'}, + {'id': 'D02', 'value': '10#85B602'}, + {'id': 'D03', 'value': '10#85BA028842'}, + {'id': 'D07', 'value': '10#85BA028827'}, + ] + } + } + expected = ( + {'custom_humidity': 39} + if custom_mapping + else { + 'rainfall': 1.5, + 'pool_temperature': 20.8, + 'temperature': 21.0, + 'humidity': 66, + } + ) + + assert rainpoint.statuses == expected + request.assert_called_once_with( + url='https://example.com/app/device/getDeviceStatus', + params={'mid': '361481'}, + headers={'lang': 'en', 'appCode': '2', 'auth': 'token'}, + ) + mock_cache.set.assert_called_once_with( + f'{rainpoint.cache_prefix}.statuses:361481', expected, timeout=60 + ) + + +def test_statuses(client, mock_cache, mock_request): + status = { + 'subDeviceStatus': [ + { + 'id': 'D01', + 'time': 1791341339073, + 'value': '10#E10000FD040000FD050000FD060000DC0197DE030000FF0FDB4B8E1A', + }, + { + 'id': 'D02', + 'time': 1791444145150, + 'value': '10#E7B202B602DC01B80585B602FF0F9895901A', + }, + { + 'id': 'D03', + 'time': 1791444360142, + 'value': '10#E73A02BA02DC01B80E85BA028842E9425CFF0F7B96901A', + }, + {'id': 'D04', 'value': None}, + {'id': 'connected', 'value': '1'}, + ], + } + mock_request.get.return_value.json.return_value = { + 'code': 0, + 'data': status, + } + + assert client.statuses == { + 'rainfall': 0.0, + 'pool_temperature': 20.8, + 'temperature': 21.0, + 'humidity': 66, + } + mock_request.post.assert_called_once_with( + url='https://example.com/auth/basic/app/login', + headers={'lang': 'en', 'appCode': '2'}, + json={ + 'areaCode': '27', + 'phoneOrEmail': 'api@example.com', + 'password': hashlib.md5(b'password').hexdigest(), + 'deviceId': mock_request.post.call_args.kwargs['json']['deviceId'], + }, + ) + mock_request.get.assert_called_once_with( + url='https://example.com/app/device/getDeviceStatus', + params={'mid': '361481'}, + headers={'lang': 'en', 'appCode': '2', 'auth': 'token'}, + ) + mock_request.post.return_value.raise_for_status.assert_called_once_with() + mock_request.get.return_value.raise_for_status.assert_called_once_with() + assert mock_cache.set.call_args.kwargs['timeout'] == 60 + + +@pytest.mark.parametrize('app_code', ['1', '2']) +def test_configured_app_code(client, mock_cache, mock_request, app_code): + client.config['app_code'] = app_code + client = RainPoint('361481') + client._authenticate() + + assert mock_request.post.call_args.kwargs['headers']['appCode'] == app_code + assert client.request_headers == {'lang': 'en', 'appCode': app_code} + + +def test_cached_statuses(client, mock_cache, mock_request): + statuses = {'rainfall': 0.0, 'temperature': 21.0} + mock_cache.get.return_value = statuses + assert client.statuses == statuses + mock_cache.get.assert_called_once_with( + f'{client.cache_prefix}.statuses:361481' + ) + mock_request.get.assert_not_called() + mock_request.post.assert_not_called() + + +def test_cached_authentication_token(client, mock_cache, mock_request): + mock_cache.get.return_value = 'old-token' + assert client._authenticate() == 'old-token' + mock_request.post.assert_not_called() + + +def test_authentication_token_cache_lifetime(client, mock_cache, mock_request): + mock_request.post.return_value.json.return_value = { + 'code': 0, + 'data': {'token': 'new', 'tokenExpired': 3600}, + } + assert client._authenticate() == 'new' + mock_cache.set.assert_called_once_with( + f'{client.cache_prefix}.auth', 'new', timeout=3600 + ) + + +@pytest.mark.parametrize('method', ['GET', 'POST']) +def test_http_error_not_cached( + client, mock_cache, mock_request, mocker, method +): + response = ( + mock_request.get.return_value + if method == 'GET' + else mock_request.post.return_value + ) + response.raise_for_status.side_effect = requests.HTTPError + if method == 'GET': + mocker.patch.object(client, '_authenticate', return_value='token') + with pytest.raises(requests.HTTPError): + if method == 'GET': + _ = client.statuses + else: + client._authenticate() + mock_cache.set.assert_not_called() + + +@pytest.mark.parametrize( + 'model_code, payload, expected', + [ + ( + 87, + '10#E10000FD040000FD050000FD060000DC0197DE030000FF0FDB4B8E1A', + {'rainfall_mm_hour': 0.0}, + ), + ( + 262, + '10#E73A02BA02DC01B80E85BA028842E9425CFF0F7B96901A', + { + 'temperature_c': 21.0, + 'humidity_pct': 66, + }, + ), + ( + 268, + '10#E7B202B602DC01B80585B602FF0F9895901A', + {'temperature_c': 20.8}, + ), + ], +) +def test_decode_sensor_sample(client, model_code, payload, expected): + assert client._decode_sensor(payload, model_code) == expected + + +@pytest.mark.parametrize('model_code', [87, 262, 268, 999]) +def test_decode_sensor_ignores_unused_datapoints(client, model_code): + assert ( + client._decode_sensor('10#DC01E700000000E9000097DE030000', model_code) + == {} + ) + + +@pytest.mark.parametrize('value', [None, '', 'offline']) +def test_decode_sensor_missing(client, value): + assert client._decode_sensor(value, 262) == {} + + +@pytest.mark.parametrize('value', ['10#FD', '10#85BA', '10#not-hex']) +def test_decode_sensor_malformed(client, value): + with pytest.raises(ValueError): + client._decode_sensor(value, 262) + + +def test_decode_sensor_signed_temperature(client): + assert client._decode_sensor('10#85CEFF', 262) == {'temperature_c': -20.6} + + +def test_decode_sensor_extended_stream(client): + assert client._decode_sensor('11#0085BA02', 262) == {'temperature_c': 21.0} + + +def test_custom_cache_timeout(client, mock_cache, mock_request): + client = RainPoint('361481', cache_timeout=30) + assert client.statuses == {} + assert mock_cache.set.call_args.kwargs['timeout'] == 30 + + +@pytest.mark.parametrize('cache_timeout', [None, 0]) +def test_default_cache_timeout(client, mock_cache, mock_request, cache_timeout): + client = RainPoint('361481', cache_timeout=cache_timeout) + cached_statuses = {'temperature': 21.0} + mock_cache.get.return_value = cached_statuses + + assert client.cache_timeout == client.config['cache_timeout'] == 60 + assert client.statuses == cached_statuses + mock_request.get.assert_not_called() + mock_request.post.assert_not_called() + mock_cache.set.assert_not_called() + + +def test_zero_cache_timeout_from_settings(client): + client.config['cache_timeout'] = 0 + assert RainPoint('361481').cache_timeout == 0 + + +@pytest.mark.parametrize('status', [None, {}, {'subDeviceStatus': []}]) +def test_missing_sensor_readings(client, mock_cache, mock_request, status): + mock_request.get.return_value.json.return_value = { + 'code': 0, + 'data': status, + } + assert client.statuses == {} + + +def test_custom_device_address(client, mock_cache, mock_request, mocker): + mocker.patch.dict( + 'iotserver.apps.device.integrations.rainpoint.READING_MAP', + { + 'temperature_sensor': { + 'address': 7, + 'model_code': 262, + 'fields': {'temperature_c': 'temperature'}, + } + }, + ) + mock_request.get.return_value.json.return_value = { + 'code': 0, + 'data': {'subDeviceStatus': [{'id': 'D07', 'value': '10#85BA02'}]}, + } + assert client.statuses == {'temperature': 21.0} + + +@pytest.mark.parametrize( + 'status_id, value, expected', + [ + ('D01', '10#FD040F00', {'rainfall': 1.5}), + ('D03', '10#8540018800', {'temperature': 0.0, 'humidity': 0}), + ], +) +def test_requested_readings( + client, mock_cache, mock_request, mocker, status_id, value, expected +): + mocker.patch.object(client, '_authenticate', return_value='token') + mock_request.get.return_value.json.return_value = { + 'code': 0, + 'data': {'subDeviceStatus': [{'id': status_id, 'value': value}]}, + } + assert client.statuses == expected + + +def test_status_cache_is_scoped_to_hub(client, mock_cache): + mock_cache.get.return_value = {} + _ = client.statuses + first_key = mock_cache.get.call_args.args[0] + client.hub_id = 'other' + _ = client.statuses + second_key = mock_cache.get.call_args.args[0] + assert first_key != second_key diff --git a/iotserver/apps/device/tests/utils/test_rules.py b/iotserver/apps/device/tests/utils/test_rules.py index e8a2949..d9a879b 100644 --- a/iotserver/apps/device/tests/utils/test_rules.py +++ b/iotserver/apps/device/tests/utils/test_rules.py @@ -266,6 +266,29 @@ def test_custom_cache_timeout(self, mocker): mock_solarman_cls.assert_called_once_with(3208097, cache_timeout=30) +class TestRainPointStatus: + @pytest.mark.parametrize('cache_timeout', [None, 30, 0]) + def test_returns_statuses(self, mocker, cache_timeout): + statuses = { + 'rainfall': 1.5, + 'temperature': 21.0, + 'humidity': 66, + 'pool_temperature': 20.8, + } + mock_client = mocker.patch( + 'iotserver.apps.device.utils.rules.RainPoint' + ) + mock_client.return_value.statuses = statuses + + rainpoint_status = rules.rainpoint_status( + hub_id='hub-1', cache_timeout=cache_timeout + ) + assert rainpoint_status == statuses + mock_client.assert_called_once_with( + 'hub-1', cache_timeout=cache_timeout + ) + + def test_rule_actions_dispatch_table(): assert rules.RULE_ACTIONS == { 'timer': rules.timer, @@ -274,6 +297,7 @@ def test_rule_actions_dispatch_table(): 'sonoff_toggle': rules.sonoff_toggle, 'olarm_activated': rules.olarm_activated, 'solarman_status': rules.solarman_status, + 'rainpoint_status': rules.rainpoint_status, } diff --git a/iotserver/apps/device/utils/rules.py b/iotserver/apps/device/utils/rules.py index 4cd3a1b..c2ea802 100644 --- a/iotserver/apps/device/utils/rules.py +++ b/iotserver/apps/device/utils/rules.py @@ -8,6 +8,7 @@ from django.utils import timezone from iotserver.apps.device.integrations.olarm import Olarm +from iotserver.apps.device.integrations.rainpoint import RainPoint from iotserver.apps.device.integrations.solarman import Solarman from iotserver.apps.device.integrations.sonoff import Sonoff @@ -167,6 +168,16 @@ def solarman_status(**kwargs): return solarman.real_time +def rainpoint_status(**kwargs): + """Return decoded sensor readings for the configured RainPoint hub.""" + hub_id = kwargs.get('hub_id') + cache_timeout = kwargs.get('cache_timeout') + + rainpoint = RainPoint(hub_id, cache_timeout=cache_timeout) + + return rainpoint.statuses + + # Explicit whitelist of callable rule actions, rather than `getattr` on this # module, so `rule['action']` (data, not code) can't invoke arbitrary attributes. RULE_ACTIONS = { @@ -176,6 +187,7 @@ def solarman_status(**kwargs): 'sonoff_toggle': sonoff_toggle, 'olarm_activated': olarm_activated, 'solarman_status': solarman_status, + 'rainpoint_status': rainpoint_status, }