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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ jobs:
- name: Install static-analysis dependencies
run: |
python -m pip install --upgrade pip
python -m pip install -e . ruff mypy types-python-dateutil types-pytz
python -m pip install -e . ruff mypy
- name: Run Ruff
run: ruff check OWNd tests setup.py scripts
- name: Run mypy (library, and the consumer typing contract in tests/typing)
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ jobs:
- name: Install dependencies
run: |
python -m pip install --upgrade pip
python -m pip install -e ".[test]" packaging ruff mypy types-python-dateutil types-pytz
python -m pip install -e ".[test]" packaging ruff mypy
- name: Lint and type check
run: |
ruff check OWNd tests setup.py
Expand Down
49 changes: 49 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# Changelog

All notable changes to `OWNd` are documented in this file.

## [2.0.0b11] - 2026-10-20

### Added
- **WHO 0 (`OWNScenarioEvent`)**: Decode `WHAT 40–46` programming and lock states (`#90`).
- **WHO 1 (`OWNLightingCommand`, `OWNLightingEvent`)**:
- Presets, step dimming, fixed timers, and variable timer builders (`#93`).
- `WHAT 19` anomaly / fault state modeling (`#115`).
- Public APL normalization (`normalize_where`) and DALI / RGB / HSV / XY / colour-temperature accessors (`#123`).
- **WHO 3 (`OWNLoadEvent`, `OWNLoadCommand`)**: Load control parser, command builders, typed measurement properties for dimensions 1–4 (`voltage`, `current`, `power`, `energy`), and gateway profile gating (`#104`, `#124`).
- **WHO 4 (`OWNHeatingEvent`, `OWNHeatingCommand`)**: Consolidated firmware-validated heating support including season frames (`WHAT 0` / `WHAT 1`), central unit status (`WHAT 22–24`, `30–31`), holiday/vacation program handling (`WHAT 115#P`, `13DDD#P`, `3101–3103`), and F453AV / F461 live-plant conformance (`#83`, `#97`, `#116`, `#119`, `#120`).
- **WHO 6 & WHO 8 (`OWNDoorEntryEvent`, `OWNDoorEntryCommand`)**: Door entry, lock, camera, and intercom call dialects (`#100`).
- **WHO 13 & WHO 1013 (`OWNGatewayEvent`, `OWNGatewayCommand`)**:
- Typed gateway clock, timezone, `kernel_version`, `distribution_version`, and diagnostic properties and builders (`#112`, closes `#111`).
- H4684 / LGRH4684 / MH201 gateway identification profiles and WHO 1013 device diagnostic frame support (`#118`).
- **WHO 14 (`OWNActuatorLockEvent`, `OWNActuatorLockCommand`)**: Actuator lock and unlock (`WHAT 0` / `WHAT 1`) event parser and command builders (`#130`).
- **WHO 16 (`OWNSoundEvent`, `OWNSoundCommand`)**: Sound system media transport commands, tuner frequency/station decoding, and L4561N source selection (`#95`, `#123`).
- **WHO 17 (`OWNSceneCommand`)**: Complete scene command factory surface (`#99`).
- **WHO 18 (`OWNEnergyEvent`, `OWNEnergyCommand`)**:
- Dimension `1200` automatic power-update stream lifecycle properties (`stream_duration`, `is_streaming_active`, `energy_type`), `stop_sending_instant_power`, and `request_active_power` / `get_instant_power` builders (`#92`, `#102`, `#107`).
- Stop&Go (`F16EB`) automatic reset breaker commands and status flag dimensions `212`, `250`, `251–263` (`#113`, `#122`).
- **WHO 22 (`OWNSoundDiffusionEvent`, `OWNSoundDiffusionCommand`)**: Advanced sound diffusion 2.0 events and commands (power-on, atomic off, matrix routing, paging, keypad, 100 kHz tuner frequency, presets, 8-character RDS, and `MH200NProfile` support) (`#103`, `#127`).
- **WHO 25 (`OWNScenarioPlusEvent`, `OWNScenarioPlusCommand`)**: Scenario-plus `WHAT 11–15` parser and command builders (`#91`, `#106`).
- **Virtual-device expose support (`#129`)**:
- Query and translation helpers on `OWNMessage` (`is_status_request`, `is_dimension_request`, `is_dimension_writing`, `is_dimension_reply`, `effective_what`) and per-WHO query properties (`is_brightness_query`, `is_shutter_status_query`, `is_temperature_query`, `is_clock_query`, `is_dimension_query`, `is_auto_update_command`).
- Reply and write builders: `OWNLightingCommand.report_brightness`, `OWNAutomationCommand.report_shutter_level`, `OWNHeatingCommand.report_temperature` / `write_temperature` / `encode_temperature`, `OWNGatewayCommand.set_*` / `report_*` for time, date and datetime, and `OWNEnergyCommand.report_*` for dimensions `113`, `51`–`54` and `1200`.
- `OWNHeatingEvent.probe_temperature` for 3-digit external probes.
- `GatewayProfile` flags `supports_dimension_response_injection`, `supports_who4_command_injection` and `supports_who18_response_injection` (`bool | None`; `None` means unverified, only `F461Profile` sets them from live captures).
- **Typed exceptions (`#132`)**: `OWNError`, `OWNConnectionError`, `OWNTimeoutError`, `OWNAuthError`, `OWNNackError` and `OWNProtocolError` in `OWNd.exceptions`, re-exported from `OWNd` and `OWNd.connection`; `session.last_error`, opt-in `raise_on_error` on the connect, test and send APIs, and `on_auth_error` / `raise_on_auth_error` on `OWNEventSession`.
- **Firmware Oracle & Documentation Conformance**:
- Firmware oracle conformance suite across emulated gateways (`#84`, `#117`, `#126`).
- Unified documentation synchronization sentinel (`scripts/sync_documentation.py`) verifying WHO catalog, gateway profile tables, and coverage badges (`#101`, `#105`, `#108`).

