Skip to content

Commit

Permalink
Cleanup internal resources for requests and responses (#80)
Browse files Browse the repository at this point in the history
Update the rainbird request/response objects to be in the same format,
so that we can later encode/decode them using the same functions.
  • Loading branch information
allenporter authored Jan 10, 2023
1 parent 13bb8b4 commit d5fab2e
Show file tree
Hide file tree
Showing 9 changed files with 132 additions and 110 deletions.
18 changes: 9 additions & 9 deletions pyrainbird/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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:
Expand All @@ -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],
Expand All @@ -183,16 +183,16 @@ 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
)

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]),
Expand Down
85 changes: 37 additions & 48 deletions pyrainbird/async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -199,26 +199,28 @@ 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,
)

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,
)

Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand All @@ -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]:
Expand All @@ -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)
18 changes: 9 additions & 9 deletions pyrainbird/rainbird.py
Original file line number Diff line number Diff line change
@@ -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]:
Expand Down Expand Up @@ -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"
Expand Down
35 changes: 33 additions & 2 deletions pyrainbird/resources/__init__.py
Original file line number Diff line number Diff line change
@@ -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])
Loading

0 comments on commit d5fab2e

Please sign in to comment.