Skip to content

Commit

Permalink
Combine request and response encoding/decoding (#81)
Browse files Browse the repository at this point in the history
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
  • Loading branch information
allenporter authored Jan 11, 2023
1 parent d5fab2e commit d9f93ea
Show file tree
Hide file tree
Showing 13 changed files with 147 additions and 100 deletions.
29 changes: 8 additions & 21 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, RAINBIRD_RESPONSES_BY_ID
from pyrainbird.resources import RAINBIRD_COMMANDS, RAINBIRD_COMMANDS_BY_ID

from . import rainbird
from .client import RainbirdClient
Expand Down Expand Up @@ -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"]
Expand Down Expand Up @@ -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,
)
Expand All @@ -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",
Expand Down
6 changes: 3 additions & 3 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 LENGTH, RAINBIRD_COMMANDS, RAINBIRD_RESPONSES, RESPONSE
from .resources import LENGTH, RAINBIRD_COMMANDS, RESPONSE

_LOGGER = logging.getLogger(__name__)
T = TypeVar("T")
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
]
)
Expand Down
1 change: 0 additions & 1 deletion pyrainbird/data.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions pyrainbird/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,6 @@ class RainbirdApiException(Exception):
class RainbirdAuthException(Exception):
"""Authentication exception from rainbird API."""


class RainbirdCodingException(Exception):
"""Error while encoding or decoding objects."""
86 changes: 64 additions & 22 deletions pyrainbird/rainbird.py
Original file line number Diff line number Diff line change
@@ -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__)

Expand All @@ -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


Expand Down Expand Up @@ -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
17 changes: 11 additions & 6 deletions pyrainbird/resources/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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]),
}
10 changes: 6 additions & 4 deletions pyrainbird/resources/sipcommands.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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'
Expand Down
27 changes: 6 additions & 21 deletions tests/test_async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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


Expand Down
Loading

0 comments on commit d9f93ea

Please sign in to comment.