Skip to content

Commit

Permalink
feat: initial work towards using pydantic
Browse files Browse the repository at this point in the history
  • Loading branch information
cryptk committed May 25, 2023
1 parent ddafeb1 commit 9c4b4b6
Show file tree
Hide file tree
Showing 15 changed files with 1,252 additions and 76 deletions.
82 changes: 82 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
name: CI

on:
push:
branches:
- main
pull_request:

concurrency:
group: ${{ github.head_ref || github.run_id }}
cancel-in-progress: true

jobs:
# Make sure commit messages follow the conventional commits convention:
# https://www.conventionalcommits.org
commitlint:
name: Lint Commit Messages
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
- uses: wagoid/commitlint-github-action@v5.3.0
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-python@v4
with:
python-version: "3.10"
- uses: pre-commit/action@v3.0.0

test:
strategy:
fail-fast: false
matrix:
python-version:
- "3.10"
- "3.11"
os:
- ubuntu-latest
- windows-latest
- macOS-latest
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v3
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
- uses: snok/install-poetry@v1.3.3
- name: Install Dependencies
run: poetry install
shell: bash
- name: Test with Pytest
run: poetry run pytest
shell: bash
release:
runs-on: ubuntu-latest
environment: release
if: github.ref == 'refs/heads/main'
needs:
- test

steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
persist-credentials: false

# Run semantic release:
# - Update CHANGELOG.md
# - Update version in code
# - Create git tag
# - Create GitHub release
# - Publish to PyPI
- name: Python Semantic Release
uses: relekang/python-semantic-release@v7.33.2
with:
github_token: ${{ secrets.GH_TOKEN }}
repository_username: __token__
repository_password: ${{ secrets.PYPI_TOKEN }}
54 changes: 54 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# See https://pre-commit.com for more information
# See https://pre-commit.com/hooks.html for more hooks
exclude: "CHANGELOG.md"
default_stages: [ commit ]

repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.4.0
hooks:
- id: debug-statements
- id: check-builtin-literals
- id: check-case-conflict
- id: check-docstring-first
- id: check-json
exclude: ^.vscode/
- id: check-toml
- id: detect-private-key
- id: end-of-file-fixer
- id: trailing-whitespace
- repo: https://github.com/python-poetry/poetry
rev: 1.3.2
hooks:
- id: poetry-check
- repo: https://github.com/PyCQA/isort
rev: 5.12.0
hooks:
- id: isort
- repo: https://github.com/psf/black
rev: 22.12.0
hooks:
- id: black
- repo: https://github.com/codespell-project/codespell
rev: v2.2.2
hooks:
- id: codespell
- repo: https://github.com/charliermarsh/ruff-pre-commit
rev: v0.0.260
hooks:
- id: ruff
args:
- --fix
- repo: local
hooks:
- id: pylint
name: pylint
entry: poetry run -- pylint
language: system
types: [python]
# - repo: https://github.com/pre-commit/mirrors-mypy
# rev: v1.2.0
# hooks:
# - id: mypy
# exclude: cli.py
# additional_dependencies: [ "pydantic" ]
7 changes: 6 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
{
"files.trimTrailingWhitespace": true,
"editor.rulers": [140]
"editor.rulers": [140],
"python.formatting.provider": "black",
// https://code.visualstudio.com/docs/python/testing#_pytest-configuration-settings
"python.testing.pytestEnabled": false,
"python.linting.mypyEnabled": true,
"python.analysis.typeCheckingMode": "basic"
}
595 changes: 592 additions & 3 deletions poetry.lock

Large diffs are not rendered by default.

60 changes: 30 additions & 30 deletions pyomnilogic_local/api.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
from __future__ import annotations

import asyncio
import logging
import random
import struct
import time
from typing import Union
from typing import Any
import xml.etree.ElementTree as ET
import zlib

from .models.leadmessage import LeadMessage
from .types import ColorLogicBrightness, ColorLogicShow, ColorLogicSpeed, MessageType