### Changed
- **Packaging & Dependencies**:
- Normalize version string to canonical PEP 440 (`2.0.0b11`).
- Migrate package metadata to PEP 621 `[project]` and PEP 639 `license = "Apache-2.0"` in `pyproject.toml`.
- Drop unused `pytz` and `python-dateutil` runtime dependencies in favor of Python 3.11+ standard library `datetime` and `zoneinfo` (with `tzdata` included in `[test]` on Windows).
- **Input Validation & Connection Reliability**:
- Harden bounds and infeasible input validation across WHO subsystems and connection sessions (`#121`, `#125`, `#127`, `#128`).
- Only retry idempotent status queries in `OWNCommandSession` after mid-send transport loss (`#131`).
- `OWNCommandSession.send()` reconnects at most once, with a 2 s cooldown, instead of running the full backoff while holding the send lock; routine send logs move from `INFO` to `DEBUG` (`#132`). Callers that need the full 5-attempt retry, such as after closing an idle socket on a single-slot MH201, call `connect()` first (`#133`).
- **API**:
- `set_datetime_to_now`, `set_date_to_today` and `set_time_to_now` accept a `datetime.tzinfo` as well as an IANA name; transport `send` accepts `str | OWNMessage`; `OWNd.connection` uses static re-exports (`#132`).
- `OWNd.connection.gateway` no longer resolves `find_gateways` / `get_gateway` / `get_port` through the package at call time, so tests must patch `OWNd.connection.gateway.*` (`#133`).
- WHO 13 builders raise `ValueError` for UTC offsets that are not whole hours (for example `Asia/Kolkata`), which the `0HH` / `1HH` field cannot carry, instead of truncating them (`#129`).
2 changes: 1 addition & 1 deletion OWNd/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
OWNTimeoutError,
)

__version__ = "2.0.0-b10"
__version__ = "2.0.0b11"

