Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,8 @@ jobs:
python -m pip install -e . ruff mypy types-python-dateutil types-pytz
- name: Run Ruff
run: ruff check OWNd tests setup.py scripts
- name: Run mypy
run: mypy OWNd
- name: Run mypy (library, and the consumer typing contract in tests/typing)
run: mypy OWNd tests/typing
- name: Enforce PyPI library standards & clean decoupling
run: python scripts/verify_library_standards.py

Expand Down
10 changes: 4 additions & 6 deletions OWNd/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,14 @@
import argparse
import asyncio
import logging
from typing import Any

from .message import OWNMessage

from .connection import OWNEventSession, OWNGateway


async def main(arguments: dict, connection: OWNEventSession) -> None:
async def main(arguments: dict[str, Any], connection: OWNEventSession) -> None:
"""Package entry point!"""

address = (
Expand All @@ -33,11 +34,8 @@ async def main(arguments: dict, connection: OWNEventSession) -> None:
if "serialNumber" in arguments and isinstance(arguments["serialNumber"], str)
else None
)
logger = (
arguments["logger"]
if "logger" in arguments and isinstance(arguments["logger"], logging.Logger)
else None
)
raw_logger = arguments.get("logger")
logger = raw_logger if isinstance(raw_logger, logging.Logger) else logging.getLogger("OWNd")

