From d9f93ea67076406c6eda96b2e757f34ffeda07f3 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 10 Jan 2023 22:05:39 -0800 Subject: [PATCH] Combine request and response encoding/decoding (#81) Unify how requests and responses are encoded/decoded so the same libraries can be used on both. This is helpful to support mitm proxy plugins, but also to add variable length input fields like set date/time. Many commands still need to be moved to the new yaml format for encoding. Issue #57 --- pyrainbird/__init__.py | 29 +++------ pyrainbird/async_client.py | 6 +- pyrainbird/data.py | 1 - pyrainbird/exceptions.py | 3 + pyrainbird/rainbird.py | 86 +++++++++++++++++++------- pyrainbird/resources/__init__.py | 17 +++-- pyrainbird/resources/sipcommands.yaml | 10 +-- tests/test_async_client.py | 27 ++------ tests/test_integration.py | 26 ++++---- tests/test_rainbird.py | 19 +++++- tests/testdata/available_stations.yaml | 16 ++++- tests/testdata/model_and_version.yaml | 2 + tests/testdata/settings.yaml | 5 +- 13 files changed, 147 insertions(+), 100 deletions(-) diff --git a/pyrainbird/__init__.py b/pyrainbird/__init__.py index a4962a7..831950f 100644 --- a/pyrainbird/__init__.py +++ b/pyrainbird/__init__.py @@ -11,7 +11,7 @@ States, WaterBudget, ) -from pyrainbird.resources import RAINBIRD_COMMANDS, RAINBIRD_RESPONSES_BY_ID +from pyrainbird.resources import RAINBIRD_COMMANDS, RAINBIRD_COMMANDS_BY_ID from . import rainbird from .client import RainbirdClient @@ -48,10 +48,7 @@ def get_model_and_version(self): ) def get_available_stations(self, page=_DEFAULT_PAGE): - mask = ( - "%%0%dX" - % RAINBIRD_RESPONSES_BY_ID["83"]["setStations"]["length"] - ) + mask = "%%0%dX" % RAINBIRD_COMMANDS_BY_ID["83"]["setStations"]["length"] return self._process_command( lambda resp: AvailableStations( mask % resp["setStations"], page=resp["pageNumber"] @@ -158,18 +155,11 @@ def command(self, command, *args): self.logger.warn("Empty response from controller") return None decoded = rainbird.decode(decrypted_data) - if ( - decrypted_data[:2] - != RAINBIRD_COMMANDS["%sRequest" % command][ - "response" - ] - ): + if decrypted_data[:2] != RAINBIRD_COMMANDS["%sRequest" % command]["response"]: raise Exception( "Status request failed with wrong response! Requested %s but got %s:\n%s" % ( - RAINBIRD_COMMANDS["%sRequest" % command][ - "response" - ], + RAINBIRD_COMMANDS["%sRequest" % command]["response"], decrypted_data[:2], decoded, ) @@ -183,17 +173,14 @@ def _process_command(self, funct, cmd, *args): funct(response) if response is not None and response["type"] - == RAINBIRD_RESPONSES_BY_ID[ - RAINBIRD_COMMANDS[cmd + "Request"]["response"] - ]["type"] + == RAINBIRD_COMMANDS_BY_ID[RAINBIRD_COMMANDS[cmd + "Request"]["response"]][ + "type" + ] else response ) def _update_irrigation_state(self, page=_DEFAULT_PAGE): - mask = ( - "%%0%dX" - % RAINBIRD_RESPONSES_BY_ID["BF"]["activeStations"]["length"] - ) + mask = "%%0%dX" % RAINBIRD_COMMANDS_BY_ID["BF"]["activeStations"]["length"] result = self._process_command( lambda resp: States((mask % resp["activeStations"])[:4]), "CurrentStationsActive", diff --git a/pyrainbird/async_client.py b/pyrainbird/async_client.py index 0913eb1..770f291 100644 --- a/pyrainbird/async_client.py +++ b/pyrainbird/async_client.py @@ -32,7 +32,7 @@ ZipCode, ) from .exceptions import RainbirdApiException, RainbirdAuthException -from .resources import LENGTH, RAINBIRD_COMMANDS, RAINBIRD_RESPONSES, RESPONSE +from .resources import LENGTH, RAINBIRD_COMMANDS, RESPONSE _LOGGER = logging.getLogger(__name__) T = TypeVar("T") @@ -130,7 +130,7 @@ async def get_available_stations(self, page=_DEFAULT_PAGE) -> AvailableStations: """Get the available stations.""" mask = ( "%%0%dX" - % RAINBIRD_RESPONSES["AvailableStationsResponse"]["setStations"][LENGTH] + % RAINBIRD_COMMANDS["AvailableStationsResponse"]["setStations"][LENGTH] ) return await self._process_command( lambda resp: AvailableStations( @@ -214,7 +214,7 @@ async def get_zone_states(self, page=_DEFAULT_PAGE) -> States: """Return the current state of the zone.""" mask = ( "%%0%dX" - % RAINBIRD_RESPONSES["CurrentStationsActiveResponse"]["activeStations"][ + % RAINBIRD_COMMANDS["CurrentStationsActiveResponse"]["activeStations"][ LENGTH ] ) diff --git a/pyrainbird/data.py b/pyrainbird/data.py index c61a648..9947b94 100644 --- a/pyrainbird/data.py +++ b/pyrainbird/data.py @@ -228,7 +228,6 @@ class Settings(BaseModel): @root_validator(pre=True) def _soil_type(cls, values: dict[str, Any]): """Validate different ways the SoilTypes parameter is handled.""" - print("values=", values) if soil_type := values.get("soilTypes"): values["SoilTypes"] = soil_type return values diff --git a/pyrainbird/exceptions.py b/pyrainbird/exceptions.py index 2deb070..ce90d26 100644 --- a/pyrainbird/exceptions.py +++ b/pyrainbird/exceptions.py @@ -8,3 +8,6 @@ class RainbirdApiException(Exception): class RainbirdAuthException(Exception): """Authentication exception from rainbird API.""" + +class RainbirdCodingException(Exception): + """Error while encoding or decoding objects.""" diff --git a/pyrainbird/rainbird.py b/pyrainbird/rainbird.py index 9cc32a9..f239755 100644 --- a/pyrainbird/rainbird.py +++ b/pyrainbird/rainbird.py @@ -1,10 +1,19 @@ """Library for encoding and decoding rainbird tunnelSip commands.""" -from collections.abc import Callable import logging +from collections.abc import Callable from typing import Any -from .resources import RAINBIRD_COMMANDS, RAINBIRD_RESPONSES_BY_ID +from .exceptions import RainbirdCodingException +from .resources import ( + DECODER, + LENGTH, + POSITION, + RAINBIRD_COMMANDS, + RAINBIRD_COMMANDS_BY_ID, + RESERVED_FIELDS, + TYPE, +) _LOGGER = logging.getLogger(__name__) @@ -13,10 +22,12 @@ def decode_template(data: str, cmd_template: dict[str, Any]) -> dict[str, int]: """Decode the command from the template in yaml.""" result = {} for k, v in cmd_template.items(): - if isinstance(v, dict) and "position" in v and "length" in v: - position_ = v["position"] - length_ = v["length"] - result[k] = int(data[position_ : position_ + length_], 16) + if ( + isinstance(v, dict) + and (position := v.get(POSITION)) + and (length := v.get(LENGTH)) + ): + result[k] = int(data[position : position + length], 16) return result @@ -92,28 +103,59 @@ def decode_schedule(data: str, cmd_template: dict[str, Any]) -> dict[str, Any]: def decode(data: str) -> dict[str, Any]: """Decode a rainbird tunnelSip command response.""" command_code = data[:2] - if not (cmd_template := RAINBIRD_RESPONSES_BY_ID.get(command_code)): - _LOGGER.warning("Unrecognized server response code '%s' from '%s'", command_code, data) + if not (cmd_template := RAINBIRD_COMMANDS_BY_ID.get(command_code)): + _LOGGER.warning( + "Unrecognized server response code '%s' from '%s'", command_code, data + ) return {"data": data} - decoder = DECODERS[cmd_template.get("decoder", DEFAULT_DECODER)] - return {"type": cmd_template["type"], **decoder(data, cmd_template)} + decoder = DECODERS[cmd_template.get(DECODER, DEFAULT_DECODER)] + return {TYPE: cmd_template[TYPE], **decoder(data, cmd_template)} def encode(command: str, *args) -> str: """Encode a rainbird tunnelSip command request.""" if not (command_set := RAINBIRD_COMMANDS.get(command)): - raise Exception( - "Command %s not available. Existing commands: %s" - % (command, RAINBIRD_COMMANDS.keys()) + raise RainbirdCodingException( + f"Command {command} not available. Existing commands: {RAINBIRD_COMMANDS.keys()}" ) + return encode_command(command_set, *args) + + +def encode_command(command_set: dict[str, Any], *args) -> str: + """Encode a rainbird tunnelSip command request.""" cmd_code = command_set["command"] - if len(args) > command_set["length"] - 1: - raise Exception( - "Too much parameters. %d expected:\n%s" - % (command_set["length"] - 1, command_set) + if not (length := command_set[LENGTH]): + raise RainbirdCodingException(f"Unable to encode command missing length: {command_set}") + if len(args) > length: + raise RainbirdCodingException( + f"Too many parameters. {length} expected: {command_set}" + ) + + if length == 1 or "parameter" in command_set or "parameterOne" in command_set: + # TODO: Replace old style encoding with new encoding below + params = (cmd_code,) + tuple(map(lambda x: int(x), args)) + arg_placeholders = ( + ("%%0%dX" % ((length - len(args)) * 2)) if len(args) > 0 else "" + ) + ("%02X" * (len(args) - 1)) + return ("%s" + arg_placeholders) % (params) + + data = cmd_code + ("00" * (length - 1)) + args_list = list(args) + for k in command_set: + if k in RESERVED_FIELDS: + continue + command_arg = command_set[k] + command_arg_length = command_arg[LENGTH] + arg = args_list.pop(0) + if isinstance(arg, str): + arg = int(arg, 16) + param_template = "%%0%dX" % (command_arg_length) + start_ = command_arg[POSITION] + end_ = start_ + command_arg_length + data = "%s%s%s" % ( + data[:start_], + # TODO: Replace with kwargs + (param_template % arg), + data[end_:], ) - params = (cmd_code,) + tuple(map(lambda x: int(x), args)) - arg_placeholders = ( - ("%%0%dX" % ((command_set["length"] - len(args)) * 2)) if len(args) > 0 else "" - ) + ("%02X" * (len(args) - 1)) - return ("%s" + arg_placeholders) % (params) + return data diff --git a/pyrainbird/resources/__init__.py b/pyrainbird/resources/__init__.py index fa9b322..186206b 100644 --- a/pyrainbird/resources/__init__.py +++ b/pyrainbird/resources/__init__.py @@ -9,8 +9,10 @@ TYPE = "type" LENGTH = "length" RESPONSE = "response" +POSITION = "position" +DECODER = "decoder" # Fields in the command template that should not be encoded -RESERVED_FIELDS = [COMMAND, TYPE, LENGTH, RESPONSE] +RESERVED_FIELDS = [COMMAND, TYPE, LENGTH, RESPONSE, DECODER] SIP_COMMANDS = yaml.load( resource_stream("pyrainbird.resources", "sipcommands.yaml"), Loader=yaml.FullLoader @@ -34,8 +36,11 @@ def build_id_map(commands: dict[str, Any]) -> dict[str, Any]: CONTROLLER_COMMANDS = "ControllerCommands" CONTROLLER_RESPONSES = "ControllerResponses" -RAINBIRD_COMMANDS = {**SIP_COMMANDS[CONTROLLER_COMMANDS]} -RAINBIRD_RESPONSES = {**SIP_COMMANDS[CONTROLLER_RESPONSES]} - -RAINBIRD_COMMANDS_BY_ID = build_id_map(SIP_COMMANDS[CONTROLLER_COMMANDS]) -RAINBIRD_RESPONSES_BY_ID = build_id_map(SIP_COMMANDS[CONTROLLER_RESPONSES]) +RAINBIRD_COMMANDS = { + **SIP_COMMANDS[CONTROLLER_COMMANDS], + **SIP_COMMANDS[CONTROLLER_RESPONSES], +} +RAINBIRD_COMMANDS_BY_ID = { + **build_id_map(SIP_COMMANDS[CONTROLLER_COMMANDS]), + **build_id_map(SIP_COMMANDS[CONTROLLER_RESPONSES]), +} diff --git a/pyrainbird/resources/sipcommands.yaml b/pyrainbird/resources/sipcommands.yaml index 63b190d..dad159a 100644 --- a/pyrainbird/resources/sipcommands.yaml +++ b/pyrainbird/resources/sipcommands.yaml @@ -6,12 +6,14 @@ ControllerCommands: length: 1 AvailableStationsRequest: command: '03' - parameter: 0 response: '83' length: 2 + page: + position: 2 + length: 2 CommandSupportRequest: command: '04' - commandToTest: '02' + parameter: '02' #commandToTest: '02' response: '84' length: 2 SerialNumberRequest: @@ -223,8 +225,8 @@ ControllerResponses: length: 3 RetrieveScheduleResponse: command: 'A0' - length: 18 - # Requires a custom processor that can't be expressed in yaml + # Requires a custom processor that can't be expressed in yaml. This + # does not set a length for now to indicate it requires custom encoding. decoder: decode_schedule WaterBudgetResponse: command: 'B0' diff --git a/tests/test_async_client.py b/tests/test_async_client.py index 8f1f762..8965557 100644 --- a/tests/test_async_client.py +++ b/tests/test_async_client.py @@ -7,16 +7,13 @@ import aiohttp import pytest -from pyrainbird import ( - AvailableStations, - ModelAndVersion, - WaterBudget, -) +from pyrainbird import AvailableStations, ModelAndVersion, WaterBudget +from pyrainbird import rainbird from pyrainbird.async_client import AsyncRainbirdClient, AsyncRainbirdController from pyrainbird.data import SoilType from pyrainbird.encryption import encrypt from pyrainbird.exceptions import RainbirdApiException, RainbirdAuthException -from pyrainbird.resources import RAINBIRD_RESPONSES_BY_ID, RESERVED_FIELDS +from pyrainbird.resources import RAINBIRD_COMMANDS_BY_ID, RESERVED_FIELDS from .conftest import LENGTH, PASSWORD, REQUEST, RESPONSE, RESULT_DATA, ResponseResult @@ -61,20 +58,8 @@ def mock_api_response(response: ResponseResult) -> Callable[[...], Awaitable[Non """Fixture to construct a fake API response.""" def _put_result(command: str, **kvargs) -> None: - resp = RAINBIRD_RESPONSES_BY_ID[command] - data = command + ("00" * (resp["length"] - 1)) - for k in resp: - if k in RESERVED_FIELDS: - continue - param_template = "%%0%dX" % (resp[k]["length"]) - start_ = resp[k]["position"] - end_ = start_ + resp[k]["length"] - data = "%s%s%s" % ( - data[:start_], - (param_template % kvargs[k]), - data[end_:], - ) - + command_set = RAINBIRD_COMMANDS_BY_ID[command] + data = rainbird.encode_command(command_set, *kvargs.values()) body = encrypt( ('{"jsonrpc": "2.0", "result": {"data":"%s"}, "id": 1} ' % data), PASSWORD, @@ -127,7 +112,7 @@ async def test_get_current_date( ) -> None: controller = await rainbird_controller() date = datetime.date.today() - api_response("92", year=date.year, month=date.month, day=date.day) + api_response("92", day=date.day, month=date.month, year=date.year) assert await controller.get_current_date() == date diff --git a/tests/test_integration.py b/tests/test_integration.py index aaff4e3..8a43340 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -4,14 +4,15 @@ import responses from pyrainbird import ( - RainbirdController, - ModelAndVersion, AvailableStations, CommandSupport, + ModelAndVersion, + RainbirdController, WaterBudget, ) -from pyrainbird.resources import RAINBIRD_RESPONSES_BY_ID, RESERVED_FIELDS +from pyrainbird import rainbird from pyrainbird.encryption import encrypt +from pyrainbird.resources import RAINBIRD_COMMANDS_BY_ID, RESERVED_FIELDS MOCKED_RAINBIRD_URL = "rainbird.local" MOCKED_PASSWORD = "test123" @@ -24,9 +25,7 @@ def test_get_model_and_version(self): "82", modelID=16, protocolRevisionMajor=1, protocolRevisionMinor=3 ) rainbird = RainbirdController(MOCKED_RAINBIRD_URL, MOCKED_PASSWORD) - self.assertEqual( - ModelAndVersion(16, 1, 3), rainbird.get_model_and_version() - ) + self.assertEqual(ModelAndVersion(16, 1, 3), rainbird.get_model_and_version()) @responses.activate def test_get_available_stations(self): @@ -40,9 +39,7 @@ def test_get_available_stations(self): def test_get_command_support(self): mock_response("84", commandEcho=6, support=1) rainbird = RainbirdController(MOCKED_RAINBIRD_URL, MOCKED_PASSWORD) - self.assertEqual( - CommandSupport(1, 6), rainbird.get_command_support(0x85) - ) + self.assertEqual(CommandSupport(1, 6), rainbird.get_command_support(0x85)) @responses.activate def test_get_serial_number(self): @@ -53,16 +50,14 @@ def test_get_serial_number(self): @responses.activate def test_get_current_time(self): time = datetime.time() - mock_response( - "90", hour=time.hour, minute=time.minute, second=time.second - ) + mock_response("90", hour=time.hour, minute=time.minute, second=time.second) rainbird = RainbirdController(MOCKED_RAINBIRD_URL, MOCKED_PASSWORD) self.assertEqual(time, rainbird.get_current_time()) @responses.activate def test_get_current_date(self): date = datetime.date.today() - mock_response("92", year=date.year, month=date.month, day=date.day) + mock_response("92", day=date.day, month=date.month, year=date.year) rainbird = RainbirdController(MOCKED_RAINBIRD_URL, MOCKED_PASSWORD) self.assertEqual(date, rainbird.get_current_date()) @@ -159,7 +154,7 @@ def _assert_zone_state(self, i, j): def mock_response(command, **kvargs): - resp = RAINBIRD_RESPONSES_BY_ID[command] + resp = RAINBIRD_COMMANDS_BY_ID[command] data = command + ("00" * (resp["length"] - 1)) for k in resp: if k in RESERVED_FIELDS: @@ -172,12 +167,13 @@ def mock_response(command, **kvargs): (param_template % kvargs[k]), data[end_:], ) + data = rainbird.encode_command(resp, *kvargs.values()) responses.add( responses.POST, "http://%s/stick" % MOCKED_RAINBIRD_URL, body=encrypt( - (u'{"jsonrpc": "2.0", "result": {"data":"%s"}, "id": 1} ' % data), + ('{"jsonrpc": "2.0", "result": {"data":"%s"}, "id": 1} ' % data), MOCKED_PASSWORD, ), content_type="application/octet-stream", diff --git a/tests/test_rainbird.py b/tests/test_rainbird.py index 1780bb3..c711089 100644 --- a/tests/test_rainbird.py +++ b/tests/test_rainbird.py @@ -4,8 +4,8 @@ from parameterized import parameterized from pytest_golden.plugin import GoldenTestFixture -from pyrainbird import rainbird from pyrainbird.rainbird import decode, encode +from pyrainbird.resources import LENGTH, RAINBIRD_COMMANDS def encode_name_func(testcase_func, param_num, param): @@ -28,10 +28,25 @@ def decode_name_func(testcase_func, param_num, param): def test_decode(golden: GoldenTestFixture) -> None: """Fixture to read golden file and compare to golden output.""" data = golden["data"] - decoded_data = [rainbird.decode(case) for case in data] + decoded_data = [decode(case) for case in data] assert decoded_data == golden.out["decoded_data"] +@pytest.mark.golden_test("testdata/*.yaml") +def test_encode(golden: GoldenTestFixture) -> None: + """Test that we can re-encode decoded output to get back the original.""" + data = golden["data"] + decoded_data = [decode(case) for case in data] + + for entry in decoded_data: + command = entry["type"] + del entry["type"] + expected_data = data.pop(0) + if LENGTH not in RAINBIRD_COMMANDS[command]: + continue + assert encode(command, *entry.values()) == expected_data + + class TestSequence(unittest.TestCase): @parameterized.expand( [ diff --git a/tests/testdata/available_stations.yaml b/tests/testdata/available_stations.yaml index 3979c3d..7ceecdd 100644 --- a/tests/testdata/available_stations.yaml +++ b/tests/testdata/available_stations.yaml @@ -1,6 +1,16 @@ data: - - 830F0010 + - "0300" + - "83003F000000" + - "0315" + - "83FF00000000" decoded_data: + - type: AvailableStationsRequest + page: 0 - type: AvailableStationsResponse - pageNumber: 15 - setStations: 16 + pageNumber: 0 + setStations: 0x3F000000 + - type: AvailableStationsRequest + page: 0x15 + - type: AvailableStationsResponse + pageNumber: 255 + setStations: 0 diff --git a/tests/testdata/model_and_version.yaml b/tests/testdata/model_and_version.yaml index 03a2f38..101a95f 100644 --- a/tests/testdata/model_and_version.yaml +++ b/tests/testdata/model_and_version.yaml @@ -1,7 +1,9 @@ data: + - "02" - "820006090C" - "850000000000008963" decoded_data: + - type: ModelAndVersionRequest - type: ModelAndVersionResponse modelID: 6 protocolRevisionMajor: 9 diff --git a/tests/testdata/settings.yaml b/tests/testdata/settings.yaml index 95f1bb8..b89884e 100644 --- a/tests/testdata/settings.yaml +++ b/tests/testdata/settings.yaml @@ -4,7 +4,8 @@ data: - B0000064 - B0010050 - B0020050 - - BB0000000000000000FF0000 + # Not decoding current queue + # - BB0000000000000000FF0000 - BF0000000000 - CA012B483163 - CC1228270417E700040001FFFF000000 @@ -26,7 +27,7 @@ decoded_data: programCode: 2 seasonalAdjust: 80 # Not currently parsed CurrentQueueResponse - - data: BB0000000000000000FF0000 + # - data: BB0000000000000000FF0000 - type: CurrentStationsActiveResponse activeStations: 0 pageNumber: 0