diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f183641e..19ae4303 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index df699c05..bb4b7b3f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..6bc962e4 --- /dev/null +++ b/CHANGELOG.md @@ -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`). diff --git a/OWNd/__init__.py b/OWNd/__init__.py index 946826ef..068077c6 100755 --- a/OWNd/__init__.py +++ b/OWNd/__init__.py @@ -9,7 +9,7 @@ OWNTimeoutError, ) -__version__ = "2.0.0-b10" +__version__ = "2.0.0b11" __all__ = [ "__version__", diff --git a/OWNd/message/__init__.py b/OWNd/message/__init__.py index 31f6d34c..ad5ed7db 100644 --- a/OWNd/message/__init__.py +++ b/OWNd/message/__init__.py @@ -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, diff --git a/OWNd/message/energy.py b/OWNd/message/energy.py index fd10fbef..003be18d 100644 --- a/OWNd/message/energy.py +++ b/OWNd/message/energy.py @@ -7,8 +7,6 @@ import re from typing import Any -from dateutil.relativedelta import relativedelta - from .base import ( OWNCommand, OWNEvent, @@ -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}##") @@ -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 diff --git a/OWNd/message/gateway.py b/OWNd/message/gateway.py index 64f3a247..5ed56321 100644 --- a/OWNd/message/gateway.py +++ b/OWNd/message/gateway.py @@ -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 diff --git a/README.md b/README.md index b4db40ec..08795fb2 100755 --- a/README.md +++ b/README.md @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 8239c410..a39384a9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" @@ -27,7 +76,6 @@ warn_unused_configs = true [[tool.mypy.overrides]] module = [ - "dateutil.*", "defusedxml.*", "serial_asyncio.*", ] @@ -50,3 +98,4 @@ exclude_lines = [ "raise NotImplementedError", "def __repr__", ] + diff --git a/scripts/verify_library_standards.py b/scripts/verify_library_standards.py index 5c71bd44..98fdccc1 100644 --- a/scripts/verify_library_standards.py +++ b/scripts/verify_library_standards.py @@ -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: diff --git a/setup.py b/setup.py index 9cbfdf4f..3e04ef3b 100644 --- a/setup.py +++ b/setup.py @@ -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[^"]+)"$', - 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", -) diff --git a/tests/test_metadata.py b/tests/test_metadata.py index ea0af6dc..32262ef8 100644 --- a/tests/test_metadata.py +++ b/tests/test_metadata.py @@ -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")) + diff --git a/tests/test_sync_documentation.py b/tests/test_sync_documentation.py index a2ac536b..fe8a4bf6 100644 --- a/tests/test_sync_documentation.py +++ b/tests/test_sync_documentation.py @@ -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,