__all__ = [
"__version__",
Expand Down
2 changes: 0 additions & 2 deletions OWNd/message/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,6 @@
from typing import Any
from zoneinfo import ZoneInfo

from dateutil.relativedelta import relativedelta

from .actuator_lock import (
ACTUATOR_LOCK_STATE_NAMES,
ACTUATOR_STATE_LOCKED,
Expand Down
17 changes: 12 additions & 5 deletions OWNd/message/energy.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,6 @@
import re
from typing import Any

from dateutil.relativedelta import relativedelta

from .base import (
OWNCommand,
OWNEvent,
Expand Down Expand Up @@ -394,7 +392,10 @@ def get_hourly_consumption(
) -> OWNEnergyCommand | None:
where = _normalize_actuator_where(where)
today = datetime.date.today()
one_year_ago = today - relativedelta(years=1)
one_year_ago = today.replace(
year=today.year - 1,
day=28 if (today.month, today.day) == (2, 29) else today.day,
)
if date < one_year_ago:
return None
message = cls(f"*#18*{where}*511#{date.month}#{date.day}##")
Expand All @@ -418,8 +419,14 @@ def get_daily_consumption(
) -> OWNEnergyCommand | None:
where = _normalize_actuator_where(where)
today = datetime.date.today()
one_year_ago = today - relativedelta(years=1)
two_year_ago = today - relativedelta(years=2)
one_year_ago = today.replace(
year=today.year - 1,
day=28 if (today.month, today.day) == (2, 29) else today.day,
)
two_year_ago = today.replace(
year=today.year - 2,
day=28 if (today.month, today.day) == (2, 29) else today.day,
)
target = datetime.date(year=year, month=month, day=1)
if target > today:
return None
Expand Down
2 changes: 0 additions & 2 deletions OWNd/message/gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@
import re
from zoneinfo import ZoneInfo

from dateutil.relativedelta import relativedelta

from .base import OWNCommand, OWNEvent, register_command_parser, register_event_parser


Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -291,7 +291,7 @@ Clone the repository and install development dependencies:
```bash
git clone https://github.com/OpenWebNet-HA/OWNd.git
cd OWNd
pip install -e ".[test,serial]" ruff mypy types-python-dateutil types-pytz
pip install -e ".[test,serial]" ruff mypy
```

### Running Tests
Expand Down
53 changes: 51 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,56 @@
[build-system]
requires = ["setuptools>=84.0.0", "wheel"]
requires = ["setuptools>=77.0.0", "wheel"]
build-backend = "setuptools.build_meta"

[project]
name = "OWNd"
dynamic = ["version"]
description = "Python interface for the OpenWebNet protocol"
readme = "README.md"
license = "Apache-2.0"
license-files = ["LICENSE"]
authors = [
{ name = "anotherjulien", email = "yetanotherjulien@gmail.com" },
]
requires-python = ">=3.11"
classifiers = [
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Operating System :: OS Independent",
]
dependencies = [
"aiohttp",
"defusedxml",
]

[project.optional-dependencies]
serial = ["pyserial-asyncio>=0.6"]
test = [
"pytest<10",
"pytest-asyncio>=0.23",
"pytest-cov>=4.0",
"pyyaml>=6.0",
"jsonschema>=4.0",
"tzdata; sys_platform == 'win32'",
]

[project.urls]
Homepage = "https://github.com/OpenWebNet-HA/OWNd"
Repository = "https://github.com/OpenWebNet-HA/OWNd"
Issues = "https://github.com/OpenWebNet-HA/OWNd/issues"

[tool.setuptools.dynamic]
version = { attr = "OWNd.__version__" }

[tool.setuptools.packages.find]
include = ["OWNd*"]

[tool.setuptools.package-data]
OWNd = ["py.typed"]

[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-p no:homeassistant"
Expand All @@ -27,7 +76,6 @@ warn_unused_configs = true

[[tool.mypy.overrides]]
module = [
"dateutil.*",
"defusedxml.*",
"serial_asyncio.*",
]
Expand All @@ -50,3 +98,4 @@ exclude_lines = [
"raise NotImplementedError",
"def __repr__",
]

18 changes: 12 additions & 6 deletions scripts/verify_library_standards.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,13 +77,19 @@ def check_pep561_typing(validator: StandardsValidator) -> None:
else:
validator.ok("PEP 561 typing marker verified: OWNd/py.typed present.")

pyproject_file = ROOT_DIR / "pyproject.toml"
setup_file = ROOT_DIR / "setup.py"
if setup_file.is_file():
text = setup_file.read_text(encoding="utf-8")
if "py.typed" not in text:
validator.error("RULE_PEP561", setup_file, 1, "setup.py must declare package_data for py.typed")
else:
validator.ok("PEP 561 package data verified in setup.py.")
texts = [
p.read_text(encoding="utf-8")
for p in (pyproject_file, setup_file)
if p.is_file()
]
if texts and not any("py.typed" in t for t in texts):
target = pyproject_file if pyproject_file.is_file() else setup_file
validator.error("RULE_PEP561", target, 1, "pyproject.toml or setup.py must declare package_data for py.typed")
elif texts:
validator.ok("PEP 561 package data verified in packaging metadata.")



def check_version_sync(validator: StandardsValidator) -> None:
Expand Down
52 changes: 3 additions & 49 deletions setup.py
Original file line number Diff line number Diff line change
@@ -1,54 +1,8 @@
#!/usr/bin/env python3

""" PyPi setup file for OWNd. """

import re
from pathlib import Path
"""Compatibility setup.py shim; package metadata is defined in pyproject.toml."""

import setuptools

version_match = re.search(
r'^__version__ = "(?P<version>[^"]+)"$',
Path("OWNd/__init__.py").read_text(encoding="utf-8"),
re.MULTILINE,
)
if version_match is None:
raise RuntimeError("Unable to find OWNd package version")

with open("README.md", encoding="utf-8", mode="r") as fh:
long_description = fh.read()
if __name__ == "__main__":
setuptools.setup()

setuptools.setup(
name="OWNd",
version=version_match.group("version"),
author="anotherjulien",
url="https://github.com/OpenWebNet-HA/OWNd",
author_email="yetanotherjulien@gmail.com",
description="Python interface for the OpenWebNet protocol",
license="Apache-2.0",
long_description=long_description,
long_description_content_type="text/markdown",
packages=setuptools.find_packages(),
package_data={"OWNd": ["py.typed"]},
include_package_data=True,
classifiers=[
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Operating System :: OS Independent",
],
install_requires=["aiohttp", "defusedxml", "pytz", "python-dateutil"],
extras_require={
"serial": ["pyserial-asyncio>=0.6"],
"test": [
"pytest<10",
"pytest-asyncio>=0.23",
"pytest-cov>=4.0",
"pyyaml>=6.0",
"jsonschema>=4.0",
],
},
python_requires=">=3.11",
)
7 changes: 5 additions & 2 deletions tests/test_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,20 @@
r"^(0|[1-9]\d*)\."
r"(0|[1-9]\d*)\."
r"(0|[1-9]\d*)"
r"(?:-(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)"
r"(?:(?:a|b|rc)(?:0|[1-9]\d*)"
r"|-(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)"
r"(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*)?"
r"(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$"
)


def test_runtime_version_is_semver() -> None:
"""Require the public version to follow Semantic Versioning."""
"""Require the public version to follow canonical PEP 440 / Semantic Versioning."""
assert SEMVER_PATTERN.fullmatch(OWNd.__version__)
assert str(Version(OWNd.__version__)) == OWNd.__version__


def test_runtime_version_matches_installed_package() -> None:
"""Keep the public runtime version aligned with package metadata."""
assert Version(OWNd.__version__) == Version(version("OWNd"))

2 changes: 1 addition & 1 deletion tests/test_sync_documentation.py
Original file line number Diff line number Diff line change
Expand Up @@ -520,7 +520,7 @@ def test_sync_documentation_missing_coverage_graceful(tmp_path: Path) -> None:
{COVERAGE_END_MARKER}
"""
readme_file = tmp_path / "README.md"
readme_file.write_text(readme_content, encoding="utf-8")
readme_file.write_text(readme_content, encoding="utf-8", newline="\n")

in_sync, diff, msgs = sync_documentation(
readme_path=readme_file,
Expand Down
Loading