diff --git a/pyrainbird/__init__.py b/pyrainbird/__init__.py index 7fa5960..a4962a7 100644 --- a/pyrainbird/__init__.py +++ b/pyrainbird/__init__.py @@ -11,7 +11,7 @@ States, WaterBudget, ) -from pyrainbird.resources import RAINBIRD_COMMANDS +from pyrainbird.resources import RAINBIRD_COMMANDS, RAINBIRD_RESPONSES_BY_ID from . import rainbird from .client import RainbirdClient @@ -50,7 +50,7 @@ def get_model_and_version(self): def get_available_stations(self, page=_DEFAULT_PAGE): mask = ( "%%0%dX" - % RAINBIRD_COMMANDS["ControllerResponses"]["83"]["setStations"]["length"] + % RAINBIRD_RESPONSES_BY_ID["83"]["setStations"]["length"] ) return self._process_command( lambda resp: AvailableStations( @@ -147,11 +147,11 @@ def get_current_irrigation(self): ) def command(self, command, *args): - data = rainbird.encode(command, *args) + data = rainbird.encode(f"{command}Request", *args) self.logger.debug("Request to line: " + str(data)) decrypted_data = self.rainbird_client.request( data, - RAINBIRD_COMMANDS["ControllerCommands"]["%sRequest" % command]["length"], + RAINBIRD_COMMANDS["%sRequest" % command]["length"], ) self.logger.debug("Response from line: " + str(decrypted_data)) if decrypted_data is None: @@ -160,14 +160,14 @@ def command(self, command, *args): decoded = rainbird.decode(decrypted_data) if ( decrypted_data[:2] - != RAINBIRD_COMMANDS["ControllerCommands"]["%sRequest" % command][ + != RAINBIRD_COMMANDS["%sRequest" % command][ "response" ] ): raise Exception( "Status request failed with wrong response! Requested %s but got %s:\n%s" % ( - RAINBIRD_COMMANDS["ControllerCommands"]["%sRequest" % command][ + RAINBIRD_COMMANDS["%sRequest" % command][ "response" ], decrypted_data[:2], @@ -183,8 +183,8 @@ def _process_command(self, funct, cmd, *args): funct(response) if response is not None and response["type"] - == RAINBIRD_COMMANDS["ControllerResponses"][ - RAINBIRD_COMMANDS["ControllerCommands"][cmd + "Request"]["response"] + == RAINBIRD_RESPONSES_BY_ID[ + RAINBIRD_COMMANDS[cmd + "Request"]["response"] ]["type"] else response ) @@ -192,7 +192,7 @@ def _process_command(self, funct, cmd, *args): def _update_irrigation_state(self, page=_DEFAULT_PAGE): mask = ( "%%0%dX" - % RAINBIRD_COMMANDS["ControllerResponses"]["BF"]["activeStations"]["length"] + % RAINBIRD_RESPONSES_BY_ID["BF"]["activeStations"]["length"] ) result = self._process_command( lambda resp: States((mask % resp["activeStations"])[:4]), diff --git a/pyrainbird/async_client.py b/pyrainbird/async_client.py index d0caa63..0913eb1 100644 --- a/pyrainbird/async_client.py +++ b/pyrainbird/async_client.py @@ -32,7 +32,7 @@ ZipCode, ) from .exceptions import RainbirdApiException, RainbirdAuthException -from .resources import RAINBIRD_COMMANDS +from .resources import LENGTH, RAINBIRD_COMMANDS, RAINBIRD_RESPONSES, RESPONSE _LOGGER = logging.getLogger(__name__) T = TypeVar("T") @@ -68,7 +68,7 @@ def __init__( async def tunnelSip(self, data: str, length: int) -> str: """Send a tunnelSip request.""" - result = await self.request("tunnelSip", {"data": data, "length": length}) + result = await self.request("tunnelSip", {"data": data, LENGTH: length}) return result["data"] async def request( @@ -123,41 +123,41 @@ async def get_model_and_version(self) -> ModelAndVersion: response["protocolRevisionMajor"], response["protocolRevisionMinor"], ), - "ModelAndVersion", + "ModelAndVersionRequest", ) async def get_available_stations(self, page=_DEFAULT_PAGE) -> AvailableStations: """Get the available stations.""" mask = ( "%%0%dX" - % RAINBIRD_COMMANDS["ControllerResponses"]["83"]["setStations"]["length"] + % RAINBIRD_RESPONSES["AvailableStationsResponse"]["setStations"][LENGTH] ) return await self._process_command( lambda resp: AvailableStations( mask % resp["setStations"], page=resp["pageNumber"] ), - "AvailableStations", + "AvailableStationsRequest", page, ) async def get_serial_number(self) -> str: """Get the device serial number.""" return await self._process_command( - lambda resp: resp["serialNumber"], "SerialNumber" + lambda resp: resp["serialNumber"], "SerialNumberRequest" ) async def get_current_time(self) -> datetime.time: """Get the device current time.""" return await self._process_command( lambda resp: datetime.time(resp["hour"], resp["minute"], resp["second"]), - "CurrentTime", + "CurrentTimeRequest", ) async def get_current_date(self) -> datetime.date: """Get the device current date.""" return await self._process_command( lambda resp: datetime.date(resp["year"], resp["month"], resp["day"]), - "CurrentDate", + "CurrentDateRequest", ) async def get_wifi_params(self) -> WifiParams: @@ -199,7 +199,7 @@ async def water_budget(self, budget) -> WaterBudget: """Return the water budget.""" return await self._process_command( lambda resp: WaterBudget(resp["programCode"], resp["seasonalAdjust"]), - "WaterBudget", + "WaterBudgetRequest", budget, ) @@ -207,18 +207,20 @@ async def get_rain_sensor_state(self) -> bool: """Get the current state for the rain sensor.""" return await self._process_command( lambda resp: bool(resp["sensorState"]), - "CurrentRainSensorState", + "CurrentRainSensorStateRequest", ) async def get_zone_states(self, page=_DEFAULT_PAGE) -> States: """Return the current state of the zone.""" mask = ( "%%0%dX" - % RAINBIRD_COMMANDS["ControllerResponses"]["BF"]["activeStations"]["length"] + % RAINBIRD_RESPONSES["CurrentStationsActiveResponse"]["activeStations"][ + LENGTH + ] ) return await self._process_command( lambda resp: States((mask % resp["activeStations"])[:4]), - "CurrentStationsActive", + "CurrentStationsActiveRequest", page, ) @@ -229,41 +231,43 @@ async def get_zone_state(self, zone: int) -> bool: async def set_program(self, program: int) -> None: """Start a program.""" - await self._process_command(lambda resp: True, "ManuallyRunProgram", program) + await self._process_command( + lambda resp: True, "ManuallyRunProgramRequest", program + ) async def test_zone(self, zone: int) -> None: """Test a zone.""" - await self._process_command(lambda resp: True, "TestStations", zone) + await self._process_command(lambda resp: True, "TestStationsRequest", zone) async def irrigate_zone(self, zone: int, minutes: int) -> None: """Send the irrigate command.""" await self._process_command( - lambda resp: True, "ManuallyRunStation", zone, minutes + lambda resp: True, "ManuallyRunStationRequest", zone, minutes ) async def stop_irrigation(self) -> None: """Send the stop command.""" - await self._process_command(lambda resp: True, "StopIrrigation") + await self._process_command(lambda resp: True, "StopIrrigationRequest") async def get_rain_delay(self) -> int: """Return the current rain delay value.""" return await self._process_command( - lambda resp: resp["delaySetting"], "RainDelayGet" + lambda resp: resp["delaySetting"], "RainDelayGetRequest" ) async def set_rain_delay(self, days: int) -> None: """Set the rain delay value in days.""" - await self._process_command(lambda resp: True, "RainDelaySet", days) + await self._process_command(lambda resp: True, "RainDelaySetRequest", days) async def advance_zone(self, param: int) -> None: """Advance to the zone with the specified param.""" - await self._process_command(lambda resp: True, "AdvanceStation", param) + await self._process_command(lambda resp: True, "AdvanceStationRequest", param) async def get_current_irrigation(self) -> bool: """Return True if the irrigation state is on.""" return await self._process_command( lambda resp: bool(resp["irrigationState"]), - "CurrentIrrigationState", + "CurrentIrrigationStateRequest", ) async def get_schedule_and_settings(self, stick_id: str) -> ScheduleAndSettings: @@ -297,7 +301,8 @@ async def get_weather_and_status( async def get_combined_controller_state(self) -> ControllerState: """Return the combined controller state.""" return await self._process_command( - lambda resp: ControllerState.parse_obj(resp), "CombinedControllerState" + lambda resp: ControllerState.parse_obj(resp), + "CombinedControllerStateRequest", ) async def get_controller_firmware_version(self) -> ControllerFirmwareVersion: @@ -306,21 +311,21 @@ async def get_controller_firmware_version(self) -> ControllerFirmwareVersion: lambda resp: ControllerFirmwareVersion( resp["major"], resp["minor"], resp["patch"] ), - "ControllerFirmwareVersion", + "ControllerFirmwareVersionRequest", ) async def get_schedule(self, command_code: str) -> dict[str, Any]: """Run the schedule command for the specified raw command code.""" return await self._process_command( lambda resp: resp, - "RetrieveSchedule", + "RetrieveScheduleRequest", command_code, ) async def test_command_support(self, command_id: int) -> bool: """Debugging command to test if the device supports the specified command.""" return await self._process_command( - lambda resp: bool(resp["support"]), "CommandSupport", command_id + lambda resp: bool(resp["support"]), "CommandSupportRequest", command_id ) async def test_rpc_support(self, rpc: str) -> dict[str, Any]: @@ -330,41 +335,25 @@ async def test_rpc_support(self, rpc: str) -> dict[str, Any]: async def _command(self, command: str, *args) -> dict[str, Any]: data = rainbird.encode(command, *args) _LOGGER.debug("Request to line: " + str(data)) + command_data = RAINBIRD_COMMANDS[command] decrypted_data = await self._local_client.tunnelSip( data, - RAINBIRD_COMMANDS["ControllerCommands"]["%sRequest" % command]["length"], + command_data[LENGTH], ) _LOGGER.debug("Response from line: " + str(decrypted_data)) decoded = rainbird.decode(decrypted_data) - if ( - decrypted_data[:2] - != RAINBIRD_COMMANDS["ControllerCommands"]["%sRequest" % command][ - "response" - ] - ): + response_code = decrypted_data[:2] + expected_response_code = command_data[RESPONSE] + if response_code != expected_response_code: raise RainbirdApiException( "Status request failed with wrong response! Requested %s but got %s:\n%s" - % ( - RAINBIRD_COMMANDS["ControllerCommands"]["%sRequest" % command][ - "response" - ], - decrypted_data[:2], - decoded, - ) + % (expected_response_code, response_code, decoded) ) _LOGGER.debug("Response: %s" % decoded) return decoded async def _process_command( - self, funct: Callable[[dict[str, Any]], T], cmd, *args + self, funct: Callable[[dict[str, Any]], T], command: str, *args ) -> T: - response = await self._command(cmd, *args) - response_type = response["type"] - expected_type = RAINBIRD_COMMANDS["ControllerResponses"][ - RAINBIRD_COMMANDS["ControllerCommands"][cmd + "Request"]["response"] - ]["type"] - if response_type != expected_type: - raise RainbirdApiException( - f"Response type '{response_type}' did not match '{expected_type}" - ) + response = await self._command(command, *args) return funct(response) diff --git a/pyrainbird/rainbird.py b/pyrainbird/rainbird.py index dd7385d..9cc32a9 100644 --- a/pyrainbird/rainbird.py +++ b/pyrainbird/rainbird.py @@ -1,9 +1,12 @@ """Library for encoding and decoding rainbird tunnelSip commands.""" from collections.abc import Callable +import logging from typing import Any -from .resources import RAINBIRD_COMMANDS +from .resources import RAINBIRD_COMMANDS, RAINBIRD_RESPONSES_BY_ID + +_LOGGER = logging.getLogger(__name__) def decode_template(data: str, cmd_template: dict[str, Any]) -> dict[str, int]: @@ -89,24 +92,21 @@ 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 command_code not in RAINBIRD_COMMANDS["ControllerResponses"]: + if not (cmd_template := RAINBIRD_RESPONSES_BY_ID.get(command_code)): + _LOGGER.warning("Unrecognized server response code '%s' from '%s'", command_code, data) return {"data": data} - cmd_template = RAINBIRD_COMMANDS["ControllerResponses"][command_code] 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.""" - request_command = "%sRequest" % command - command_set = RAINBIRD_COMMANDS["ControllerCommands"][request_command] - if request_command in RAINBIRD_COMMANDS["ControllerCommands"]: - cmd_code = command_set["command"] - else: + if not (command_set := RAINBIRD_COMMANDS.get(command)): raise Exception( "Command %s not available. Existing commands: %s" - % (request_command, RAINBIRD_COMMANDS["ControllerCommands"]) + % (command, RAINBIRD_COMMANDS.keys()) ) + cmd_code = command_set["command"] if len(args) > command_set["length"] - 1: raise Exception( "Too much parameters. %d expected:\n%s" diff --git a/pyrainbird/resources/__init__.py b/pyrainbird/resources/__init__.py index 4fd58f0..fa9b322 100644 --- a/pyrainbird/resources/__init__.py +++ b/pyrainbird/resources/__init__.py @@ -1,10 +1,41 @@ +"""Resources related to rainbird devices.""" + +from typing import Any + import yaml from pkg_resources import resource_stream -# parameters in number of nibbles (based on string representations of SIP bytes), total lengths in number of SIP bytes -RAINBIRD_COMMANDS = yaml.load( +COMMAND = "command" +TYPE = "type" +LENGTH = "length" +RESPONSE = "response" +# Fields in the command template that should not be encoded +RESERVED_FIELDS = [COMMAND, TYPE, LENGTH, RESPONSE] + +SIP_COMMANDS = yaml.load( resource_stream("pyrainbird.resources", "sipcommands.yaml"), Loader=yaml.FullLoader ) RAINBIRD_MODELS = yaml.load( resource_stream("pyrainbird.resources", "models.yaml"), Loader=yaml.FullLoader ) + + +def build_id_map(commands: dict[str, Any]) -> dict[str, Any]: + """Build an ID based map for the specified command struct.""" + return { + content[COMMAND]: { + **content, + TYPE: key, + } + for key, content in commands.items() + } + + +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]) diff --git a/pyrainbird/resources/sipcommands.yaml b/pyrainbird/resources/sipcommands.yaml index 32871bb..63b190d 100644 --- a/pyrainbird/resources/sipcommands.yaml +++ b/pyrainbird/resources/sipcommands.yaml @@ -133,24 +133,24 @@ ControllerCommands: # LogEntriesRequest: 70 ControllerResponses: - '00': + NotAcknowledgeResponse: + command: '00' length: 3 - type: NotAcknowledgeResponse commandEcho: position: 2 length: 2 NAKCode: position: 4 length: 2 - '01': + AcknowledgeResponse: + command: '01' length: 2 - type: AcknowledgeResponse commandEcho: position: 2 length: 2 - '82': + ModelAndVersionResponse: + command: '82' length: 5 - type: ModelAndVersionResponse modelID: position: 2 length: 4 @@ -160,33 +160,33 @@ ControllerResponses: protocolRevisionMinor: position: 8 length: 2 - '83': + AvailableStationsResponse: + command: '83' length: 6 - type: AvailableStationsResponse pageNumber: position: 2 length: 2 setStations: position: 4 length: 8 - '84': + CommandSupportResponse: + command: '84' length: 3 - type: CommandSupportResponse commandEcho: position: 2 length: 2 support: position: 4 length: 2 - '85': + SerialNumberResponse: + command: '85' length: 9 - type: SerialNumberResponse serialNumber: position: 2 length: 16 - '8B': + ControllerFirmwareVersionResponse: + command: '8B' length: 5 - type: ControllerFirmwareVersion major: position: 2 length: 2 @@ -197,9 +197,9 @@ ControllerResponses: position: 6 length: 4 # UniversalMessageTransportResponse 8C - '90': + CurrentTimeResponse: + command: '90' length: 4 - type: CurrentTimeResponse hour: position: 2 length: 2 @@ -209,9 +209,9 @@ ControllerResponses: second: position: 6 length: 2 - '92': + CurrentDateResponse: + command: '92' length: 4 - type: CurrentDateResponse day: position: 2 length: 2 @@ -221,21 +221,22 @@ ControllerResponses: year: position: 5 length: 3 - 'A0': + RetrieveScheduleResponse: + command: 'A0' length: 18 - type: RetrieveScheduleResponse # Requires a custom processor that can't be expressed in yaml decoder: decode_schedule - 'B0': + WaterBudgetResponse: + command: 'B0' length: 4 - type: WaterBudgetResponse programCode: position: 2 length: 2 seasonalAdjust: position: 4 length: 4 - 'B2': + ZonesSeasonalAdjustFactorResponse: + command: 'B2' length: 18 type: ZonesSeasonalAdjustFactorResponse programCode: @@ -244,46 +245,47 @@ ControllerResponses: stationsSA: position: 4 length: 32 - 'B6': + RainDelaySettingResponse: + command: 'B6' length: 3 type: RainDelaySettingResponse delaySetting: position: 2 length: 4 # CurrentQueueResponse BB - 'BE': + CurrentRainSensorStateResponse: + command: 'BE' length: 2 - type: CurrentRainSensorStateResponse sensorState: position: 2 length: 2 - 'BF': + CurrentStationsActiveResponse: + command: 'BF' length: 6 - type: CurrentStationsActiveResponse pageNumber: position: 2 length: 2 activeStations: position: 4 length: 8 - 'C8': + CurrentIrrigationStateResponse: + command: 'C8' length: 2 - type: CurrentIrrigationStateResponse irrigationState: position: 2 length: 2 - 'CA': + ControllerEventTimestampResponse: + command: 'CA' length: 6 - type: ControllerEventTimestampResponse eventId: position: 2 length: 2 timestamp: position: 4 length: 8 - 'CC': + CombinedControllerStateResponse: + command: 'CC' length: 16 - type: CombinedControllerStateResponse hour: position: 2 length: 2 diff --git a/tests/test_async_client.py b/tests/test_async_client.py index 21d8180..8f1f762 100644 --- a/tests/test_async_client.py +++ b/tests/test_async_client.py @@ -8,7 +8,6 @@ import pytest from pyrainbird import ( - RAINBIRD_COMMANDS, AvailableStations, ModelAndVersion, WaterBudget, @@ -17,6 +16,7 @@ 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 .conftest import LENGTH, PASSWORD, REQUEST, RESPONSE, RESULT_DATA, ResponseResult @@ -61,10 +61,10 @@ 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_COMMANDS["ControllerResponses"][command] + resp = RAINBIRD_RESPONSES_BY_ID[command] data = command + ("00" * (resp["length"] - 1)) for k in resp: - if k in ["type", "length"]: + if k in RESERVED_FIELDS: continue param_template = "%%0%dX" % (resp[k]["length"]) start_ = resp[k]["position"] diff --git a/tests/test_integration.py b/tests/test_integration.py index b9ab011..aaff4e3 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -5,12 +5,12 @@ from pyrainbird import ( RainbirdController, - RAINBIRD_COMMANDS, ModelAndVersion, AvailableStations, CommandSupport, WaterBudget, ) +from pyrainbird.resources import RAINBIRD_RESPONSES_BY_ID, RESERVED_FIELDS from pyrainbird.encryption import encrypt MOCKED_RAINBIRD_URL = "rainbird.local" @@ -159,10 +159,10 @@ def _assert_zone_state(self, i, j): def mock_response(command, **kvargs): - resp = RAINBIRD_COMMANDS["ControllerResponses"][command] + resp = RAINBIRD_RESPONSES_BY_ID[command] data = command + ("00" * (resp["length"] - 1)) for k in resp: - if k in ["type", "length"]: + if k in RESERVED_FIELDS: continue param_template = "%%0%dX" % (resp[k]["length"]) start_ = resp[k]["position"] diff --git a/tests/test_rainbird.py b/tests/test_rainbird.py index e7733e2..1780bb3 100644 --- a/tests/test_rainbird.py +++ b/tests/test_rainbird.py @@ -56,4 +56,4 @@ class TestSequence(unittest.TestCase): name_func=encode_name_func, ) def test_encode(self, expected, command, *vargs): - self.assertEqual(expected, encode(command, *vargs)) + self.assertEqual(expected, encode(f"{command}Request", *vargs)) diff --git a/tests/testdata/settings.yaml b/tests/testdata/settings.yaml index f192576..95f1bb8 100644 --- a/tests/testdata/settings.yaml +++ b/tests/testdata/settings.yaml @@ -12,7 +12,7 @@ decoded_data: - type: AvailableStationsResponse setStations: 0x3F000000 pageNumber: 0 - - type: ControllerFirmwareVersion + - type: ControllerFirmwareVersionResponse major: 1 minor: 47 patch: 0