logger.info("Starting discovery of a supported gateway via SSDP")
gateway = await OWNGateway.build_from_discovery_info(
Expand Down
85 changes: 58 additions & 27 deletions OWNd/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
import secrets
import socket
import string
from collections.abc import Callable
from collections.abc import Callable, Mapping
from typing import Any
from urllib.parse import urlparse

from .discovery import find_gateways, get_gateway, get_port
Expand Down Expand Up @@ -73,7 +74,7 @@
RECONNECT_PAUSE_FATAL = 60


def _first_scalar(value, default=None):
def _first_scalar(value: Any, default: Any = None) -> Any:
"""Return a scalar from legacy tuple/list discovery values."""
while isinstance(value, (list, tuple)):
if not value:
Expand All @@ -83,7 +84,7 @@ def _first_scalar(value, default=None):


class OWNGateway:
def __init__(self, discovery_info: dict):
def __init__(self, discovery_info: Mapping[str, Any] | dict[str, Any]) -> None:
# Attributes potentially provided by user
self.address = discovery_info.get("address")
pw = discovery_info.get("password")
Expand Down Expand Up @@ -174,22 +175,26 @@ def log_id(self, value: str) -> None:
self._log_id = value

@classmethod
async def get_first_available_gateway(cls, password: str | None = None):
async def get_first_available_gateway(
cls, password: str | None = None
) -> OWNGateway | None:
local_gateways = await find_gateways()
if not local_gateways:
return None
local_gateways[0]["password"] = password
return cls(local_gateways[0])

@classmethod
async def find_from_address(cls, address: str):
async def find_from_address(cls, address: str | None) -> OWNGateway | None:
if address is not None:
gateway = await get_gateway(address)
return cls(gateway) if gateway is not None else None
return await cls.get_first_available_gateway()

@classmethod
async def build_from_discovery_info(cls, discovery_info: dict):
async def build_from_discovery_info(
cls, discovery_info: Mapping[str, Any] | dict[str, Any]
) -> OWNGateway | None:
# Work on our own copy: never mutate the caller's dict.
discovery_info = dict(discovery_info)
if (
Expand Down Expand Up @@ -359,7 +364,7 @@ def gateway(self) -> OWNGateway | None:
return self._gateway

@gateway.setter
def gateway(self, gateway: OWNGateway) -> None:
def gateway(self, gateway: OWNGateway | None) -> None:
self._gateway = gateway

@property
Expand All @@ -379,11 +384,11 @@ def connection_type(self, connection_type: str) -> None:
self._type = connection_type.lower()

@classmethod
async def test_gateway(cls, gateway: OWNGateway) -> dict:
async def test_gateway(cls, gateway: OWNGateway) -> dict[str, Any]:
connection = cls(gateway)
return await connection.test_connection()

async def test_connection(self) -> dict:
async def test_connection(self) -> dict[str, Any]:
assert self._gateway is not None
retry_count = 0
retry_timer = 1
Expand Down Expand Up @@ -450,7 +455,7 @@ async def test_connection(self) -> dict:
with contextlib.suppress(Exception):
await self.close()

async def connect(self):
async def connect(self) -> dict[str, Any] | None:
assert self._gateway is not None
self._logger.debug("%s Opening %s session.", self._log_id, self._type)

Expand Down Expand Up @@ -529,7 +534,7 @@ async def connect(self):
)
await asyncio.sleep(wait)

async def _reconnect(self) -> dict | None:
async def _reconnect(self) -> dict[str, Any] | None:
"""Tear down a (likely broken) connection and open a fresh one.

Connection state is intentionally NOT flipped to False here: a routine
Expand Down Expand Up @@ -573,7 +578,7 @@ async def _close_streams(self) -> None:
"%s %s session closed.", self._log_id, self._type.capitalize()
)

async def _negotiate(self) -> dict:
async def _negotiate(self) -> dict[str, Any]:
"""Negotiate one session within an absolute deadline."""
try:
async with asyncio.timeout(NEGOTIATION_TOTAL_TIMEOUT):
Expand All @@ -587,7 +592,7 @@ async def _negotiate(self) -> dict:
)
return {"Success": False, "Message": "negotiation_timeout"}

async def _negotiate_exchange(self) -> dict:
async def _negotiate_exchange(self) -> dict[str, Any]:
"""Perform the bounded frame exchange for session negotiation."""
# Programming-error guards (and mypy narrowing): negotiation is only
# ever entered right after a successful open_connection() on a
Expand Down Expand Up @@ -688,7 +693,10 @@ async def read_signaling() -> OWNSignaling:
self._stream_writer.write(b"*#*1##")
await self._stream_writer.drain()
resulting_message = await read_signaling()
if resulting_message.is_nonce():
if (
resulting_message.is_nonce()
and resulting_message.nonce is not None
):
server_random_string_ra = resulting_message.nonce
# Rb must be unpredictable: use a CSPRNG (not `random`).
key = "".join(secrets.choice(string.digits) for _ in range(56))
Expand Down Expand Up @@ -722,8 +730,12 @@ async def read_signaling() -> OWNSignaling:
)
# Constant-time comparison: never leak through
# timing how much of the digest matched.
if expected_response is not None and hmac.compare_digest(
hmac_response, expected_response
if (
expected_response is not None
and hmac_response is not None
and hmac.compare_digest(
hmac_response, expected_response
)
):
self._stream_writer.write(b"*#*1##")
await self._stream_writer.drain()
Expand Down Expand Up @@ -765,10 +777,14 @@ async def read_signaling() -> OWNSignaling:
resulting_message,
self._type,
)
elif resulting_message.is_nonce():
elif (
resulting_message.is_nonce()
and resulting_message.nonce is not None
):
self._logger.debug(
"%s Received nonce: `%s`", self._log_id, resulting_message
)
nonce = resulting_message.nonce
if self._gateway.password is not None:
if not self._gateway.password.isdecimal():
error = True
Expand All @@ -778,7 +794,7 @@ async def read_signaling() -> OWNSignaling:
self._log_id,
)
else:
hashed_password = f"*#{self._get_own_password(self._gateway.password, resulting_message.nonce)}##" # pylint: disable=line-too-long
hashed_password = f"*#{self._get_own_password(self._gateway.password, nonce)}##" # pylint: disable=line-too-long
self._logger.debug(
"%s Sending %s session password.",
self._log_id,
Expand Down Expand Up @@ -858,7 +874,9 @@ async def read_signaling() -> OWNSignaling:

return {"Success": not error, "Message": error_message}

def _get_own_password(self, password, nonce, test: bool = False):
def _get_own_password(
self, password: str | int, nonce: str, test: bool = False
) -> int:
# Retained for compatibility with the previously vendored implementation.
# Do not print password-derived intermediate values even in test mode.
del test
Expand Down Expand Up @@ -913,7 +931,7 @@ def _get_own_password(self, password, nonce, test: bool = False):

def _encode_hmac_password(
self, method: str, password: str, nonce_a: str, nonce_b: str
):
) -> str | None:
# SHA-1 here is mandated by the OpenWebNet protocol: the gateway
# selects the digest, the client cannot opt out. See nosec below.
if method == "sha1":
Expand Down Expand Up @@ -942,7 +960,7 @@ def _encode_hmac_password(

def _decode_hmac_response(
self, method: str, password: str, nonce_a: str, nonce_b: str
):
) -> str | None:
# SHA-1 here is mandated by the OpenWebNet protocol: the gateway
# selects the digest, the client cannot opt out. See nosec below.
if method == "sha1":
Expand Down Expand Up @@ -1003,7 +1021,7 @@ def __init__(
)
self._keepalive_task: asyncio.Task[None] | None = None

async def connect(self):
async def connect(self) -> dict[str, Any] | None:
await self._stop_keepalive()
result = await super().connect()
if result is not None and result.get("Success"):
Expand Down Expand Up @@ -1050,7 +1068,9 @@ async def _close_streams(self) -> None:
await super()._close_streams()

@classmethod
async def connect_to_gateway(cls, gateway: OWNGateway):
async def connect_to_gateway(
cls, gateway: OWNGateway
) -> dict[str, Any] | None:
connection = cls(gateway)
try:
return await connection.connect()
Expand Down Expand Up @@ -1167,7 +1187,9 @@ def __init__(
self._send_lock = asyncio.Lock()

@classmethod
async def send_to_gateway(cls, message: str, gateway: OWNGateway):
async def send_to_gateway(
cls, message: str | OWNMessage, gateway: OWNGateway
) -> list[OWNMessage | str] | bool | None:
connection = cls(gateway)
try:
await connection.connect()
Expand All @@ -1177,7 +1199,9 @@ async def send_to_gateway(cls, message: str, gateway: OWNGateway):
await connection.close()

@classmethod
async def connect_to_gateway(cls, gateway: OWNGateway):
async def connect_to_gateway(
cls, gateway: OWNGateway
) -> dict[str, Any] | None:
connection = cls(gateway)
try:
return await connection.connect()
Expand Down Expand Up @@ -1285,11 +1309,16 @@ async def _read_signaling_response(self) -> OWNSignaling:
return signaling

async def send(
self, message, is_status_request: bool = False
self, message: str | OWNMessage, is_status_request: bool = False
) -> list[OWNMessage | str] | bool | None:
"""Send the attached message on an existing 'command' connection,
actively reconnecting it if it had been reset.

``message`` is a raw frame (``"*1*1*11##"``) or any ``OWNMessage`` -
typically a command built by ``OWNLightingCommand.switch_on("11")``
or parsed by ``OWNCommand.parse()``; it goes on the wire as
``str(message)``.

Concurrency-safe: an internal lock serializes callers sharing this
session (e.g. ``run_keepalive`` alongside regular commands), so the
write/read-acknowledgement pairs can never interleave.
Expand All @@ -1302,7 +1331,7 @@ async def send(
return await self._locked_send(message, is_status_request)

async def _locked_send(
self, message, is_status_request: bool = False
self, message: str | OWNMessage, is_status_request: bool = False
) -> list[OWNMessage | str] | bool | None:
# One retry is enough for an immediate NACK or for a connection that
# was already unavailable. More importantly, never replay a command
Expand Down Expand Up @@ -1426,3 +1455,5 @@ async def _locked_send(
"%s Command session crashed.", self._log_id
)
return None

return None # pragma: no cover
Loading
Loading