omni-pca/pyproject.toml
Ryan Malloy 2cc28b0e50 program_engine: Phases 1+2 — Clock abstraction + TIMED execution
First half of the autonomous program-execution engine. Two phases land
together because Phase 1 was pure scaffolding (Clock + classifier)
and made little sense in isolation.

Phase 1 — engine foundation:

* Clock protocol with RealClock (wall time + asyncio.sleep) and
  FakeClock (manual advance, no real waiting; sleepers wake in
  chronological order on advance_to).
* classify(programs) splits a Program tuple into timed / event /
  yearly / clausal-head buckets, dropping FREE / REMARK / unknown
  records and the AND/OR/THEN clausal continuations (those are
  reached by walking forward from each WHEN/AT/EVERY head, not by
  classification).
* ProgramEngine class with start / stop lifecycle (idempotent +
  context-manager), per-program asyncio task list, _EngineMetrics
  counters.

Phase 2 — TIMED programs actually run:

* _next_absolute_fire(now, program) computes the next datetime at
  which a TIMED program with TimeKind.ABSOLUTE should fire, given
  its hour/minute/days mask. Walks forward up to 8 days; returns
  None for empty Days mask (program is effectively disabled).
* Each TIMED program gets its own asyncio task running
  sleep-until-next-fire / fire / loop. Firing dispatches the
  4-byte Command wire payload (cmd / par / pr2) through
  MockPanel._handle_command — same code path the v2 Command opcode
  uses, so a TIMED program turning on a unit produces identical
  state to a client sending the equivalent Command.
* astral added as an [engine] optional dependency, pinned to 2.2
  for HA compat (HA itself pins astral==2.2). Library wired up but
  not yet consumed — sunrise/sunset support lands in Phase 3.

Tests (28 new):

* RealClock and FakeClock behaviour incl. chronological wake order.
* classify against each ProgramType, unknown values, empty input.
* Engine lifecycle (idempotent start/stop, context manager,
  malformed-record tolerance).
* End-to-end: TIMED UNIT_ON program fires at the right Monday 06:00,
  loops correctly across weeks, never fires outside its Days mask,
  ignores programs with empty Days mask.

Full suite: 527 passed, 1 skipped (up from 499).
2026-05-14 01:25:14 -06:00

91 lines
2.6 KiB
TOML

[project]
name = "omni-pca"
version = "2026.5.11"
description = "Async Python client for HAI/Leviton Omni-Link II home automation panels (Omni Pro II, Omni IIe, Omni LTe, Lumina)."
readme = "README.md"
license = { text = "MIT" }
authors = [{ name = "Ryan Malloy", email = "ryan@supported.systems" }]
requires-python = ">=3.14.2"
keywords = ["hai", "leviton", "omni", "home-automation", "omni-link", "home-assistant"]
classifiers = [
"Development Status :: 3 - Alpha",
"Framework :: AsyncIO",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Topic :: Home Automation",
]
dependencies = [
"cryptography>=44.0.0",
]
[project.optional-dependencies]
cli = ["rich>=13.9.0", "typer>=0.15.0"]
# astral provides sunrise/sunset computation for the program engine's
# AT_SUNRISE / AT_SUNSET TIMED-program sentinels. Pure Python, MIT.
# Pinned to 2.2 for compatibility with Home Assistant (which pins it
# to exactly that version) so the `ha` group still resolves.
engine = ["astral>=2.2,<3"]
[project.scripts]
omni-pca = "omni_pca.__main__:main"
[project.urls]
Repository = "https://git.supported.systems/warehack.ing/omni-pca"
[build-system]
requires = ["uv_build>=0.11.8,<0.12.0"]
build-backend = "uv_build"
[dependency-groups]
dev = [
"pytest>=8.3.0",
"pytest-asyncio>=0.25.0",
"pytest-cov>=6.0.0",
"ruff>=0.13.0",
"mypy>=1.18.0",
]
# Optional group for testing the HA custom_component end-to-end. Pulls in
# the full Home Assistant test harness; requires Python 3.14.2+. The repo's
# .python-version pins to 3.14 for development; install with:
# uv sync --group ha
ha = [
"pytest-homeassistant-custom-component>=0.13.330",
]
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
addopts = ["-ra", "--strict-markers", "--strict-config"]
markers = [
"slow: tests that take more than ~1s",
"live: tests that require a real panel (skipped by default)",
]
[tool.ruff]
line-length = 100
target-version = "py312"
src = ["src", "tests", "custom_components"]
[tool.ruff.lint]
select = [
"E", "F", "W",
"I", "N", "UP", "B", "A", "C4", "PT", "SIM", "RUF",
]
ignore = ["E501"]
[tool.ruff.lint.per-file-ignores]
"tests/*" = ["N802", "N803", "PT011"]
[tool.mypy]
python_version = "3.12"
strict = true
warn_unused_configs = true
warn_redundant_casts = true
warn_unused_ignores = true
disallow_untyped_defs = true
no_implicit_reexport = true