_LOGGER = logging.getLogger(__name__)
Expand All @@ -15,7 +18,7 @@
class OmniLogicRequest:
HEADER_FORMAT = "!LQ4sLBBBB"

def __init__(self, msg_id, msg_type: MessageType, extra_data="", client_type=1):
def __init__(self, msg_id: int, msg_type: MessageType, extra_data: str = "", client_type: int = 1) -> None:
self.msg_id = msg_id
self.msg_type = msg_type
self.client_type = client_type
Expand All @@ -24,7 +27,7 @@ def __init__(self, msg_id, msg_type: MessageType, extra_data="", client_type=1):

self.version = "1.19".encode("ascii")

def to_bytes(self):
def to_bytes(self) -> bytes:
retval = struct.pack(
OmniLogicRequest.HEADER_FORMAT,
self.msg_id, # Msg id
Expand All @@ -40,23 +43,23 @@ def to_bytes(self):
return retval + self.extra_data

@staticmethod
def from_bytes(data):
def from_bytes(data: bytes) -> tuple[int, int, float, MessageType, int, Any, int, Any, bytes]:
# split the header and data
header = data[0:24]
rdata = data[24:]
rdata: bytes = data[24:]

msg_id, tstamp, vers, msg_type, client_type, res1, compressed, res3 = struct.unpack(OmniLogicRequest.HEADER_FORMAT, header)
return msg_id, tstamp, vers, MessageType(msg_type), client_type, res1, compressed, res3, rdata
return int(msg_id), int(tstamp), float(vers), MessageType(msg_type), client_type, res1, compressed, res3, rdata


class OmniLogicAPI:
def __init__(self, controller_ip_and_port, response_timeout):
def __init__(self, controller_ip_and_port: tuple[str, int], response_timeout: float) -> None:
self.controller_ip_and_port = controller_ip_and_port
self.response_timeout = response_timeout
self._loop = asyncio.get_running_loop()
self._protocol_factory = OmniLogicProtocol

async def _get_endpoint(self):
async def _get_endpoint(self) -> tuple[asyncio.DatagramTransport, OmniLogicProtocol]:
return await self._loop.create_datagram_endpoint(self._protocol_factory, remote_addr=self.controller_ip_and_port)

async def async_get_alarm_list(self):
Expand Down Expand Up @@ -130,7 +133,7 @@ async def async_set_heater(self, pool_id: int, equipment_id: int, temperature: i
finally:
transport.close()

async def async_set_heater_enable(self, pool_id: int, equipment_id: int, enabled: Union[int, bool]):
async def async_set_heater_enable(self, pool_id: int, equipment_id: int, enabled: int | bool):
"""async_set_heater_enable handles sending a SetHeaterEnable XML API call to the Hayward Omni pool controller
Args:
Expand All @@ -151,12 +154,11 @@ async def async_set_heater_enable(self, pool_id: int, equipment_id: int, enabled
finally:
transport.close()

# pylint: disable=too-many-arguments,too-many-locals
async def async_set_equipment(
self,
pool_id: int,
equipment_id: int,
is_on: Union[int, bool],
is_on: int | bool,
is_countdown_timer: bool = False,
start_time_hours: int = 0,
start_time_minutes: int = 0,
Expand Down Expand Up @@ -276,25 +278,27 @@ async def async_set_light_show(


class OmniLogicProtocol(asyncio.DatagramProtocol):
def __init__(self):
transport: asyncio.DatagramTransport

def __init__(self) -> None:
self.data_queue = asyncio.Queue()
self.transport = None
# self.transport: asyncio.DatagramTransport = None

def connection_made(self, transport):
def connection_made(self, transport) -> None:
self.transport = transport

def connection_lost(self, exc):
if exc:
raise exc

def datagram_received(self, data, addr):
def datagram_received(self, data: bytes, addr):
msg_id, _, _, msg_type, _, _, compressed, _, data = OmniLogicRequest.from_bytes(data)
self.data_queue.put_nowait((msg_id, msg_type, compressed, data))

def error_received(self, exc):
def error_received(self, exc: Exception) -> None:
raise exc

async def _send_request(self, msg_type, extra_data="", msg_id=None):
async def _send_request(self, msg_type: MessageType, extra_data: str = "", msg_id: int | None = None) -> None:
# If we aren't sending a specific msg_id, lets randomize it
if not msg_id:
msg_id = random.randrange(2**32)
Expand All @@ -317,15 +321,15 @@ async def _send_request(self, msg_type, extra_data="", msg_id=None):
while rec_msg_id != msg_id:
rec_msg_id, msg_type, _, _ = await self.data_queue.get()

async def _send_ack(self, msg_id):
async def _send_ack(self, msg_id: int) -> None:
body_element = ET.Element("Request", {"xmlns": "http://nextgen.hayward.com/api"})
name_element = ET.SubElement(body_element, "Name")
name_element.text = "Ack"

req_body = ET.tostring(body_element, xml_declaration=True, encoding="unicode")
await self._send_request(MessageType.XML_ACK, req_body, msg_id)

async def _receive_file(self):
async def _receive_file(self) -> str:
# wait for the initial packet.
msg_id, msg_type, compressed, data = await self.data_queue.get()
if compressed:
Expand All @@ -341,16 +345,14 @@ async def _receive_file(self):

# If the response is too large, the controller will send a LeadMessage indicating how many follow-up messages will be sent
if msg_type == MessageType.MSP_LEADMESSAGE:
# Parse XML
root = ET.fromstring(data[:-1]) # strip trailing \x00
block_count = int(root.findall(".//*[@name='MsgBlockCount']")[0].text)
leadmsg = LeadMessage.from_orm(ET.fromstring(data[:-1]))

# Wait for the block data data
retval = b""
retval: bytes = b""
# If we received a LeadMessage, continue to receive messages until we have all of our data
# Fragments of data may arrive out of order, so we store them in a buffer as they arrive and sort them after
data_fragments: dict = {}
while len(data_fragments) < block_count:
data_fragments: dict[int, bytes] = {}
while len(data_fragments) < leadmsg.msg_block_count:
msg_id, msg_type, compressed, data = await self.data_queue.get()
await self._send_ack(msg_id)
# remove an 8 byte header to get to the payload data
Expand Down Expand Up @@ -400,7 +402,7 @@ async def get_alarm_list(self):
data = await self._receive_file()
return data

async def get_config(self):
async def get_config(self) -> str:
body_element = ET.Element("Request", {"xmlns": "http://nextgen.hayward.com/api"})

name_element = ET.SubElement(body_element, "Name")
Expand Down Expand Up @@ -448,7 +450,7 @@ async def set_heater_enable(
self,
pool_id: int,
equipment_id: int,
enabled: Union[int, bool],
enabled: int | bool,
):
"""set_heater_enabled handles sending a SetHeaterEnable XML API call to the Hayward Omni pool controller
Expand Down Expand Up @@ -511,12 +513,11 @@ async def set_heater(
req_body = ET.tostring(body_element, xml_declaration=True, encoding="unicode")
await self._send_request(MessageType.SET_EQUIPMENT, req_body)

# pylint: disable=too-many-arguments,too-many-locals
async def set_equipment(
self,
pool_id: int,
equipment_id: int,
is_on: Union[int, bool],
is_on: int | bool,
is_countdown_timer: bool = False,
start_time_hours: int = 0,
start_time_minutes: int = 0,
Expand Down Expand Up @@ -593,7 +594,6 @@ async def set_filter_speed(self, pool_id: int, equipment_id: int, speed: int):
req_body = ET.tostring(body_element, xml_declaration=True, encoding="unicode")
await self._send_request(MessageType.SET_FILTER_SPEED, req_body)

# pylint: disable=too-many-arguments,too-many-locals
async def set_light_show(
self,
pool_id: int,
Expand Down
Loading

0 comments on commit 9c4b4b6

Please sign in to comment.