diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 265db3b..a34d7b8 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -6,7 +6,7 @@ body: attributes: value: | Thanks for filing! The most useful payload for diagnosing problems is - `hypercolor.run_diagnostics` โ€” it returns daemon health and coordinator state + `hypercolor.run_diagnostics` because it returns daemon health and coordinator state with hosts and keys redacted. - type: textarea diff --git a/.github/actions/setup-hypercolor/action.yml b/.github/actions/setup-hypercolor/action.yml new file mode 100644 index 0000000..e700e08 --- /dev/null +++ b/.github/actions/setup-hypercolor/action.yml @@ -0,0 +1,58 @@ +name: Set up Hypercolor development environment +description: Check out the pinned client and install uv with Python + +inputs: + hypercolor-ref: + description: Full Hypercolor client commit SHA + required: false + default: 978096e614695777d3cd13ea0b184877e46afd84 + hypercolor-token: + description: Optional token for the Hypercolor repository + required: false + default: "" + python-version: + description: Python version to install + required: false + default: "3.14" + +runs: + using: composite + steps: + - name: Set up uv + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + with: + enable-cache: true + cache-dependency-glob: uv.lock + + - name: Set up just + shell: bash + run: uv tool install rust-just==1.58.0 + + - name: Set up Python + shell: bash + env: + PYTHON_VERSION: ${{ inputs.python-version }} + run: uv python install "${PYTHON_VERSION}" + + - name: Check out Hypercolor client + shell: bash + env: + HYPERCOLOR_REF: ${{ inputs.hypercolor-ref }} + HYPERCOLOR_TOKEN: ${{ inputs.hypercolor-token }} + run: | + set -euo pipefail + if [[ ! "${HYPERCOLOR_REF}" =~ ^[0-9a-fA-F]{40}$ ]]; then + echo "HYPERCOLOR_REF must be a full commit SHA" >&2 + exit 1 + fi + git init -q ../hypercolor + git -C ../hypercolor remote add origin https://github.com/hyperb1iss/hypercolor.git + if [[ -n "${HYPERCOLOR_TOKEN}" ]]; then + auth="$(printf 'x-access-token:%s' "${HYPERCOLOR_TOKEN}" | base64 | tr -d '\n')" + git -C ../hypercolor \ + -c "http.extraheader=AUTHORIZATION: basic ${auth}" \ + fetch --depth 1 origin "${HYPERCOLOR_REF}" + else + git -C ../hypercolor fetch --depth 1 origin "${HYPERCOLOR_REF}" + fi + git -C ../hypercolor checkout --detach FETCH_HEAD diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index d6d5877..109c948 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -2,7 +2,7 @@ Thanks for the PR! Please: - Run `just verify` locally before pushing. - Use a conventional commit subject (`feat(hass):`, `fix(hass):`, etc). -- Keep the PR focused โ€” one feature, fix, or refactor at a time. +- Keep the PR focused: one feature, fix, or refactor at a time. --> ## Summary diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 418dc39..abbd339 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,9 @@ on: branches: [main] tags: ["v*.*.*"] +permissions: + contents: read + concurrency: group: ci-${{ github.ref }} cancel-in-progress: >- @@ -14,113 +17,134 @@ concurrency: github.ref != 'refs/heads/main' }} jobs: - verify: - name: Lint, typecheck, test, build + workflow-quality: + name: Workflow syntax and shell runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Check out Hypercolor client + - name: Install actionlint env: - HYPERCOLOR_REF: ${{ vars.HYPERCOLOR_REF || '978096e614695777d3cd13ea0b184877e46afd84' }} - HYPERCOLOR_TOKEN: ${{ secrets.HYPERCOLOR_TOKEN }} + ACTIONLINT_CHECKSUM: 8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8 + ACTIONLINT_VERSION: 1.7.12 + run: | + set -euo pipefail + archive="${RUNNER_TEMP}/actionlint.tar.gz" + curl --fail --location --silent --show-error \ + "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" \ + --output "${archive}" + printf '%s %s\n' "${ACTIONLINT_CHECKSUM}" "${archive}" | sha256sum --check + tar -xzf "${archive}" -C "${RUNNER_TEMP}" actionlint + + - name: Validate workflows run: | set -euo pipefail - if [[ ! "${HYPERCOLOR_REF}" =~ ^[0-9a-fA-F]{40}$ ]]; then - echo "HYPERCOLOR_REF must be a full commit SHA" >&2 - exit 1 - fi - if [ -n "${HYPERCOLOR_TOKEN}" ]; then - remote_url="https://x-access-token:${HYPERCOLOR_TOKEN}@github.com/hyperb1iss/hypercolor.git" - else - remote_url="https://github.com/hyperb1iss/hypercolor" - fi - git init -q ../hypercolor - git -C ../hypercolor remote add origin "${remote_url}" - git -C ../hypercolor fetch --depth 1 origin "${HYPERCOLOR_REF}" - git -C ../hypercolor checkout --detach FETCH_HEAD + shellcheck --version + "${RUNNER_TEMP}/actionlint" \ + -color \ + -shellcheck "$(command -v shellcheck)" + + verify: + name: Lint, typecheck, test, build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: ./.github/actions/setup-hypercolor + with: + hypercolor-ref: ${{ vars.HYPERCOLOR_REF || '978096e614695777d3cd13ea0b184877e46afd84' }} + hypercolor-token: ${{ secrets.HYPERCOLOR_TOKEN }} + + - name: Sync dependencies + run: uv sync --all-groups --locked + + - name: Verify + run: just verify + + - name: Upload build artifacts + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: dist + path: dist/ + retention-days: 7 + + pypi-compatibility: + name: Published Hypercolor compatibility + runs-on: ubuntu-latest + env: + UV_NO_SOURCES: "1" + UV_PROJECT_ENVIRONMENT: .venv-pypi + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up uv - uses: astral-sh/setup-uv@v7 + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 with: enable-cache: true - cache-dependency-glob: "uv.lock" + cache-dependency-glob: uv.lock + + - name: Set up just + run: uv tool install rust-just==1.58.0 - name: Set up Python run: uv python install 3.14 - - name: Sync (locked) - run: uv sync --all-groups --locked + - name: Sync from published packages + run: uv sync --all-groups - - name: Lint + - name: Prove Hypercolor came from the isolated environment run: | - uv run ruff check . - uv run ruff format --check . - - - name: Typecheck - run: uv run ty check - - - name: Test - run: uv run pytest + uv run --no-sync python - <<'PY' + import sys + from pathlib import Path - - name: Metadata - run: uv run python scripts/check_integration_metadata.py + import hypercolor - - name: Build - run: uv build + package = Path(hypercolor.__file__).resolve() + environment = Path(sys.prefix).resolve() + if not package.is_relative_to(environment): + raise SystemExit(f"Hypercolor loaded outside {environment}: {package}") + print(f"Hypercolor loaded from {package}") + PY - - name: Upload build artifacts - uses: actions/upload-artifact@v4 - with: - name: dist - path: dist/ - retention-days: 7 + - name: Verify against published Hypercolor + run: just verify hassfest: name: Home Assistant hassfest runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 - - uses: home-assistant/actions/hassfest@master + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: home-assistant/actions/hassfest@a7c616ce81ccda50150bf1595786c71b1883fabb hacs: name: HACS validation runs-on: ubuntu-latest - # HACS validation walks the public GitHub API, so it can only run while - # the repo is public. Flip the repo visibility and this job lights up. if: ${{ github.event.repository.visibility == 'public' }} steps: - - uses: actions/checkout@v5 - - uses: hacs/action@main + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: hacs/action@1ebf01c408f29afcb6406bd431bc98fd8cbb15aa with: category: integration - # Temporary until hypercolor is listed in home-assistant/brands - # (icon/logo submission upstream). Drop this line once merged. ignore: brands release: name: Publish GitHub release if: >- ${{ !cancelled() && startsWith(github.ref, 'refs/tags/v') && + needs.workflow-quality.result == 'success' && needs.verify.result == 'success' && + needs.pypi-compatibility.result == 'success' && needs.hassfest.result == 'success' && (needs.hacs.result == 'success' || needs.hacs.result == 'skipped') }} - needs: [verify, hassfest, hacs] - runs-on: ubuntu-latest + needs: [workflow-quality, verify, pypi-compatibility, hassfest, hacs] + uses: hyperb1iss/shared-workflows/.github/workflows/github-release.yml@9edda2222785fe0351a26a07bf26a3385874d288 + with: + attach-artifacts: true + artifact-pattern: dist + release-notes-model: claude-opus-5 + release-notes-provider: anthropic permissions: + actions: read contents: write - steps: - - uses: actions/checkout@v5 - - - name: Download build artifacts - uses: actions/download-artifact@v4 - with: - name: dist - path: dist - - - name: Create GitHub release - uses: softprops/action-gh-release@v2 - with: - files: ./dist/* - generate_release_notes: true - fail_on_unmatched_files: true + secrets: inherit diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9b24d61..9b8743b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,7 +11,7 @@ on: description: "Version bump type (if version is not specified)" required: false type: choice - default: "patch" + default: patch options: - patch - minor @@ -26,7 +26,7 @@ permissions: contents: read concurrency: - group: release + group: cut-release cancel-in-progress: false jobs: @@ -34,22 +34,22 @@ jobs: name: Home Assistant hassfest runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 - - uses: home-assistant/actions/hassfest@master + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: home-assistant/actions/hassfest@a7c616ce81ccda50150bf1595786c71b1883fabb hacs: name: HACS validation - if: ${{ github.event.repository.visibility == 'public' }} runs-on: ubuntu-latest + if: ${{ github.event.repository.visibility == 'public' }} steps: - - uses: actions/checkout@v5 - - uses: hacs/action@main + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: hacs/action@1ebf01c408f29afcb6406bd431bc98fd8cbb15aa with: category: integration ignore: brands release: - name: Cut Release + name: Cut release if: >- ${{ !cancelled() && needs.hassfest.result == 'success' && (needs.hacs.result == 'success' || needs.hacs.result == 'skipped') }} @@ -61,111 +61,32 @@ jobs: timeout-minutes: 30 steps: - name: Check out main - uses: actions/checkout@v5 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: main fetch-depth: 0 - - name: Check out Hypercolor client - env: - HYPERCOLOR_REF: ${{ vars.HYPERCOLOR_REF || '978096e614695777d3cd13ea0b184877e46afd84' }} - HYPERCOLOR_TOKEN: ${{ secrets.HYPERCOLOR_TOKEN }} - run: | - set -euo pipefail - if [[ ! "${HYPERCOLOR_REF}" =~ ^[0-9a-fA-F]{40}$ ]]; then - echo "HYPERCOLOR_REF must be a full commit SHA" >&2 - exit 1 - fi - if [ -n "${HYPERCOLOR_TOKEN}" ]; then - remote_url="https://x-access-token:${HYPERCOLOR_TOKEN}@github.com/hyperb1iss/hypercolor.git" - else - remote_url="https://github.com/hyperb1iss/hypercolor" - fi - git init -q ../hypercolor - git -C ../hypercolor remote add origin "${remote_url}" - git -C ../hypercolor fetch --depth 1 origin "${HYPERCOLOR_REF}" - git -C ../hypercolor checkout --detach FETCH_HEAD - - - name: Set up uv - uses: astral-sh/setup-uv@v7 - - - name: Set up Python - run: uv python install 3.14 + - uses: ./.github/actions/setup-hypercolor + with: + hypercolor-ref: ${{ vars.HYPERCOLOR_REF || '978096e614695777d3cd13ea0b184877e46afd84' }} + hypercolor-token: ${{ secrets.HYPERCOLOR_TOKEN }} - name: Configure git run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" - - name: Determine version + - name: Plan release id: version env: GH_TOKEN: ${{ github.token }} INPUT_BUMP: ${{ inputs.bump }} INPUT_VERSION: ${{ inputs.version }} - run: | - set -euo pipefail - project_version="$(uv version --short)" - manifest_version="$(jq -r '.version' custom_components/hypercolor/manifest.json)" - if [[ "${project_version}" != "${manifest_version}" ]]; then - echo "Project version ${project_version} does not match manifest ${manifest_version}" >&2 - exit 1 - fi - - latest_tag="$( - git tag --list 'v[0-9]*.[0-9]*.[0-9]*' --sort=-v:refname | - grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | - head -n1 || true - )" - if [[ -n "${INPUT_VERSION}" ]]; then - version="${INPUT_VERSION#v}" - elif [[ -z "${latest_tag}" ]]; then - version="${project_version}" - else - current="${latest_tag#v}" - IFS='.' read -r major minor patch <<< "${current}" - case "${INPUT_BUMP}" in - major) version="$((major + 1)).0.0" ;; - minor) version="${major}.$((minor + 1)).0" ;; - patch) version="${major}.${minor}.$((patch + 1))" ;; - esac - fi - - if [[ ! "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "Invalid version: ${version}; expected X.Y.Z" >&2 - exit 1 - fi - - tag="v${version}" - tag_exists=false - if git rev-parse -q --verify "refs/tags/${tag}" >/dev/null; then - tag_commit="$(git rev-list -n1 "${tag}")" - head_commit="$(git rev-parse HEAD)" - if [[ "${tag_commit}" != "${head_commit}" || "${version}" != "${project_version}" ]]; then - echo "Tag ${tag} exists but does not match the current release state" >&2 - exit 1 - fi - if gh release view "${tag}" >/dev/null 2>&1; then - echo "GitHub release ${tag} already exists" >&2 - exit 1 - fi - tag_exists=true - fi - - if [[ -n "${latest_tag}" && "${tag_exists}" == "false" ]]; then - highest="$(printf '%s\n%s\n' "${latest_tag#v}" "${version}" | sort -V | tail -n1)" - if [[ "${highest}" != "${version}" || "${latest_tag#v}" == "${version}" ]]; then - echo "Version ${version} is not above the latest tag ${latest_tag}" >&2 - exit 1 - fi - fi - - { - echo "current=${project_version}" - echo "tag_exists=${tag_exists}" - echo "version=${version}" - echo "tag=${tag}" - } >> "${GITHUB_OUTPUT}" + run: >- + uv run --no-project --python 3.14 python scripts/release.py + --version "${INPUT_VERSION}" + --bump "${INPUT_BUMP}" + --github-output "${GITHUB_OUTPUT}" - name: Stamp release version env: @@ -186,22 +107,8 @@ jobs: - name: Sync dependencies run: uv sync --all-groups --locked - - name: Lint - run: | - uv run ruff check . - uv run ruff format --check . - - - name: Typecheck - run: uv run ty check - - - name: Test - run: uv run pytest - - - name: Validate metadata - run: uv run python scripts/check_integration_metadata.py - - - name: Build - run: uv build + - name: Verify + run: just verify - name: Validate release diff run: | @@ -246,10 +153,7 @@ jobs: if: inputs.dry_run == false && steps.version.outputs.tag_exists != 'true' env: TAG: ${{ steps.version.outputs.tag }} - run: | - set -euo pipefail - git push origin HEAD:main - git push origin "${TAG}" + run: git push --atomic origin HEAD:main "refs/tags/${TAG}" - name: Dispatch and monitor build and publish id: publish @@ -263,8 +167,7 @@ jobs: before_id="$( gh run list --workflow ci.yml --event workflow_dispatch \ --branch "${TAG}" --commit "${candidate_sha}" --limit 1 \ - --json databaseId \ - --jq '.[0].databaseId // empty' + --json databaseId --jq '.[0].databaseId // empty' )" gh workflow run ci.yml --ref "${TAG}" @@ -273,8 +176,7 @@ jobs: candidate_id="$( gh run list --workflow ci.yml --event workflow_dispatch \ --branch "${TAG}" --commit "${candidate_sha}" --limit 1 \ - --json databaseId \ - --jq '.[0].databaseId // empty' + --json databaseId --jq '.[0].databaseId // empty' )" if [[ -n "${candidate_id}" && "${candidate_id}" != "${before_id}" ]]; then run_id="${candidate_id}" @@ -304,12 +206,11 @@ jobs: echo "- Dry run: ${DRY_RUN}" if [[ "${DRY_RUN}" == "true" ]]; then echo "- Build and validation passed; nothing was pushed" + elif [[ "${TAG_EXISTS}" == "true" ]]; then + echo "- Existing release tag reused after an interrupted publish" + echo "- Build and publish completed: ${PUBLISH_URL}" else - if [[ "${TAG_EXISTS}" == "true" ]]; then - echo "- Existing release tag reused after an interrupted publish" - else - echo "- Release commit and tag pushed" - fi + echo "- Release commit and tag pushed atomically" echo "- Build and publish completed: ${PUBLISH_URL}" fi } >> "${GITHUB_STEP_SUMMARY}" diff --git a/README.md b/README.md index 5a71e8c..2e16544 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ built directly on this integration's catalog, live controls, and effect cover ar ## ๐Ÿ“ก Requirements - Home Assistant **2026.4.4** or newer -- Python **3.14.2+** (HA's runtime floor for this release) +- Python **3.14.2 through 3.14.x** (HA's runtime range for this release) - A reachable Hypercolor daemon (default port `9420`) - Optional: an API key, if your daemon has auth turned on @@ -122,7 +122,7 @@ the device tree reads naturally. | Entity | Type | Purpose | | --- | --- | --- | -| `light.hypercolor` | light | master power, brightness, effect picker | +| `light.hypercolor` | light | pause or resume output, set brightness, and pick effects | | `binary_sensor.hypercolor_connected` | binary_sensor | live connectivity to the daemon | | `sensor.hypercolor_active_effect` | sensor | display name of the running effect | | `select.hypercolor_scene` | select | activate a scene | @@ -134,16 +134,20 @@ the device tree reads naturally. | `button.hypercolor_discover_devices` | button | re-run device discovery | | `number.hypercolor_brightness` / `speed` / `hue_shift` / `intensity` | number | live patches into the running effect | +Turning the master light off pauses output without discarding the active effect, preset, +or controls. Turning it back on resumes that exact state. The Stop button is the separate, +destructive action that clears the active effect. + ### Optional channels Toggle these in the integration's options panel: -- ๐ŸŒŠ **Audio entities** (`channels.audio`) โ€” adds `binary_sensor.hypercolor_audio_beat`, +- ๐ŸŒŠ **Audio entities** (`channels.audio`) adds `binary_sensor.hypercolor_audio_beat`, `binary_sensor.hypercolor_audio_reactive_active`, `sensor.hypercolor_audio_energy`, `select.hypercolor_audio_device`, and `switch.hypercolor_audio_reactive`. - ๐Ÿงช **Metrics entities** (`channels.metrics`): adds `sensor.hypercolor_fps` and `sensor.hypercolor_render_time`. -- ๐Ÿฆ‹ **Per-device entities** (`per_device_entities`) โ€” opt specific device ids in to get +- ๐Ÿฆ‹ **Per-device entities** (`per_device_entities`) lets you opt specific device ids in to get their own light, identify button, and enabled switch. ### Master light attributes @@ -159,6 +163,7 @@ rich effect info and a full control surface without walking every companion enti | `effect_audio_reactive` | whether the running effect reacts to audio | | `effect_controls` | normalized control descriptors (`id`, `label`, `kind`, `min`/`max`/`step`, `value`, `options`) for every control the running effect exposes | | `effect_image` / `active_effect_cover_image_url` | cover art URL for palette extraction | +| `active_preset_id` / `active_preset_modified` | selected preset derivation and whether live controls diverged from it | | `active_scene` / `active_scene_id` / `zone_count` / `scene_count` / `device_count` | scene and topology context | ### Live controls @@ -172,15 +177,16 @@ the `hypercolor.set_control` service. ## ๐Ÿช„ Services -Sixteen services cover the daemon's full surface area. All of them take `config_entry_id` -so multi-daemon setups stay unambiguous. +Twenty services cover Hypercolor's Home Assistant automation surface. All of them take +`config_entry_id` so multi-daemon setups stay unambiguous. | Service | What it does | | --- | --- | | `hypercolor.apply_effect` | apply an effect by id, optionally with controls, transition, or an effect-scoped preset id | | `hypercolor.set_color` | shortcut for the `solid_color` effect, takes `hex` or `r/g/b` | | `hypercolor.set_control` | patch a single control on the running effect | -| `hypercolor.activate_scene` / `create_scene` | activate or create a scene | +| `hypercolor.activate_scene` / `deactivate_scene` / `create_scene` | activate, deactivate, or create a scene | +| `hypercolor.set_zone` / `list_zones` / `set_unassigned_behavior` | inspect and configure scene zones | | `hypercolor.activate_profile` / `save_profile` | activate or capture a profile | | `hypercolor.apply_layout` | switch spatial layouts | | `hypercolor.apply_preset` | apply a bundled or saved preset by `effect_id` and `preset_id` | @@ -261,11 +267,13 @@ that becomes the integration's unique id. That means the same daemon keeps the s entry across IP changes, container restarts, and network re-shuffles. The integration also runs a background WebSocket session against the daemon. Events -patch authoritative state immediately and refresh only the affected coordinator; metrics -and audio spectrum are opt-in channels that ride the same socket. If the WebSocket drops, -the integration backs off exponentially and retries forever, so HA's connectivity sensor -reflects reality without needing per-tick polling. Periodic reconciliation is disabled by -default and remains available as an explicit fallback. +trigger immediate coordinator refreshes; aggregate metrics and audio spectrum are opt-in +channels that ride the same socket. The integration subscribes before its first HTTP +reconciliation, treats resync notifications as barriers, and negotiates optional channels +against the daemon's advertised capabilities. If the WebSocket drops, the integration +backs off exponentially and retries forever, so HA's connectivity sensor reflects reality +without needing per-tick polling. Periodic reconciliation is disabled by default and remains +available as an explicit fallback. ## ๐Ÿงช Development @@ -275,7 +283,7 @@ Python 3.14, ruff, ty, pytest. The dev tooling expects a sibling checkout of client can be installed editable. ```bash -git clone https://github.com/hyperb1iss/hypercolor.git ../hypercolor +git clone https://github.com/hyperb1iss/hypercolor.git git clone https://github.com/hyperb1iss/hypercolor-hass.git cd hypercolor-hass @@ -294,7 +302,9 @@ just hass-dev | `just fmt` | `ruff check --fix` then `ruff format` | | `just lint` | `ruff check` and `ruff format --check` | | `just typecheck` | `ty check` against the integration | -| `just test` | full pytest suite | +| `just test` | full pytest suite with the coverage gate | +| `just e2e` | full integration lifecycle against the fake daemon | +| `just e2e-real` | smoke test against a running real daemon | | `just metadata` | manifest, hacs.json, services.yaml, strings.json checks | | `just hass-check` | Home Assistant config validation against the throwaway config | | `just verify` | the whole pipeline: lint โ†’ typecheck โ†’ test โ†’ metadata โ†’ build | @@ -311,9 +321,11 @@ ruff and ty run on every commit. ### Tests Unit tests live under `tests/` and use `pytest-homeassistant-custom-component` to bring up -a real HA instance per test. The end-to-end suite (`tests/test_hass_e2e.py`) exercises -the full integration lifecycle against a mock daemon. Pass `HYPERCOLOR_HASS_REAL_E2E=1` -and run `just e2e-real` to point it at a real daemon. +a real HA instance per test. The end-to-end scenarios in +`tests/test_hass_control_surface.py` and `tests/test_hass_entity_lifecycle.py` exercise +the full integration lifecycle against a fake daemon. Pass +`HYPERCOLOR_HASS_REAL_E2E=1` and run `just e2e-real` to point the explicit smoke test at +a real daemon. ## ๐Ÿ’œ Contributing @@ -322,7 +334,7 @@ PRs welcome. The bar is: 1. `just verify` is green 2. Tests cover anything you added or changed 3. Conventional commits (`feat(hass):`, `fix(hass):`, etc.) -4. No `SyncHypercolorClient` import โ€” the integration is async only +4. No `SyncHypercolorClient` import because the integration is async only For larger ideas, open an issue first so we can sketch the shape before you write the code. Driver work, spatial topology, and effect authoring all live upstream in @@ -330,9 +342,9 @@ code. Driver work, spatial topology, and effect authoring all live upstream in ## ๐ŸŒ™ Related -- ๐Ÿ’œ [Hypercolor](https://github.com/hyperb1iss/hypercolor) โ€” the engine and daemon -- ๐ŸŒŒ [SignalRGB Home Assistant](https://github.com/hyperb1iss/signalrgb-homeassistant) โ€” sister integration for SignalRGB on Windows -- ๐Ÿช„ [hyper-light-card](https://github.com/hyperb1iss/hyper-light-card) โ€” companion Lovelace card for this integration +- ๐Ÿ’œ [Hypercolor](https://github.com/hyperb1iss/hypercolor), the engine and daemon +- ๐ŸŒŒ [SignalRGB Home Assistant](https://github.com/hyperb1iss/signalrgb-homeassistant), the sister integration for SignalRGB on Windows +- ๐Ÿช„ [hyper-light-card](https://github.com/hyperb1iss/hyper-light-card), the companion Lovelace card for this integration ## ๐Ÿ“„ License diff --git a/custom_components/hypercolor/__init__.py b/custom_components/hypercolor/__init__.py index 8f33be7..f213984 100644 --- a/custom_components/hypercolor/__init__.py +++ b/custom_components/hypercolor/__init__.py @@ -15,6 +15,7 @@ from homeassistant.helpers.httpx_client import get_async_client from hypercolor import HypercolorClient +from hypercolor.models import Device from .api import ( CannotConnectError, @@ -24,6 +25,7 @@ ) from .const import ( CONF_API_KEY, + CONF_CHANNELS_AUDIO, CONF_RECONCILE_INTERVAL_S, DOMAIN, OPTIONS_DEFAULTS, @@ -31,15 +33,13 @@ ) from .coordinator import ( HypercolorCoordinator, - load_audio, - load_catalog, - load_metrics, - load_state, + load_snapshot, reconcile_loop, websocket_loop, ) -from .entity import child_device_identifier, read_field -from .runtime_data import HypercolorRuntimeData +from .entity import child_device_identifier +from .models import HypercolorState +from .runtime_data import ConnectionSource, ConnectionState, HypercolorRuntimeData from .services import async_setup_services type HypercolorConfigEntry = ConfigEntry[HypercolorRuntimeData] @@ -76,68 +76,40 @@ async def async_setup_entry(hass: HomeAssistant, entry: HypercolorConfigEntry) - api_key=entry.data.get(CONF_API_KEY), httpx_client=httpx_client, ) + connection_state = ConnectionState() + connection_state.set_connected(ConnectionSource.SNAPSHOT) + coordinator = HypercolorCoordinator( + hass, + config_entry=entry, + loader=lambda previous: load_snapshot( + client, + load_audio=bool( + entry.options.get( + CONF_CHANNELS_AUDIO, + OPTIONS_DEFAULTS[CONF_CHANNELS_AUDIO], + ) + ), + previous=previous, + ), + connection_state=connection_state, + ) runtime_data = HypercolorRuntimeData( client=client, server=server, + coordinator=coordinator, + connection_state=connection_state, ) - runtime_data.connection_state.set_connected() entry.runtime_data = runtime_data - state = HypercolorCoordinator( - hass, - config_entry=entry, - name="state", - loader=lambda: load_state(client), - connection_state=runtime_data.connection_state, - ) - catalog = HypercolorCoordinator( - hass, - config_entry=entry, - name="catalog", - loader=lambda: load_catalog(client), - connection_state=runtime_data.connection_state, - ) - devices = HypercolorCoordinator( - hass, - config_entry=entry, - name="devices", - loader=client.get_devices, - connection_state=runtime_data.connection_state, - ) - metrics = HypercolorCoordinator( - hass, - config_entry=entry, - name="metrics", - loader=lambda: load_metrics(client), - connection_state=runtime_data.connection_state, - ) - audio = HypercolorCoordinator( - hass, - config_entry=entry, - name="audio", - loader=lambda: load_audio(client), - connection_state=runtime_data.connection_state, - ) - runtime_data.coordinators.update( - { - "state": state, - "catalog": catalog, - "devices": devices, - "metrics": metrics, - "audio": audio, - } - ) - await state.async_config_entry_first_refresh() - await catalog.async_config_entry_first_refresh() - await devices.async_config_entry_first_refresh() - if entry.options.get("channels.metrics", OPTIONS_DEFAULTS["channels.metrics"]): - await metrics.async_config_entry_first_refresh() - if entry.options.get("channels.audio", OPTIONS_DEFAULTS["channels.audio"]): - await audio.async_config_entry_first_refresh() + await coordinator.async_config_entry_first_refresh() entry.async_on_unload(entry.add_update_listener(_async_update_listener)) - _register_child_devices(hass, entry, devices.data) + def sync_devices() -> None: + _register_child_devices(hass, entry, runtime_data.snapshot.devices) + + sync_devices() + entry.async_on_unload(runtime_data.coordinator.async_add_listener(sync_devices)) _cleanup_opted_out_entities(hass, entry) - _cleanup_stale_zone_entities(hass, entry, state.data) + _cleanup_stale_zone_entities(hass, entry, runtime_data.snapshot.state) reconcile_interval_s = int( entry.options.get(CONF_RECONCILE_INTERVAL_S, OPTIONS_DEFAULTS[CONF_RECONCILE_INTERVAL_S]) @@ -145,7 +117,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: HypercolorConfigEntry) - if reconcile_interval_s > 0: runtime_data.reconcile_task = entry.async_create_background_task( hass, - reconcile_loop([state, catalog, devices], reconcile_interval_s), + reconcile_loop(coordinator, reconcile_interval_s), name="hypercolor.reconcile", ) runtime_data.ws_task = entry.async_create_background_task( @@ -167,7 +139,12 @@ async def async_unload_entry(hass: HomeAssistant, entry: HypercolorConfigEntry) tasks = [ task - for task in (runtime.ws_task, runtime.reconcile_task, runtime.unavailable_task) + for task in ( + runtime.ws_task, + runtime.reconcile_task, + runtime.coordinator.unavailable_task, + *runtime.refresh_tasks, + ) if task is not None ] for task in tasks: @@ -218,7 +195,7 @@ async def async_remove_config_entry_device( def _register_child_devices( hass: HomeAssistant, entry: HypercolorConfigEntry, - devices: list[Any], + devices: tuple[Device, ...], ) -> None: device_registry = dr.async_get(hass) runtime = entry.runtime_data @@ -229,19 +206,19 @@ def _register_child_devices( manufacturer="Hypercolor", model="Daemon", sw_version=runtime.server.version, - configuration_url=(f"http://{entry.data[CONF_HOST]}:{entry.data[CONF_PORT]}"), + configuration_url=f"http://{entry.data[CONF_HOST]}:{entry.data[CONF_PORT]}", ) - for device in devices or []: - device_id = str(read_field(device, "id")) + for device in devices: + device_id = device.id if not device_id: continue device_registry.async_get_or_create( config_entry_id=entry.entry_id, identifiers={(DOMAIN, child_device_identifier(runtime, device_id))}, - name=str(read_field(device, "name", device_id)), - manufacturer=str(read_field(device, "vendor", "Hypercolor")), - model=str(read_field(device, "backend", read_field(device, "family", "LED device"))), - sw_version=read_field(device, "firmware_version"), + name=device.name, + manufacturer="Hypercolor", + model=device.backend, + sw_version=device.firmware_version, via_device=(DOMAIN, runtime.server.instance_id), ) @@ -265,28 +242,25 @@ def _cleanup_opted_out_entities( if suffix is None: continue device_id = registry_entry.unique_id[len(prefix) : -len(suffix)] - if device_id in opted_in: - continue - entity_registry.async_remove(registry_entry.entity_id) + if device_id not in opted_in: + entity_registry.async_remove(registry_entry.entity_id) def _cleanup_stale_zone_entities( hass: HomeAssistant, entry: HypercolorConfigEntry, - state: Any, + state: HypercolorState, ) -> None: """Prune zone lights whose zones no longer exist. Zone ids are per-scene UUIDs, so zone churn would otherwise grow the - registry without bound. Pruning happens at setup only โ€” mid-session + registry without bound. Pruning happens at setup only; mid-session scene switches leave entities unavailable rather than yanking them out from under dashboards. """ entity_registry = er.async_get(hass) runtime = entry.runtime_data - current_zone_ids = { - str(read_field(zone, "id")) for zone in read_field(state, "zones", []) or [] - } + current_zone_ids = {zone.id for zone in state.zones} prefix = f"{runtime.server.instance_id}:zone:" for registry_entry in er.async_entries_for_config_entry(entity_registry, entry.entry_id): if not registry_entry.unique_id.startswith(prefix): diff --git a/custom_components/hypercolor/api.py b/custom_components/hypercolor/api.py index bcfd5b5..c27ec6c 100644 --- a/custom_components/hypercolor/api.py +++ b/custom_components/hypercolor/api.py @@ -1,11 +1,17 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Any +from typing import Any, Never import httpx -from .const import CONF_API_KEY +from hypercolor import ( + HypercolorApiError, + HypercolorAuthenticationError, + HypercolorClient, + HypercolorError, + HypercolorNotFoundError, +) class CannotConnectError(Exception): @@ -52,45 +58,38 @@ async def async_validate_daemon( except (KeyError, TypeError, ValueError) as exc: raise CannotConnectError from exc - headers = {"Authorization": f"Bearer {api_key}"} if api_key else {} + client = HypercolorClient( + host=host, + port=port, + api_key=api_key, + httpx_client=httpx_client, + ) try: - output_probe = await httpx_client.get( - f"{root_url}/api/v1/output/power", - headers=headers, - ) - except httpx.HTTPError as exc: - raise CannotConnectError from exc - if output_probe.status_code in {httpx.codes.UNAUTHORIZED, httpx.codes.FORBIDDEN}: - raise InvalidAuthError - if output_probe.status_code == httpx.codes.NOT_FOUND: - raise UnsupportedDaemonError - if output_probe.status_code >= httpx.codes.BAD_REQUEST: - raise CannotConnectError + await client.get_output_power() + except (HypercolorError, httpx.HTTPError, TypeError, ValueError) as exc: + _raise_client_validation_error(exc, unsupported_not_found=True) if server_info.auth_required: try: - control_probe = await httpx_client.post( - f"{root_url}/api/v1/diagnose", - headers=headers, - json={"checks": []}, - ) - except httpx.HTTPError as exc: - raise CannotConnectError from exc - - if control_probe.status_code in { - httpx.codes.UNAUTHORIZED, - httpx.codes.FORBIDDEN, - }: - raise InvalidAuthError - if control_probe.status_code >= httpx.codes.BAD_REQUEST: - raise CannotConnectError + await client.run_diagnostics(checks=[]) + except (HypercolorError, httpx.HTTPError, TypeError, ValueError) as exc: + _raise_client_validation_error(exc) return server_info -def auth_headers(entry_data: dict[str, Any]) -> dict[str, str]: - api_key = entry_data.get(CONF_API_KEY) - return {"Authorization": f"Bearer {api_key}"} if api_key else {} +def _raise_client_validation_error( + error: Exception, + *, + unsupported_not_found: bool = False, +) -> Never: + if isinstance(error, HypercolorAuthenticationError) or ( + isinstance(error, HypercolorApiError) and error.status_code == httpx.codes.FORBIDDEN + ): + raise InvalidAuthError from error + if unsupported_not_found and isinstance(error, HypercolorNotFoundError): + raise UnsupportedDaemonError from error + raise CannotConnectError from error def _server_payload(payload: Any) -> dict[str, Any]: diff --git a/custom_components/hypercolor/binary_sensor.py b/custom_components/hypercolor/binary_sensor.py index d819593..40105ea 100644 --- a/custom_components/hypercolor/binary_sensor.py +++ b/custom_components/hypercolor/binary_sensor.py @@ -1,28 +1,22 @@ from __future__ import annotations -from datetime import UTC, datetime +from time import monotonic from homeassistant.components.binary_sensor import BinarySensorDeviceClass, BinarySensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_call_later -from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( CONF_AUDIO_BEAT_HOLD_MS, CONF_CHANNELS_AUDIO, + CONF_DISCONNECT_GRACE_S, DEFAULT_AUDIO_BEAT_HOLD_MS, + DEFAULT_DISCONNECT_GRACE_S, ) -from .entity import ( - MultiCoordinatorEntity, - catalog_items, - hub_device_info, - item_id, - item_name, - read_field, -) -from .runtime_data import HypercolorRuntimeData +from .entity import HypercolorEntity, HypercolorWebsocketEntity, hub_device_info +from .runtime_data import ConnectionSource, HypercolorRuntimeData async def async_setup_entry( @@ -49,35 +43,36 @@ class HypercolorConnectedBinarySensor(BinarySensorEntity): def __init__(self, entry: ConfigEntry[HypercolorRuntimeData]) -> None: runtime = entry.runtime_data self._entry = entry + self._remove_timer: CALLBACK_TYPE | None = None self._attr_device_info = hub_device_info(runtime, entry.data) self._attr_unique_id = f"{runtime.server.instance_id}:connected" - self._remove_timer: CALLBACK_TYPE | None = None async def async_added_to_hass(self) -> None: await super().async_added_to_hass() self.async_on_remove(self._cancel_timer) self.async_on_remove( - self._entry.runtime_data.connection_state.async_add_listener( - self._handle_connection_update - ) + self._entry.runtime_data.connection_state.add_listener(self._connection_updated) ) - self._handle_connection_update() + self._connection_updated() @callback - def _handle_connection_update(self) -> None: + def _connection_updated(self) -> None: self._cancel_timer() - if not self._entry.runtime_data.connection_state.connected: - grace_s = int(self._entry.options.get("disconnect_grace_s", 5)) - if grace_s > 0: - self._remove_timer = async_call_later( - self.hass, - grace_s, - self._disconnect_grace_expired, - ) + grace_s = int(self._entry.options.get(CONF_DISCONNECT_GRACE_S, DEFAULT_DISCONNECT_GRACE_S)) + unavailable_in = self._entry.runtime_data.connection_state.source_unavailable_in( + ConnectionSource.WEBSOCKET, + grace_s, + ) + if unavailable_in is not None and unavailable_in > 0: + self._remove_timer = async_call_later( + self.hass, + unavailable_in, + self._connection_grace_expired, + ) self.async_write_ha_state() @callback - def _disconnect_grace_expired(self, *_: object) -> None: + def _connection_grace_expired(self, *_: object) -> None: self._remove_timer = None self.async_write_ha_state() @@ -89,45 +84,35 @@ def _cancel_timer(self) -> None: @property def is_on(self) -> bool: - state = self._entry.runtime_data.connection_state - if state.connected: - return True - grace_s = int(self._entry.options.get("disconnect_grace_s", 5)) - if state.last_disconnected_at is None: - return False - elapsed = datetime.now(UTC) - state.last_disconnected_at - return elapsed.total_seconds() < grace_s + grace_s = int(self._entry.options.get(CONF_DISCONNECT_GRACE_S, DEFAULT_DISCONNECT_GRACE_S)) + return self._entry.runtime_data.connection_state.is_source_connected( + ConnectionSource.WEBSOCKET, + grace_s, + ) -class HypercolorAudioBeatBinarySensor(CoordinatorEntity, BinarySensorEntity): +class HypercolorAudioBeatBinarySensor(HypercolorWebsocketEntity, BinarySensorEntity): _attr_device_class = BinarySensorDeviceClass.SOUND _attr_has_entity_name = True _attr_name = "Audio beat" def __init__(self, entry: ConfigEntry[HypercolorRuntimeData]) -> None: + super().__init__(entry) runtime = entry.runtime_data - super().__init__(runtime.coordinators["audio"]) - self._entry = entry self._remove_timer: CALLBACK_TYPE | None = None self._attr_device_info = hub_device_info(runtime, entry.data) self._attr_unique_id = f"{runtime.server.instance_id}:audio_beat" - async def async_added_to_hass(self) -> None: - await super().async_added_to_hass() - self.async_on_remove(self._cancel_timer) - @property def is_on(self) -> bool: - spectrum = (self.coordinator.data or {}).get("spectrum") or {} - beat_until = spectrum.get("beat_until") - if isinstance(beat_until, datetime): - return datetime.now(UTC) <= beat_until - return bool(spectrum.get("beat", False)) + beat_until = self.snapshot.audio.beat_until + return beat_until is not None and monotonic() <= beat_until @callback def _handle_coordinator_update(self) -> None: if self.is_on: - self._cancel_timer() + if self._remove_timer is not None: + self._remove_timer() hold_ms = int( self._entry.options.get( CONF_AUDIO_BEAT_HOLD_MS, @@ -146,42 +131,17 @@ def _beat_expired(self, *_: object) -> None: self._remove_timer = None self.async_write_ha_state() - @callback - def _cancel_timer(self) -> None: - if self._remove_timer is not None: - self._remove_timer() - self._remove_timer = None - -class HypercolorAudioReactiveBinarySensor(MultiCoordinatorEntity, BinarySensorEntity): +class HypercolorAudioReactiveBinarySensor(HypercolorEntity, BinarySensorEntity): _attr_has_entity_name = True _attr_name = "Audio reactive active" def __init__(self, entry: ConfigEntry[HypercolorRuntimeData]) -> None: + super().__init__(entry) runtime = entry.runtime_data - super().__init__(runtime.coordinators["state"], runtime.coordinators["catalog"]) - self._catalog = runtime.coordinators["catalog"] self._attr_device_info = hub_device_info(runtime, entry.data) self._attr_unique_id = f"{runtime.server.instance_id}:audio_reactive_active" @property def is_on(self) -> bool: - return active_effect_audio_reactive(self.coordinator.data, self._catalog.data) - - -def active_effect_audio_reactive(state_data: object, catalog_data: object) -> bool: - active = read_field(state_data, "active_effect_detail") - audio_reactive = read_field(active, "audio_reactive") - if audio_reactive is not None: - return bool(audio_reactive) - - active_id = read_field(state_data, "active_effect_id") - active_name = read_field(state_data, "active_effect") - for effect in catalog_items(catalog_data, "effects"): - if active_id is not None and item_id(effect) == active_id: - return bool(read_field(effect, "audio_reactive", False)) - if active_id is None and active_name: - for effect in catalog_items(catalog_data, "effects"): - if item_name(effect) == active_name: - return bool(read_field(effect, "audio_reactive", False)) - return False + return self.snapshot.active_effect_audio_reactive diff --git a/custom_components/hypercolor/button.py b/custom_components/hypercolor/button.py index 7d053f7..89911bb 100644 --- a/custom_components/hypercolor/button.py +++ b/custom_components/hypercolor/button.py @@ -1,25 +1,20 @@ from __future__ import annotations -import contextlib import secrets from collections.abc import Awaitable, Callable -from typing import Any from homeassistant.components.button import ButtonEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity -from hypercolor import HypercolorNotFoundError +from hypercolor.models import Device from .entity import ( + HypercolorDeviceEntity, + HypercolorEntity, add_configured_device_entities, - catalog_items, - child_device_info, hub_device_info, - item_id, - read_field, ) from .runtime_data import HypercolorRuntimeData @@ -29,12 +24,13 @@ async def async_setup_entry( entry: ConfigEntry[HypercolorRuntimeData], async_add_entities: AddEntitiesCallback, ) -> None: + runtime = entry.runtime_data entities: list[ButtonEntity] = [ HypercolorActionButton( entry, name="Discover devices", unique_suffix="discover_devices", - action=entry.runtime_data.client.discover_devices, + action=lambda: runtime.async_mutate(runtime.client.discover_devices), ), HypercolorEffectNavigationButton(entry, "Previous effect", "previous_effect", -1), HypercolorEffectNavigationButton(entry, "Next effect", "next_effect", 1), @@ -43,14 +39,14 @@ async def async_setup_entry( entry, name="Stop effect", unique_suffix="stop_effect", - action=lambda: _stop_effect(entry.runtime_data.client), + action=runtime.async_stop_effect, ), ] async_add_entities(entities) add_configured_device_entities(entry, async_add_entities, HypercolorIdentifyDeviceButton) -class HypercolorActionButton(CoordinatorEntity, ButtonEntity): +class HypercolorActionButton(HypercolorEntity, ButtonEntity): _attr_has_entity_name = True def __init__( @@ -59,11 +55,10 @@ def __init__( *, name: str, unique_suffix: str, - action: Callable[[], Awaitable[Any]], + action: Callable[[], Awaitable[object]], ) -> None: + super().__init__(entry) runtime = entry.runtime_data - super().__init__(runtime.coordinators["state"]) - self._entry = entry self._action = action self._attr_name = name self._attr_device_info = hub_device_info(runtime, entry.data) @@ -71,10 +66,9 @@ def __init__( async def async_press(self) -> None: await self._action() - await self._entry.runtime_data.coordinators["state"].async_request_refresh() -class HypercolorEffectNavigationButton(CoordinatorEntity, ButtonEntity): +class HypercolorEffectNavigationButton(HypercolorEntity, ButtonEntity): _attr_has_entity_name = True def __init__( @@ -84,48 +78,39 @@ def __init__( unique_suffix: str, step: int, ) -> None: + super().__init__(entry) runtime = entry.runtime_data - super().__init__(runtime.coordinators["catalog"]) - self._entry = entry - self._state = runtime.coordinators["state"] self._step = step self._attr_name = name self._attr_device_info = hub_device_info(runtime, entry.data) self._attr_unique_id = f"{runtime.server.instance_id}:{unique_suffix}" async def async_press(self) -> None: - effects = catalog_items(self.coordinator.data, "effects") + effects = self.snapshot.catalog.effects.items if not effects: return if self._step == 0: effect = secrets.choice(effects) else: - active = read_field(self._state.data, "active_effect_id") + active_id = self.snapshot.state.active_effect_id index = next( - (idx for idx, effect in enumerate(effects) if item_id(effect) == active), + (idx for idx, effect in enumerate(effects) if effect.id == active_id), -1, ) effect = effects[(index + self._step) % len(effects)] - await self._entry.runtime_data.client.apply_effect(item_id(effect)) - await self._state.async_request_refresh() + await self._runtime.async_mutate(lambda: self._runtime.client.apply_effect(effect.id)) -class HypercolorIdentifyDeviceButton(CoordinatorEntity, ButtonEntity): +class HypercolorIdentifyDeviceButton(HypercolorDeviceEntity, ButtonEntity): _attr_has_entity_name = True _attr_name = "Identify" - def __init__(self, entry: ConfigEntry[HypercolorRuntimeData], device: Any) -> None: + def __init__(self, entry: ConfigEntry[HypercolorRuntimeData], device: Device) -> None: + super().__init__(entry, device) runtime = entry.runtime_data - super().__init__(runtime.coordinators["devices"]) - self._entry = entry - self._device_id = str(read_field(device, "id")) - self._attr_device_info = child_device_info(runtime, device) self._attr_unique_id = f"{runtime.server.instance_id}:device:{self._device_id}:identify" async def async_press(self) -> None: - await self._entry.runtime_data.client.identify_device(self._device_id) - - -async def _stop_effect(client: Any) -> None: - with contextlib.suppress(HypercolorNotFoundError): - await client.stop_effect() + await self._runtime.async_mutate( + lambda: self._runtime.client.identify_device(self._device_id) + ) diff --git a/custom_components/hypercolor/config_flow.py b/custom_components/hypercolor/config_flow.py index d521143..e509b9f 100644 --- a/custom_components/hypercolor/config_flow.py +++ b/custom_components/hypercolor/config_flow.py @@ -33,7 +33,6 @@ DOMAIN, OPTIONS_DEFAULTS, ) -from .entity import read_field class HypercolorConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @@ -313,12 +312,8 @@ def _device_options( selected_ids: list[str], ) -> list[selector.SelectOptionDict]: runtime = entry.runtime_data if entry.state is ConfigEntryState.LOADED else None - coordinator = read_field(runtime, "coordinators", {}).get("devices") if runtime else None - devices = read_field(coordinator, "data", []) or [] - labels = { - str(read_field(device, "id")): str(read_field(device, "name", read_field(device, "id"))) - for device in devices - } + devices = runtime.snapshot.devices if runtime is not None else () + labels = {device.id: device.name for device in devices} for device_id in selected_ids: labels.setdefault(device_id, device_id) return [ diff --git a/custom_components/hypercolor/coordinator.py b/custom_components/hypercolor/coordinator.py index ce35322..e8db664 100644 --- a/custom_components/hypercolor/coordinator.py +++ b/custom_components/hypercolor/coordinator.py @@ -4,181 +4,294 @@ import contextlib import logging from collections.abc import Awaitable, Callable -from datetime import UTC, datetime, timedelta -from typing import Any +from time import monotonic +from typing import Any, Protocol from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from websockets.exceptions import InvalidStatus -from hypercolor import HypercolorAuthenticationError, HypercolorNotFoundError +from hypercolor import HypercolorAuthenticationError, HypercolorError +from hypercolor.models import ( + ActiveEffect, + ActiveScene, + AudioDevices, + Device, + EffectPreset, + EffectSummary, + Layout, + LayoutSummary, + ProfileSummary, + Scene, + SystemState, +) from hypercolor.websocket import EventMessage, MetricsMessage, SpectrumData from .const import ( CONF_AUDIO_BEAT_HOLD_MS, CONF_CHANNELS_AUDIO, CONF_CHANNELS_METRICS, + CONF_UNAVAILABLE_AFTER_S, DOMAIN, OPTIONS_DEFAULTS, ) -from .entity import read_field +from .models import HypercolorAudio, HypercolorCatalog, HypercolorSnapshot, HypercolorState from .repairs import ( async_create_auth_issue, async_create_unavailable_issue, async_delete_auth_issue, async_delete_unavailable_issue, ) -from .runtime_data import ConnectionState, HypercolorRuntimeData +from .runtime_data import ConnectionSource, ConnectionState, HypercolorRuntimeData _LOGGER = logging.getLogger(__name__) -# A reconnect must not stall on a daemon that accepts the socket but is slow -# to send its hello frame (websockets only times out the handshake itself). WS_CONNECT_TIMEOUT_S = 15 -_EVENT_REFRESH_TARGETS = { - "asset_changed": ("catalog",), - "audio_source_changed": ("audio", "state"), - "audio_started": ("audio", "state"), - "audio_stopped": ("audio", "state"), - "config_changed": ("state",), - "control_surface_changed": ("devices",), - "effect_started": ("catalog", "state"), - "effect_stopped": ("catalog", "state"), - "effect_registry_updated": ("catalog",), - "input_source_changed": ("state",), - "library_store_changed": ("catalog",), - "profile_deleted": ("catalog",), - "profile_loaded": ("state",), - "profile_saved": ("catalog",), - "scene_library_changed": ("catalog",), - "scene_enabled": ("catalog",), - "scene_settings_changed": ("catalog", "state"), - "session_changed": ("state",), +_REFRESH_EVENTS = { + "asset_changed", + "audio_source_changed", + "audio_started", + "audio_stopped", + "brightness_changed", + "capture_started", + "capture_stopped", + "config_changed", + "context_changed", + "control_surface_changed", + "daemon_shutdown", + "daemon_started", + "input_source_changed", + "library_store_changed", + "paused", + "resumed", + "session_changed", } -_EVENT_PREFIX_REFRESH_TARGETS = ( - ("effect_", ("state",)), - ("scene_", ("state",)), - ("active_scene_", ("state",)), - ("render_group_", ("state",)), - ("layer_", ("state",)), - ("layout_", ("catalog", "state")), - ("device_", ("devices",)), +_REFRESH_EVENT_PREFIXES = ( + "active_scene_", + "device_", + "effect_", + "layer_", + "layout_", + "profile_", + "render_group_", + "scene_", ) +_NO_REFRESH_EVENTS = { + "audio_level_update", + "beat_detected", + "device_metrics", + "frame_rendered", +} + + +class SnapshotClient(Protocol): + async def get_status(self) -> SystemState: ... + + async def get_active_effect(self) -> ActiveEffect | None: ... + + async def get_active_scene(self) -> ActiveScene | None: ... + + async def get_active_layout(self) -> Layout | None: ... + + async def get_effects(self) -> list[EffectSummary]: ... + + async def get_scenes(self) -> list[Scene]: ... + + async def get_profiles(self) -> list[ProfileSummary]: ... + + async def get_layouts(self) -> list[LayoutSummary]: ... + + async def get_effect_presets(self, effect_id: str) -> list[EffectPreset]: ... + + async def get_devices(self) -> list[Device]: ... + async def get_audio_devices(self) -> AudioDevices: ... -class HypercolorCoordinator(DataUpdateCoordinator[Any]): + def active_effect_cover_image_url(self) -> str: ... + + +class HypercolorCoordinator(DataUpdateCoordinator[HypercolorSnapshot]): def __init__( self, hass: HomeAssistant, *, config_entry: ConfigEntry[Any], - name: str, - loader: Callable[[], Awaitable[Any]], + loader: Callable[[HypercolorSnapshot | None], Awaitable[HypercolorSnapshot]], connection_state: ConnectionState, ) -> None: super().__init__( hass, logger=_LOGGER, - name=f"{DOMAIN}.{name}", + name=f"{DOMAIN}.snapshot", update_interval=None, config_entry=config_entry, ) self._loader = loader self._connection_state = connection_state - self._config_entry = config_entry + self.config_entry: ConfigEntry[Any] = config_entry + self.unavailable_task: asyncio.Task[None] | None = None - async def _async_update_data(self) -> Any: + async def _async_update_data(self) -> HypercolorSnapshot: try: - data = await self._loader() + data = await self._loader(self.data) except HypercolorAuthenticationError as exc: - self._connection_state.set_disconnected(exc) - async_create_auth_issue(self.hass, self._config_entry.entry_id) + self._connection_state.set_disconnected(ConnectionSource.SNAPSHOT, exc) + async_create_auth_issue(self.hass, self.config_entry.entry_id) raise ConfigEntryAuthFailed from exc - async_delete_auth_issue(self.hass, self._config_entry.entry_id) + except HypercolorError as exc: + self.mark_disconnected(ConnectionSource.SNAPSHOT, exc) + raise UpdateFailed("Failed to refresh Hypercolor snapshot") from exc + if self.data is not None: + data = data.with_push_telemetry(self.data) + self.mark_connected(ConnectionSource.SNAPSHOT) + async_delete_auth_issue(self.hass, self.config_entry.entry_id) return data + def mark_connected(self, source: ConnectionSource) -> None: + if self._connection_state.set_connected(source): + self._sync_unavailable_issue() -async def reconcile_loop( - coordinators: list[HypercolorCoordinator], - interval_s: int, -) -> None: - while True: - await asyncio.sleep(interval_s) - await asyncio.gather( - *(coordinator.async_request_refresh() for coordinator in coordinators) + def mark_disconnected( + self, + source: ConnectionSource, + error: BaseException, + ) -> None: + if self._connection_state.set_disconnected(source, error): + self._sync_unavailable_issue() + + def _sync_unavailable_issue(self) -> None: + if self.unavailable_task is not None: + self.unavailable_task.cancel() + self.unavailable_task = None + delay_s = self._connection_state.unavailable_in(self._unavailable_after_s) + if delay_s is None: + async_delete_unavailable_issue(self.hass, self.config_entry.entry_id) + elif delay_s <= 0: + async_create_unavailable_issue(self.hass, self.config_entry.entry_id) + else: + self.unavailable_task = self.hass.async_create_task( + self._create_unavailable_issue_after(delay_s), + ) + + async def _create_unavailable_issue_after(self, delay_s: float) -> None: + await asyncio.sleep(delay_s) + self.unavailable_task = None + if self._connection_state.is_available(self._unavailable_after_s): + return + async_create_unavailable_issue(self.hass, self.config_entry.entry_id) + + @property + def _unavailable_after_s(self) -> int: + return int( + self.config_entry.options.get( + CONF_UNAVAILABLE_AFTER_S, + OPTIONS_DEFAULTS[CONF_UNAVAILABLE_AFTER_S], + ) ) -async def load_state(client: Any) -> dict[str, Any]: - status = await client.get_status() - active_effect = await client.get_active_effect() - active_scene = await client.get_active_scene() - active_layout = await client.get_active_layout() - active_effect_id = read_field(active_effect, "id", read_field(status, "active_effect")) - active_effect_name = read_field(active_effect, "name", read_field(status, "active_effect")) - active_effect_definition = None - if active_effect_id and callable(get_effect := getattr(client, "get_effect", None)): - with contextlib.suppress(HypercolorNotFoundError): - active_effect_definition = await get_effect(str(active_effect_id)) - active_effect_cover_image_url = _active_effect_cover_image_url(client, active_effect) - zones = read_field(active_scene, "groups", []) or [] - return { - "status": status, - "active_effect_detail": active_effect, - "active_effect_definition": active_effect_definition, - "active_scene_detail": active_scene, - "active_layout_detail": active_layout, - "active_effect": active_effect_name, - "active_effect_id": active_effect_id, - "active_effect_name": active_effect_name, - "active_effect_state": read_field(active_effect, "state", "idle"), - "active_effect_cover_image_url": active_effect_cover_image_url, - "active_preset": read_field(active_effect, "active_preset_id"), - "active_preset_modified": bool(read_field(active_effect, "active_preset_modified", False)), - "active_scene": read_field(active_scene, "id"), - "active_scene_name": read_field(active_scene, "name"), - "active_layout": read_field(active_layout, "id"), - "zones": list(zones), - "groups_revision": read_field(active_scene, "groups_revision", 0), - "global_brightness": read_field(status, "global_brightness"), - "brightness": read_field(status, "brightness"), - "device_count": read_field(status, "device_count"), - "scene_count": read_field(status, "scene_count"), - "render_loop": read_field(status, "render_loop", {}), - "audio_available": read_field(status, "audio_available", False), - } - - -async def load_catalog(client: Any) -> dict[str, Any]: - active_effect = await _optional(client.get_active_effect) - active_effect_id = read_field(active_effect, "id") - return { - "effects": await client.get_effects(), - "scenes": await client.get_scenes(), - "profiles": await client.get_profiles(), - "layouts": await client.get_layouts(), - "preset_effect_id": str(active_effect_id) if active_effect_id else None, - "presets": ( - await client.get_effect_presets(str(active_effect_id)) if active_effect_id else [] +async def reconcile_loop(coordinator: HypercolorCoordinator, interval_s: int) -> None: + while True: + await asyncio.sleep(interval_s) + await coordinator.async_request_refresh() + + +async def load_snapshot( + client: SnapshotClient, + *, + load_audio: bool, + previous: HypercolorSnapshot | None = None, +) -> HypercolorSnapshot: + active_effect_task = asyncio.create_task(client.get_active_effect()) + state_task = load_state(client, active_effect=active_effect_task) + catalog_task = load_catalog(client, active_effect=active_effect_task) + devices_task = client.get_devices() + audio_task = client.get_audio_devices() if load_audio else _empty_audio() + state, catalog, devices, audio_devices = await asyncio.gather( + state_task, + catalog_task, + devices_task, + audio_task, + ) + previous_audio = previous.audio if previous is not None else HypercolorAudio() + return HypercolorSnapshot( + state=state, + catalog=catalog, + devices=tuple(devices), + metrics=previous.metrics if previous is not None else {}, + audio=HypercolorAudio( + devices=audio_devices, + spectrum=previous_audio.spectrum, + beat_until=previous_audio.beat_until, ), - } + ) -async def load_metrics(client: Any) -> dict[str, Any]: - status = await client.get_status() - return { - "status": status, - "fps": {}, - "frame_time": {}, - } +async def load_state( + client: SnapshotClient, + *, + active_effect: Awaitable[ActiveEffect | None] | None = None, +) -> HypercolorState: + active_effect_request = ( + active_effect if active_effect is not None else client.get_active_effect() + ) + status, active_effect_value, active_scene, active_layout = await asyncio.gather( + client.get_status(), + active_effect_request, + client.get_active_scene(), + client.get_active_layout(), + ) + cover_image_url = ( + client.active_effect_cover_image_url() + if active_effect_value is not None and active_effect_value.cover_image_url + else None + ) + return HypercolorState( + status=status, + active_effect=active_effect_value, + active_scene=active_scene, + active_layout=active_layout, + active_effect_cover_image_url=cover_image_url, + ) -async def load_audio(client: Any) -> dict[str, Any]: - devices = await client.get_audio_devices() - return {"devices": devices, "spectrum": None, "enabled": True} +async def load_catalog( + client: SnapshotClient, + *, + active_effect: Awaitable[ActiveEffect | None] | None = None, +) -> HypercolorCatalog: + active_effect_request = ( + active_effect if active_effect is not None else client.get_active_effect() + ) + effects, scenes, profiles, layouts, preset_stack = await asyncio.gather( + client.get_effects(), + client.get_scenes(), + client.get_profiles(), + client.get_layouts(), + _load_effect_presets(client, active_effect_request), + ) + preset_effect_id, presets = preset_stack + return HypercolorCatalog.build( + effects=effects, + scenes=scenes, + profiles=profiles, + layouts=layouts, + preset_effect_id=preset_effect_id, + presets=presets, + ) + + +async def _load_effect_presets( + client: SnapshotClient, + active_effect: Awaitable[ActiveEffect | None], +) -> tuple[str | None, list[EffectPreset]]: + effect = await active_effect + if effect is None: + return None, [] + return effect.id, await client.get_effect_presets(effect.id) async def websocket_loop(runtime: HypercolorRuntimeData, options: dict[str, Any]) -> None: @@ -188,11 +301,10 @@ async def websocket_loop(runtime: HypercolorRuntimeData, options: dict[str, Any] try: hello = await asyncio.wait_for(stream.connect(), timeout=WS_CONNECT_TIMEOUT_S) _mark_connected(runtime) - _seed_hello(runtime, hello) - channels = _websocket_channels(options) + channels = _websocket_channels(options, capabilities=set(hello.capabilities)) if channels: await stream.subscribe(*channels) - await _reconcile_after_reconnect(runtime, options) + await runtime.coordinator.async_request_refresh() backoff_s = 1 async for message in stream: await _process_ws_message(runtime, message, options) @@ -200,9 +312,11 @@ async def websocket_loop(runtime: HypercolorRuntimeData, options: dict[str, Any] raise except Exception as exc: # noqa: BLE001 error = _normalize_websocket_error(exc) - _mark_disconnected(runtime, options, error) + _mark_disconnected(runtime, error) if isinstance(error, HypercolorAuthenticationError): - _start_reauth(runtime) + entry = runtime.coordinator.config_entry + async_create_auth_issue(runtime.coordinator.hass, entry.entry_id) + entry.async_start_reauth(runtime.coordinator.hass) _LOGGER.debug("Hypercolor WebSocket disconnected", exc_info=True) await asyncio.sleep(backoff_s) backoff_s = min(backoff_s * 2, 30) @@ -211,75 +325,19 @@ async def websocket_loop(runtime: HypercolorRuntimeData, options: dict[str, Any] await stream.disconnect() -async def _optional(loader: Callable[[], Awaitable[Any]]) -> Any: - try: - return await loader() - except HypercolorNotFoundError: - return None - - -def _active_effect_cover_image_url(client: Any, active_effect: Any) -> str | None: - cover_image_url = read_field(active_effect, "cover_image_url") - if not cover_image_url: - return None - return client.active_effect_cover_image_url() - - -def _seed_hello(runtime: HypercolorRuntimeData, hello: Any) -> None: - hello_state = read_field(hello, "state") - if not isinstance(hello_state, dict): - return - - updates: dict[str, Any] = {} - if (brightness := read_field(hello_state, "brightness")) is not None: - updates.update(global_brightness=brightness, brightness=brightness) - if (paused := read_field(hello_state, "paused")) is not None: - updates["active_effect_state"] = "paused" if paused else "running" - if "effect" in hello_state: - effect = read_field(hello_state, "effect") - updates.update( - active_effect=read_field(effect, "name"), - active_effect_id=read_field(effect, "id"), - ) - if "scene" in hello_state: - scene = read_field(hello_state, "scene") - updates.update( - active_scene=read_field(scene, "id"), - active_scene_name=read_field(scene, "name"), - ) - if (device_count := read_field(hello_state, "device_count")) is not None: - updates["device_count"] = device_count - if updates: - _patch_coordinator(runtime, "state", **updates) - if isinstance(fps := read_field(hello_state, "fps"), dict): - _patch_coordinator(runtime, "metrics", fps=fps) - - -async def _reconcile_after_reconnect( - runtime: HypercolorRuntimeData, +def _websocket_channels( options: dict[str, Any], -) -> None: - names = ["state", "catalog", "devices"] - if options.get(CONF_CHANNELS_METRICS, OPTIONS_DEFAULTS[CONF_CHANNELS_METRICS]): - names.append("metrics") - if options.get(CONF_CHANNELS_AUDIO, OPTIONS_DEFAULTS[CONF_CHANNELS_AUDIO]): - names.append("audio") - refreshes = [ - runtime.coordinators[name].async_request_refresh() - for name in names - if name in runtime.coordinators - ] - if refreshes: - await asyncio.gather(*refreshes) - - -def _websocket_channels(options: dict[str, Any]) -> list[str]: + *, + capabilities: set[str] | None = None, +) -> list[str]: channels = ["events"] if options.get(CONF_CHANNELS_METRICS, OPTIONS_DEFAULTS[CONF_CHANNELS_METRICS]): channels.append("metrics") if options.get(CONF_CHANNELS_AUDIO, OPTIONS_DEFAULTS[CONF_CHANNELS_AUDIO]): channels.append("spectrum") - return channels + if capabilities is None: + return channels + return [channel for channel in channels if channel in capabilities] def _normalize_websocket_error(error: Exception) -> Exception: @@ -291,188 +349,77 @@ def _normalize_websocket_error(error: Exception) -> Exception: return error -def _start_reauth(runtime: HypercolorRuntimeData) -> None: - state = runtime.coordinators.get("state") - if state is None: +async def _process_ws_message( + runtime: HypercolorRuntimeData, + message: object, + options: dict[str, Any], +) -> None: + if isinstance(message, EventMessage) and message.event == "resync_required": + if runtime.refresh_tasks: + await asyncio.gather(*tuple(runtime.refresh_tasks), return_exceptions=True) + await runtime.coordinator.async_request_refresh() return - async_create_auth_issue(state.hass, state.config_entry.entry_id) - state.config_entry.async_start_reauth(state.hass) + _handle_ws_message(runtime, message, options) def _handle_ws_message( runtime: HypercolorRuntimeData, - message: Any, + message: object, options: dict[str, Any], ) -> None: _mark_connected(runtime) if isinstance(message, MetricsMessage): - _set_coordinator_data( - runtime, "metrics", _normalize_metrics(read_field(message, "data", {})) + runtime.coordinator.async_set_updated_data( + runtime.snapshot.with_metrics(_normalize_metrics(message.data)) ) - elif isinstance(message, SpectrumData): + return + if isinstance(message, SpectrumData): hold_ms = int( - options.get( - CONF_AUDIO_BEAT_HOLD_MS, - OPTIONS_DEFAULTS[CONF_AUDIO_BEAT_HOLD_MS], - ) + options.get(CONF_AUDIO_BEAT_HOLD_MS, OPTIONS_DEFAULTS[CONF_AUDIO_BEAT_HOLD_MS]) ) - beat_until = None - if read_field(message, "beat", False): - beat_until = datetime.now(UTC) + timedelta(milliseconds=hold_ms) - current = dict(read_field(runtime.coordinators.get("audio"), "data", {}) or {}) - current["spectrum"] = { - "level": read_field(message, "level", 0.0), - "bass": read_field(message, "bass", 0.0), - "mid": read_field(message, "mid", 0.0), - "treble": read_field(message, "treble", 0.0), - "beat": read_field(message, "beat", False), - "beat_confidence": read_field(message, "beat_confidence", 0.0), - "beat_until": beat_until, - } - _set_coordinator_data(runtime, "audio", current) - elif isinstance(message, EventMessage): - event = str(read_field(message, "event", "")) - data = read_field(message, "data", {}) - _handle_event(runtime, event, data) - - -async def _process_ws_message( - runtime: HypercolorRuntimeData, - message: Any, - options: dict[str, Any], -) -> None: - if isinstance(message, EventMessage) and str(read_field(message, "event", "")) == ( - "resync_required" - ): - _mark_connected(runtime) - await _reconcile_after_reconnect(runtime, options) - return - _handle_ws_message(runtime, message, options) - - -def _normalize_metrics(data: Any) -> dict[str, Any]: - normalized = dict(data) if isinstance(data, dict) else {} - normalized["fps"] = read_field(data, "fps", {}) or {} - normalized["frame_time"] = read_field(data, "frame_time", {}) or {} - return normalized - - -def _handle_event(runtime: HypercolorRuntimeData, event: str, data: Any) -> None: - if event == "resync_required": - _request_refresh(runtime, *sorted(runtime.coordinators)) - return - if event == "paused": - _patch_coordinator(runtime, "state", active_effect_state="paused") - return - if event == "resumed": - _patch_coordinator(runtime, "state", active_effect_state="running") - return - if event == "brightness_changed": - brightness = read_field(data, "new_value") - if brightness is not None: - _patch_coordinator( - runtime, - "state", - global_brightness=brightness, - brightness=brightness, - ) - return - if event == "fps_changed": - current = dict(read_field(runtime.coordinators.get("metrics"), "data", {}) or {}) - fps = dict(read_field(current, "fps", {}) or {}) - fps.update( - { - "target": read_field(data, "new_target"), - "actual": read_field(data, "measured"), - } + beat_until = monotonic() + hold_ms / 1000 if message.beat else None + runtime.coordinator.async_set_updated_data( + runtime.snapshot.with_spectrum(message, beat_until) ) - current["fps"] = fps - _set_coordinator_data(runtime, "metrics", current) return - - targets = set(_EVENT_REFRESH_TARGETS.get(event, ())) - if not targets: - targets.update( - target - for prefix, prefix_targets in _EVENT_PREFIX_REFRESH_TARGETS - if event.startswith(prefix) - for target in prefix_targets - ) - if event == "config_changed" and str(read_field(data, "key", "")).startswith("audio."): - targets.add("audio") - if event == "library_store_changed" and read_field(data, "collection") == "presets": - targets.add("state") - if targets: - _request_refresh(runtime, *sorted(targets)) + if not isinstance(message, EventMessage): + return + if event_requires_refresh(message.event): + _request_refresh(runtime) -def _set_coordinator_data( - runtime: HypercolorRuntimeData, - coordinator_name: str, - data: Any, -) -> None: - if coordinator := runtime.coordinators.get(coordinator_name): - coordinator.async_set_updated_data(data) +def event_requires_refresh(event: str) -> bool: + return event not in _NO_REFRESH_EVENTS and ( + event in _REFRESH_EVENTS + or any(event.startswith(prefix) for prefix in _REFRESH_EVENT_PREFIXES) + ) -def _patch_coordinator( - runtime: HypercolorRuntimeData, - coordinator_name: str, - **updates: Any, -) -> None: - coordinator = runtime.coordinators.get(coordinator_name) - if coordinator is None: - return - current = dict(coordinator.data or {}) - current.update(updates) - coordinator.async_set_updated_data(current) +def _request_refresh(runtime: HypercolorRuntimeData) -> None: + task = runtime.coordinator.hass.async_create_task( + runtime.coordinator.async_request_refresh(), + ) + runtime.refresh_tasks.add(task) + task.add_done_callback(runtime.refresh_tasks.discard) -def _request_refresh(runtime: HypercolorRuntimeData, *coordinator_names: str) -> None: - for coordinator_name in coordinator_names: - if coordinator := runtime.coordinators.get(coordinator_name): - coordinator.hass.async_create_task(coordinator.async_request_refresh()) +def _normalize_metrics(data: Any) -> dict[str, Any]: + normalized = dict(data) if isinstance(data, dict) else {} + normalized["fps"] = data.get("fps", {}) if isinstance(data, dict) else {} + normalized["frame_time"] = data.get("frame_time", {}) if isinstance(data, dict) else {} + return normalized def _mark_connected(runtime: HypercolorRuntimeData) -> None: - if not runtime.connection_state.set_connected(): - return - if runtime.unavailable_task is not None: - runtime.unavailable_task.cancel() - runtime.unavailable_task = None - state = runtime.coordinators.get("state") - if state is not None: - async_delete_unavailable_issue(state.hass, state.config_entry.entry_id) + runtime.coordinator.mark_connected(ConnectionSource.WEBSOCKET) def _mark_disconnected( runtime: HypercolorRuntimeData, - options: dict[str, Any], error: BaseException, ) -> None: - runtime.connection_state.set_disconnected(error) - if runtime.unavailable_task is not None: - return - state = runtime.coordinators.get("state") - if state is None: - return - unavailable_after_s = int(options.get("unavailable_after_s", 30)) - runtime.unavailable_task = state.hass.async_create_task( - _mark_unavailable_after(runtime, unavailable_after_s), - ) + runtime.coordinator.mark_disconnected(ConnectionSource.WEBSOCKET, error) -async def _mark_unavailable_after( - runtime: HypercolorRuntimeData, - delay_s: int, -) -> None: - await asyncio.sleep(delay_s) - if runtime.connection_state.connected: - return - state = runtime.coordinators.get("state") - if state is None: - return - error = ConnectionError("Hypercolor WebSocket is disconnected") - for coordinator in runtime.coordinators.values(): - coordinator.async_set_update_error(error) - async_create_unavailable_issue(state.hass, state.config_entry.entry_id) +async def _empty_audio() -> AudioDevices | None: + return None diff --git a/custom_components/hypercolor/diagnostics.py b/custom_components/hypercolor/diagnostics.py index eec5373..9e38e27 100644 --- a/custom_components/hypercolor/diagnostics.py +++ b/custom_components/hypercolor/diagnostics.py @@ -22,10 +22,7 @@ async def async_get_config_entry_diagnostics( "config": {**entry.data, **entry.options}, "server": asdict(runtime.server), "connection": runtime.connection_state.snapshot(), - "coordinators": { - name: coordinator.last_update_success - for name, coordinator in runtime.coordinators.items() - }, + "snapshot_coordinator": runtime.coordinator.last_update_success, }, TO_REDACT, ) diff --git a/custom_components/hypercolor/entity.py b/custom_components/hypercolor/entity.py index 566e2e4..a76aa8e 100644 --- a/custom_components/hypercolor/entity.py +++ b/custom_components/hypercolor/entity.py @@ -1,48 +1,177 @@ from __future__ import annotations -from collections import Counter from collections.abc import Mapping from typing import Any, Protocol from homeassistant.config_entries import ConfigEntry -from homeassistant.core import callback +from homeassistant.core import CALLBACK_TYPE, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.event import async_call_later from homeassistant.helpers.update_coordinator import CoordinatorEntity, DataUpdateCoordinator +from homeassistant.util import slugify -from .const import DOMAIN -from .runtime_data import HypercolorRuntimeData +from hypercolor.models import Device +from .const import ( + CONF_DISCONNECT_GRACE_S, + CONF_UNAVAILABLE_AFTER_S, + DEFAULT_DISCONNECT_GRACE_S, + DOMAIN, + OPTIONS_DEFAULTS, +) +from .models import HypercolorSnapshot +from .runtime_data import ConnectionSource, HypercolorRuntimeData -class _DeviceEntityFactory(Protocol): - def __call__(self, entry: ConfigEntry[HypercolorRuntimeData], device: Any) -> Any: ... - -class MultiCoordinatorEntity(CoordinatorEntity): - def __init__( +class DeviceEntityFactory(Protocol): + def __call__( self, - coordinator: DataUpdateCoordinator[Any], - *secondary_coordinators: DataUpdateCoordinator[Any], - ) -> None: - super().__init__(coordinator) - self._secondary_coordinators = secondary_coordinators + entry: ConfigEntry[HypercolorRuntimeData], + device: Device, + ) -> Any: ... + + +class HypercolorEntity(CoordinatorEntity[DataUpdateCoordinator[HypercolorSnapshot]]): + _availability_timer: CALLBACK_TYPE | None = None + + def __init__(self, entry: ConfigEntry[HypercolorRuntimeData]) -> None: + self._entry = entry + self._runtime = entry.runtime_data + super().__init__(self._runtime.coordinator) + + async def async_added_to_hass(self) -> None: + await super().async_added_to_hass() + self.async_on_remove(self._cancel_availability_timer) + self.async_on_remove( + self._runtime.connection_state.add_listener(self._availability_updated) + ) + self._availability_updated() + + @property + def snapshot(self) -> HypercolorSnapshot: + return self._runtime.snapshot + + @property + def available(self) -> bool: + unavailable_after_s = int( + self._entry.options.get( + CONF_UNAVAILABLE_AFTER_S, + OPTIONS_DEFAULTS[CONF_UNAVAILABLE_AFTER_S], + ) + ) + return self._runtime.connection_state.is_available(unavailable_after_s) + + @callback + def _availability_updated(self) -> None: + self._cancel_availability_timer() + unavailable_after_s = int( + self._entry.options.get( + CONF_UNAVAILABLE_AFTER_S, + OPTIONS_DEFAULTS[CONF_UNAVAILABLE_AFTER_S], + ) + ) + unavailable_in = self._runtime.connection_state.unavailable_in(unavailable_after_s) + if unavailable_in is not None and unavailable_in > 0: + self._availability_timer = async_call_later( + self.hass, + unavailable_in, + self._availability_expired, + ) + self.async_write_ha_state() + + @callback + def _availability_expired(self, *_: object) -> None: + self._availability_timer = None + self.async_write_ha_state() + + @callback + def _cancel_availability_timer(self) -> None: + if self._availability_timer is not None: + self._availability_timer() + self._availability_timer = None + + +class HypercolorWebsocketEntity(HypercolorEntity): + _connection_timer: CALLBACK_TYPE | None = None async def async_added_to_hass(self) -> None: await super().async_added_to_hass() - for coordinator in self._secondary_coordinators: - self.async_on_remove(coordinator.async_add_listener(self._handle_secondary_update)) + self.async_on_remove(self._cancel_connection_timer) + self.async_on_remove(self._runtime.connection_state.add_listener(self._connection_updated)) + self._connection_updated() @callback - def _handle_secondary_update(self) -> None: + def _connection_updated(self) -> None: + self._cancel_connection_timer() + grace_s = int( + self._entry.options.get( + CONF_DISCONNECT_GRACE_S, + DEFAULT_DISCONNECT_GRACE_S, + ) + ) + unavailable_in = self._runtime.connection_state.source_unavailable_in( + ConnectionSource.WEBSOCKET, + grace_s, + ) + if unavailable_in is not None and unavailable_in > 0: + self._connection_timer = async_call_later( + self.hass, + unavailable_in, + self._connection_grace_expired, + ) self.async_write_ha_state() + @callback + def _connection_grace_expired(self, *_: object) -> None: + self._connection_timer = None + self.async_write_ha_state() + + @callback + def _cancel_connection_timer(self) -> None: + if self._connection_timer is not None: + self._connection_timer() + self._connection_timer = None + + @property + def available(self) -> bool: + grace_s = int( + self._entry.options.get( + CONF_DISCONNECT_GRACE_S, + DEFAULT_DISCONNECT_GRACE_S, + ) + ) + return super().available and self._runtime.connection_state.is_source_connected( + ConnectionSource.WEBSOCKET, + grace_s, + ) + + +class HypercolorDeviceEntity(HypercolorEntity): + def __init__( + self, + entry: ConfigEntry[HypercolorRuntimeData], + device: Device, + ) -> None: + super().__init__(entry) + self._device_id = device.id + self._attr_device_info = child_device_info(self._runtime, device) + + @property + def available(self) -> bool: + return super().available and self._device is not None + + @property + def _device(self) -> Device | None: + return self.snapshot.device(self._device_id) + def add_configured_device_entities( entry: ConfigEntry[HypercolorRuntimeData], async_add_entities: AddEntitiesCallback, - factory: _DeviceEntityFactory, + factory: DeviceEntityFactory, ) -> None: - coordinator = entry.runtime_data.coordinators["devices"] + runtime = entry.runtime_data known_ids: set[str] = set() @callback @@ -50,17 +179,16 @@ def sync_entities() -> None: configured_ids = set(entry.options.get("per_device_entities", [])) fresh = [ device - for device in coordinator.data or [] - if (device_id := str(read_field(device, "id"))) in configured_ids - and device_id not in known_ids + for device in runtime.snapshot.devices + if device.id in configured_ids and device.id not in known_ids ] if not fresh: return - known_ids.update(str(read_field(device, "id")) for device in fresh) + known_ids.update(device.id for device in fresh) async_add_entities([factory(entry, device) for device in fresh]) sync_entities() - entry.async_on_unload(coordinator.async_add_listener(sync_entities)) + entry.async_on_unload(runtime.coordinator.async_add_listener(sync_entities)) def hub_device_info(runtime: HypercolorRuntimeData, entry_data: Mapping[str, Any]) -> DeviceInfo: @@ -74,15 +202,13 @@ def hub_device_info(runtime: HypercolorRuntimeData, entry_data: Mapping[str, Any ) -def child_device_info(runtime: HypercolorRuntimeData, device: Any) -> DeviceInfo: - device_id = str(read_field(device, "id")) - name = str(read_field(device, "name", device_id)) +def child_device_info(runtime: HypercolorRuntimeData, device: Device) -> DeviceInfo: return DeviceInfo( - identifiers={(DOMAIN, child_device_identifier(runtime, device_id))}, - name=name, - manufacturer=str(read_field(device, "vendor", "Hypercolor")), - model=str(read_field(device, "backend", read_field(device, "family", "LED device"))), - sw_version=read_field(device, "firmware_version"), + identifiers={(DOMAIN, child_device_identifier(runtime, device.id))}, + name=device.name, + manufacturer="Hypercolor", + model=device.backend, + sw_version=device.firmware_version, via_device=(DOMAIN, runtime.server.instance_id), ) @@ -91,49 +217,5 @@ def child_device_identifier(runtime: HypercolorRuntimeData, device_id: str) -> s return f"{runtime.server.instance_id}:device:{device_id}" -def catalog_items(catalog: Any, key: str) -> list[Any]: - if isinstance(catalog, Mapping): - value = catalog.get(key, []) - return list(value) if isinstance(value, list) else [] - if key == "effects" and isinstance(catalog, list): - return catalog - return [] - - -def option_map(items: list[Any]) -> dict[str, str]: - name_counts = Counter(item_name(item) for item in items) - return {item_option(item, name_counts): item_id(item) for item in items} - - -def item_option(item: Any, name_counts: Mapping[str, int]) -> str: - name = item_name(item) - return name if name_counts[name] == 1 else f"{name} ({item_id(item)})" - - -def item_id(item: Any) -> str: - return str(read_field(item, "id", read_field(item, "name"))) - - -def item_name(item: Any) -> str: - return str(read_field(item, "name", read_field(item, "id"))) - - -def read_field(value: Any, field: str, default: Any = None) -> Any: - if isinstance(value, dict): - return value.get(field, default) - return getattr(value, field, default) - - -def control_scalar(value: Any) -> Any: - """Unwrap a daemon control value to its scalar. - - The daemon serializes control values externally tagged, e.g. - ``{"float": 12.0}`` or ``{"enum": "Palette Blend"}``; older payloads - and the control patch path use bare scalars. Colors, gradients, and - rects stay as-is. - """ - if isinstance(value, dict) and len(value) == 1: - inner = next(iter(value.values())) - if isinstance(inner, (int, float, str, bool)): - return inner - return value +def device_slug(device_id: str) -> str: + return slugify(device_id).replace("__", "_") diff --git a/custom_components/hypercolor/light.py b/custom_components/hypercolor/light.py index 135107b..faac995 100644 --- a/custom_components/hypercolor/light.py +++ b/custom_components/hypercolor/light.py @@ -1,6 +1,5 @@ from __future__ import annotations -import asyncio from typing import Any from homeassistant.components.light import ( @@ -11,23 +10,20 @@ LightEntityFeature, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from hypercolor.models import ActiveEffect, ControlDefinition, Device, EffectSummary, Zone from .brightness import daemon_to_ha, ha_to_daemon from .entity import ( - MultiCoordinatorEntity, + HypercolorDeviceEntity, + HypercolorEntity, add_configured_device_entities, - catalog_items, - child_device_info, - control_scalar, hub_device_info, - item_id, - item_name, - read_field, ) +from .models import CatalogIndex, control_scalar from .runtime_data import HypercolorRuntimeData @@ -36,205 +32,171 @@ async def async_setup_entry( entry: ConfigEntry[HypercolorRuntimeData], async_add_entities: AddEntitiesCallback, ) -> None: + runtime = entry.runtime_data entities: list[LightEntity] = [HypercolorMasterLight(entry)] async_add_entities(entities) add_configured_device_entities(entry, async_add_entities, HypercolorDeviceLight) - state = entry.runtime_data.coordinators["state"] known_zone_ids: set[str] = set() + @callback def _sync_zone_entities() -> None: fresh = [ zone - for zone in renderable_zones(state.data) - if str(read_field(zone, "id")) not in known_zone_ids + for zone in runtime.snapshot.state.renderable_zones + if zone.id not in known_zone_ids ] if not fresh: return - known_zone_ids.update(str(read_field(zone, "id")) for zone in fresh) - async_add_entities( - HypercolorZoneLight(entry, str(read_field(zone, "id"))) for zone in fresh - ) + known_zone_ids.update(zone.id for zone in fresh) + async_add_entities(HypercolorZoneLight(entry, zone.id) for zone in fresh) _sync_zone_entities() - entry.async_on_unload(state.async_add_listener(_sync_zone_entities)) + entry.async_on_unload(runtime.coordinator.async_add_listener(_sync_zone_entities)) -class HypercolorMasterLight(MultiCoordinatorEntity, LightEntity): +class HypercolorMasterLight(HypercolorEntity, LightEntity): _attr_color_mode = ColorMode.BRIGHTNESS _attr_has_entity_name = True _attr_name = None _attr_supported_features = LightEntityFeature.EFFECT def __init__(self, entry: ConfigEntry[HypercolorRuntimeData]) -> None: + super().__init__(entry) runtime = entry.runtime_data - super().__init__(runtime.coordinators["state"], runtime.coordinators["catalog"]) - self._entry = entry - self._catalog = runtime.coordinators["catalog"] self._attr_device_info = hub_device_info(runtime, entry.data) self._attr_supported_color_modes = {ColorMode.BRIGHTNESS} self._attr_unique_id = f"{runtime.server.instance_id}:master" + self._last_effect_id, self._last_preset_id = self._running_effect_ref() + + @callback + def _handle_coordinator_update(self) -> None: + effect_id, preset_id = self._running_effect_ref() + if effect_id: + self._last_effect_id = effect_id + self._last_preset_id = preset_id + super()._handle_coordinator_update() + + def _running_effect_ref(self) -> tuple[str | None, str | None]: + state = self.snapshot.state + return state.active_effect_id, state.active_preset_id @property - def brightness(self) -> int | None: - value = read_field(self.coordinator.data, "global_brightness") - return daemon_to_ha(int(value)) if value is not None else None + def brightness(self) -> int: + return daemon_to_ha(self.snapshot.state.status.global_brightness) @property def effect(self) -> str | None: - value = read_field(self.coordinator.data, "active_effect") - return str(value) if value else None + state = self.snapshot.state + return ( + self.snapshot.catalog.effects.label(state.active_effect_id) or state.active_effect_name + ) @property - def effect_list(self) -> list[str] | None: - return effect_names(self._catalog.data) + def effect_list(self) -> list[str]: + return self.snapshot.catalog.effects.options @property def extra_state_attributes(self) -> dict[str, Any]: - state = self.coordinator.data - cover_image_url = read_field(state, "active_effect_cover_image_url") - active_id = read_field(state, "active_effect_id") - active_detail = read_field(state, "active_effect_detail") - catalog_entry = active_effect_entry( - self._catalog.data, - active_id, - read_field(state, "active_effect"), - ) - metadata = effect_metadata(catalog_entry, active_detail) + state = self.snapshot.state + summary = self.snapshot.active_effect_summary + cover_image_url = state.active_effect_cover_image_url return { - "active_effect": self.effect, - "active_effect_id": active_id, - "active_preset_id": read_field(state, "active_preset"), - "active_preset_modified": bool(read_field(state, "active_preset_modified", False)), + "active_effect": state.active_effect_name, + "active_effect_id": state.active_effect_id, + "active_preset_id": state.active_preset_id, + "active_preset_modified": state.active_preset_modified, "active_effect_cover_image_url": cover_image_url, - "device_count": read_field(state, "device_count"), - # `effect_image` mirrors the SignalRGB attribute the card reads for - # its palette/background source; keep it aliased to the cover URL. + "device_count": state.status.device_count, "effect_image": cover_image_url, - "scene_count": read_field(state, "scene_count"), - "active_scene": read_field(state, "active_scene_name"), - "active_scene_id": read_field(state, "active_scene"), - "zone_count": len(renderable_zones(state)), - # Card-facing effect metadata sourced from the catalog + running - # effect. These are the attributes hyper-light-card renders in the - # effect-info panel and the generic control surface. - "effect_description": metadata["description"], - "effect_publisher": metadata["publisher"], - "effect_audio_reactive": metadata["audio_reactive"], - "effect_tags": metadata["tags"], - "effect_category": metadata["category"], - "effect_version": metadata["version"], - "effect_controls": effect_controls_payload(active_detail), + "scene_count": state.status.scene_count, + "active_scene": state.active_scene.name if state.active_scene is not None else None, + "active_scene_id": state.active_scene.id if state.active_scene is not None else None, + "zone_count": len(state.renderable_zones), + **effect_metadata(summary), + "effect_controls": effect_controls_payload(state.active_effect), } @property - def is_on(self) -> bool | None: - if self.effect is None: - return False - return read_field(self.coordinator.data, "active_effect_state") != "paused" + def is_on(self) -> bool: + state = self.snapshot.state + return state.active_effect_id is not None and not state.paused async def async_turn_on(self, **kwargs: Any) -> None: - client = self._entry.runtime_data.client - was_paused = read_field(self.coordinator.data, "active_effect_state") == "paused" - effect_changed = False - if ATTR_BRIGHTNESS in kwargs: - await client.set_brightness(ha_to_daemon(int(kwargs[ATTR_BRIGHTNESS]))) - - effect = kwargs.get(ATTR_EFFECT) - if effect: - await client.apply_effect(effect_id_for_name(self._catalog.data, str(effect))) - effect_changed = True - elif was_paused: - result = await client.resume_rendering() - self._set_output_state(read_field(result, "state", "running")) - return - elif self.effect is None and (effect_id := first_effect_id(self._catalog.data)): - await client.apply_effect(effect_id) - effect_changed = True - - if effect_changed: - await asyncio.gather( - self.coordinator.async_refresh(), - self._catalog.async_refresh(), - ) - else: - await self.coordinator.async_request_refresh() + async def operation() -> None: + client = self._runtime.client + if ATTR_BRIGHTNESS in kwargs: + await client.set_brightness(ha_to_daemon(int(kwargs[ATTR_BRIGHTNESS]))) + + effect = kwargs.get(ATTR_EFFECT) + if effect: + await client.apply_effect(self.snapshot.catalog.effects.resolve(str(effect))) + elif self.snapshot.state.paused: + await client.resume_rendering() + elif not self.is_on and ( + resume := self._last_effect_id or first_id(self.snapshot.catalog.effects) + ): + preset = self._last_preset_id if resume == self._last_effect_id else None + if preset is not None: + await client.apply_effect_preset(resume, preset) + else: + await client.apply_effect(resume) + + await self._runtime.async_mutate(operation) async def async_turn_off(self, **kwargs: Any) -> None: - result = await self._entry.runtime_data.client.pause_rendering() - self._set_output_state(read_field(result, "state", "paused")) + await self._runtime.async_mutate(self._runtime.client.pause_rendering) - def _set_output_state(self, state: Any) -> None: - current = dict(self.coordinator.data or {}) - current["active_effect_state"] = str(state) - self.coordinator.async_set_updated_data(current) - -class HypercolorDeviceLight(CoordinatorEntity, LightEntity): +class HypercolorDeviceLight(HypercolorDeviceEntity, LightEntity): _attr_color_mode = ColorMode.BRIGHTNESS _attr_has_entity_name = True _attr_name = None - def __init__(self, entry: ConfigEntry[HypercolorRuntimeData], device: Any) -> None: + def __init__(self, entry: ConfigEntry[HypercolorRuntimeData], device: Device) -> None: + super().__init__(entry, device) runtime = entry.runtime_data - super().__init__(runtime.coordinators["devices"]) - self._entry = entry - self._device_id = str(read_field(device, "id")) - self._attr_device_info = child_device_info(runtime, device) self._attr_supported_color_modes = {ColorMode.BRIGHTNESS} self._attr_unique_id = f"{runtime.server.instance_id}:device:{self._device_id}:light" @property def brightness(self) -> int | None: - if device := self._device: - value = read_field(device, "brightness") - return daemon_to_ha(int(value)) if value is not None else None - return None + return daemon_to_ha(device.brightness) if (device := self._device) is not None else None @property def is_on(self) -> bool | None: - if device := self._device: - return ( - bool(read_field(device, "enabled", True)) and read_field(device, "status") != "off" - ) - return None + return device.enabled and device.status != "off" if (device := self._device) else None async def async_turn_on(self, **kwargs: Any) -> None: - fields: dict[str, Any] = {"enabled": True} - if ATTR_BRIGHTNESS in kwargs: - fields["brightness"] = ha_to_daemon(int(kwargs[ATTR_BRIGHTNESS])) - await self._entry.runtime_data.client.update_device(self._device_id, **fields) - await self.coordinator.async_request_refresh() + brightness = ( + ha_to_daemon(int(kwargs[ATTR_BRIGHTNESS])) if ATTR_BRIGHTNESS in kwargs else None + ) - async def async_turn_off(self, **kwargs: Any) -> None: - await self._entry.runtime_data.client.update_device(self._device_id, enabled=False) - await self.coordinator.async_request_refresh() + async def operation() -> None: + await self._runtime.client.update_device( + self._device_id, + enabled=True, + brightness=brightness, + ) - @property - def _device(self) -> Any | None: - for device in self.coordinator.data or []: - if str(read_field(device, "id")) == self._device_id: - return device - return None + await self._runtime.async_mutate(operation) + async def async_turn_off(self, **kwargs: Any) -> None: + async def operation() -> None: + await self._runtime.client.update_device(self._device_id, enabled=False) -class HypercolorZoneLight(MultiCoordinatorEntity, LightEntity): - """One zone (render group) of the active scene. + await self._runtime.async_mutate(operation) - Zones are scene-scoped: when the active scene changes, entities for - zones that no longer exist go unavailable, and new zones appear. - """ +class HypercolorZoneLight(HypercolorEntity, LightEntity): _attr_color_mode = ColorMode.BRIGHTNESS _attr_has_entity_name = True _attr_supported_features = LightEntityFeature.EFFECT def __init__(self, entry: ConfigEntry[HypercolorRuntimeData], zone_id: str) -> None: + super().__init__(entry) runtime = entry.runtime_data - super().__init__(runtime.coordinators["state"], runtime.coordinators["catalog"]) - self._entry = entry self._zone_id = zone_id - self._catalog = runtime.coordinators["catalog"] self._attr_device_info = hub_device_info(runtime, entry.data) self._attr_supported_color_modes = {ColorMode.BRIGHTNESS} self._attr_unique_id = f"{runtime.server.instance_id}:zone:{zone_id}" @@ -244,265 +206,140 @@ def available(self) -> bool: return super().available and self._zone is not None @property - def name(self) -> str | None: - if zone := self._zone: - return str(read_field(zone, "name", self._zone_id)) - return f"Zone {self._zone_id}" + def name(self) -> str: + return zone.name if (zone := self._zone) is not None else f"Zone {self._zone_id}" @property def brightness(self) -> int | None: - if zone := self._zone: - value = read_field(zone, "brightness") - if value is not None: - return max(0, min(255, round(float(value) * 255))) - return None + return _zone_to_ha(zone.brightness) if (zone := self._zone) is not None else None @property def is_on(self) -> bool | None: - if zone := self._zone: - return bool(read_field(zone, "enabled", True)) - return None + return zone.enabled if (zone := self._zone) is not None else None @property def effect(self) -> str | None: zone = self._zone - if zone is None: - return None - effect_id = read_field(zone, "effect_id") - if not effect_id: + if zone is None or zone.effect_id is None: return None - return effect_name_for_id(self._catalog.data, str(effect_id)) + return self.snapshot.catalog.effects.label(zone.effect_id) or zone.effect_id @property - def effect_list(self) -> list[str] | None: - return effect_names(self._catalog.data) + def effect_list(self) -> list[str]: + return self.snapshot.catalog.effects.options @property def extra_state_attributes(self) -> dict[str, Any]: zone = self._zone - layout = read_field(zone, "layout") - outputs = read_field(layout, "zones", []) or [] + scene = self.snapshot.state.active_scene return { "zone_id": self._zone_id, - "role": read_field(zone, "role"), - "effect_id": read_field(zone, "effect_id"), - "preset_id": read_field(zone, "preset_id"), - "output_count": len(outputs) if isinstance(outputs, list) else None, - "scene_id": read_field(self.coordinator.data, "active_scene"), + "role": zone.role if zone is not None else None, + "effect_id": zone.effect_id if zone is not None else None, + "preset_id": zone.preset_id if zone is not None else None, + "output_count": len(zone.layout.zones) if zone is not None else None, + "scene_id": scene.id if scene is not None else None, } async def async_turn_on(self, **kwargs: Any) -> None: - client = self._entry.runtime_data.client - scene_id = self._scene_id() - updates: dict[str, Any] = {} - if ATTR_BRIGHTNESS in kwargs: - updates["brightness"] = round(int(kwargs[ATTR_BRIGHTNESS]) / 255, 4) - if not self.is_on: - updates["enabled"] = True - if updates: - await client.update_zone(scene_id, self._zone_id, **updates) - + brightness = ( + round(int(kwargs[ATTR_BRIGHTNESS]) / 255, 4) if ATTR_BRIGHTNESS in kwargs else None + ) effect = kwargs.get(ATTR_EFFECT) - if effect: - await client.apply_effect( - effect_id_for_name(self._catalog.data, str(effect)), - render_group=self._zone_id, - ) - await self.coordinator.async_request_refresh() + + async def operation() -> None: + client = self._runtime.client + if brightness is not None or not self.is_on: + await client.update_zone( + self._scene_id(), + self._zone_id, + brightness=brightness, + enabled=True if not self.is_on else None, + ) + if effect: + await client.apply_effect( + self.snapshot.catalog.effects.resolve(str(effect)), + render_group=self._zone_id, + ) + + await self._runtime.async_mutate(operation) async def async_turn_off(self, **kwargs: Any) -> None: - client = self._entry.runtime_data.client - await client.update_zone(self._scene_id(), self._zone_id, enabled=False) - await self.coordinator.async_request_refresh() + async def operation() -> None: + await self._runtime.client.update_zone( + self._scene_id(), + self._zone_id, + enabled=False, + ) + + await self._runtime.async_mutate(operation) def _scene_id(self) -> str: - scene_id = read_field(self.coordinator.data, "active_scene") - if not scene_id: + scene = self.snapshot.state.active_scene + if scene is None: raise HomeAssistantError("No active Hypercolor scene") - return str(scene_id) + return scene.id @property - def _zone(self) -> Any | None: - for zone in renderable_zones(self.coordinator.data): - if str(read_field(zone, "id")) == self._zone_id: - return zone - return None + def _zone(self) -> Zone | None: + return self.snapshot.state.zone(self._zone_id) -def renderable_zones(state: Any) -> list[Any]: - """Zones of the active scene that render to LEDs (not display faces).""" - zones = read_field(state, "zones", []) or [] - if not isinstance(zones, list): - return [] - return [zone for zone in zones if read_field(zone, "role") != "display"] - - -def active_effect_entry(catalog: Any, active_id: Any, active_name: Any) -> Any | None: - """The catalog record for the running effect, matched by id then name.""" - effects = _catalog_effects(catalog) - if not effects: - return None - if active_id is not None: - for effect in effects: - if item_id(effect) == str(active_id): - return effect - if active_name: - for effect in effects: - if item_name(effect) == str(active_name): - return effect - return None - - -def effect_metadata(catalog_entry: Any, active_detail: Any) -> dict[str, Any]: - """Card-facing metadata for the running effect. - - Description/author/tags/category/version come from the catalog record; - audio-reactivity prefers the live effect detail and falls back to the - catalog flag so the card lights up the audio badge even before the first - state push carries an explicit value. - """ - audio_reactive = read_field(active_detail, "audio_reactive") - if audio_reactive is None: - audio_reactive = read_field(catalog_entry, "audio_reactive", False) - tags = read_field(catalog_entry, "tags", []) or [] +def effect_metadata(effect: EffectSummary | None) -> dict[str, Any]: return { - "description": read_field(catalog_entry, "description"), - "publisher": read_field(catalog_entry, "author", read_field(catalog_entry, "publisher")), - "audio_reactive": bool(audio_reactive), - "tags": [str(tag) for tag in tags] if isinstance(tags, list) else [], - "category": read_field(catalog_entry, "category"), - "version": read_field(catalog_entry, "version"), + "effect_description": effect.description if effect is not None else None, + "effect_publisher": effect.author if effect is not None else None, + "effect_audio_reactive": effect.audio_reactive if effect is not None else False, + "effect_tags": list(effect.tags) if effect is not None else [], + "effect_category": effect.category if effect is not None else None, + "effect_version": effect.version if effect is not None else None, } -def effect_controls_payload(active_detail: Any) -> list[dict[str, Any]]: - """Normalize the running effect's controls for the card. - - Each entry is a flat, JSON-serializable descriptor the card renders as a - slider/toggle/select/color without needing per-effect number entities. - Current values prefer the live ``control_values`` map, falling back to the - control's own default. - """ - controls = read_field(active_detail, "controls", []) or [] - if not isinstance(controls, list): +def effect_controls_payload(active_effect: ActiveEffect | None) -> list[dict[str, Any]]: + if active_effect is None: return [] - values = read_field(active_detail, "control_values", {}) or {} - payload: list[dict[str, Any]] = [] - for control in controls: - control_id = read_field(control, "id") - if control_id is None: - continue - value = control_scalar(read_field(values, control_id)) - if value is None: - value = control_scalar(read_field(control, "value", read_field(control, "default"))) - if value is None: - value = control_scalar(read_field(control, "default_value")) - descriptor: dict[str, Any] = { - "id": str(control_id), - "label": str(read_field(control, "name", read_field(control, "label", control_id))), - # Canonical widget kind the card renders directly (number/boolean/ - # enum/color/other). The daemon/client name the widget under - # `type`, `control_type`, or `kind` depending on the payload path; - # collapse them all to one vocabulary here so the card never guesses. - "kind": _canonical_control_kind(control), - "min": read_field(control, "min", read_field(control, "min_")), - "max": read_field(control, "max", read_field(control, "max_")), - "step": read_field(control, "step"), - "value": value, - } - options = _control_options(control) - if options is not None: - descriptor["options"] = options - payload.append(descriptor) + return [ + _control_payload(control, active_effect.control_values.get(control.id)) + for control in active_effect.controls + ] + + +def _control_payload(control: ControlDefinition, live_value: Any) -> dict[str, Any]: + value = control_scalar(live_value) + if value is None: + value = control_scalar(control.value) + if value is None: + value = control_scalar(control.default) + payload = { + "id": control.id, + "label": control.label, + "kind": _canonical_control_kind(control), + "min": control.min, + "max": control.max, + "step": control.step, + "value": value, + } + if control.options is not None: + payload["options"] = list(control.options) return payload -_BOOLEAN_KINDS = frozenset({"boolean", "bool", "toggle", "switch", "checkbox"}) -_COLOR_KINDS = frozenset({"color", "color_picker", "colorpicker", "rgb", "rgba"}) -_ENUM_KINDS = frozenset({"enum", "select", "dropdown", "combobox", "choice", "variant"}) -_NUMBER_KINDS = frozenset({"number", "slider", "float", "int", "integer", "range"}) - - -def _canonical_control_kind(control: Any) -> str: - """Collapse the daemon's widget vocabulary to a card-renderable kind. - - Returns one of ``number``/``boolean``/``enum``/``color`` for controls the - card can faithfully render and round-trip, or ``other`` for controls it has - no safe widget for (text/gradient/rect/asset) so the card skips them rather - than mis-rendering them as sliders that corrupt state on interaction. - """ - token = str( - read_field( - control, - "type", - read_field(control, "control_type", read_field(control, "kind", "")), - ) - or "" - ).lower() - if token in _BOOLEAN_KINDS: +def _canonical_control_kind(control: ControlDefinition) -> str: + if control.type in {"boolean", "bool", "toggle", "switch", "checkbox"}: return "boolean" - if token in _COLOR_KINDS: + if control.type in {"color", "color_picker", "colorpicker", "rgb", "rgba"}: return "color" - if token in _ENUM_KINDS: + if control.type in {"enum", "select", "dropdown", "combobox", "choice", "variant"}: return "enum" - if token in _NUMBER_KINDS: + if control.type in {"number", "slider", "float", "int", "integer", "range"}: return "number" - # No recognized widget token; a choice list still implies a selector. - if _control_options(control): - return "enum" - return "other" - - -def _control_options(control: Any) -> list[str] | None: - for key in ("options", "labels", "variants", "choices"): - raw = read_field(control, key) - if isinstance(raw, list) and raw: - return [_option_label(item) for item in raw] - return None - - -def _option_label(item: Any) -> str: - if isinstance(item, dict): - for key in ("label", "name", "id", "value"): - if (value := item.get(key)) is not None: - return str(value) - return str(item) - - -def effect_names(catalog: Any) -> list[str] | None: - effects = _catalog_effects(catalog) - if effects is None: - return None - return [item_name(effect) for effect in effects] - - -def effect_id_for_name(catalog: Any, name: str) -> str: - effects = _catalog_effects(catalog) - if effects is None: - return name - for effect in effects: - if item_name(effect) == name: - return item_id(effect) - return name - - -def first_effect_id(catalog: Any) -> str | None: - effects = _catalog_effects(catalog) - if not effects: - return None - return item_id(effects[0]) + return "enum" if control.options else "other" -def effect_name_for_id(catalog: Any, effect_id: str) -> str: - effects = _catalog_effects(catalog) - if effects is None: - return effect_id - for effect in effects: - if item_id(effect) == effect_id: - return item_name(effect) - return effect_id +def first_id(index: CatalogIndex[EffectSummary]) -> str | None: + return index.items[0].id if index.items else None -def _catalog_effects(catalog: Any) -> list[Any] | None: - effects = catalog_items(catalog, "effects") - return effects or None +def _zone_to_ha(brightness: float) -> int: + return max(0, min(255, round(brightness * 255))) diff --git a/custom_components/hypercolor/models.py b/custom_components/hypercolor/models.py new file mode 100644 index 0000000..945ccbf --- /dev/null +++ b/custom_components/hypercolor/models.py @@ -0,0 +1,231 @@ +from __future__ import annotations + +from collections import Counter +from collections.abc import Callable, Iterable, Mapping +from dataclasses import dataclass, field, replace +from types import MappingProxyType +from typing import Any, Protocol, Self + +from hypercolor.models import ( + ActiveEffect, + ActiveScene, + AudioDevices, + Device, + EffectPreset, + EffectPresetOrigin, + EffectSummary, + JsonObject, + Layout, + LayoutSummary, + ProfileSummary, + Scene, + SystemState, + Zone, +) +from hypercolor.websocket import SpectrumData + + +class NamedCatalogItem(Protocol): + id: str + name: str + + +@dataclass(frozen=True, slots=True) +class CatalogIndex[CatalogItemT: NamedCatalogItem]: + items: tuple[CatalogItemT, ...] + by_id: Mapping[str, CatalogItemT] + label_by_id: Mapping[str, str] + id_by_label: Mapping[str, str] + + @classmethod + def build( + cls, + items: Iterable[CatalogItemT], + *, + collision_label: Callable[[CatalogItemT], str] | None = None, + ) -> Self: + catalog_items = tuple(items) + name_counts = Counter(item.name for item in catalog_items) + by_id = {item.id: item for item in catalog_items} + candidates = { + item.id: ( + item.name + if name_counts[item.name] == 1 + else collision_label(item) + if collision_label is not None + else f"{item.name} ({item.id})" + ) + for item in catalog_items + } + candidate_counts = Counter(candidates.values()) + label_by_id = { + item.id: ( + candidates[item.id] + if candidate_counts[candidates[item.id]] == 1 + else f"{candidates[item.id]} [{item.id[:8]}]" + ) + for item in catalog_items + } + id_by_label = {label: item_id for item_id, label in label_by_id.items()} + return cls( + items=catalog_items, + by_id=MappingProxyType(by_id), + label_by_id=MappingProxyType(label_by_id), + id_by_label=MappingProxyType(id_by_label), + ) + + @property + def options(self) -> list[str]: + return [self.label_by_id[item.id] for item in self.items] + + def label(self, item_id: str | None) -> str | None: + return self.label_by_id.get(item_id) if item_id is not None else None + + def resolve(self, label_or_id: str) -> str: + return self.id_by_label.get(label_or_id, label_or_id) + + +@dataclass(frozen=True, slots=True) +class HypercolorState: + status: SystemState + active_effect: ActiveEffect | None + active_scene: ActiveScene | None + active_layout: Layout | None + active_effect_cover_image_url: str | None + + @property + def active_effect_id(self) -> str | None: + return self.active_effect.id if self.active_effect is not None else None + + @property + def active_effect_name(self) -> str | None: + if self.active_effect is not None: + return self.active_effect.name + return self.status.active_effect + + @property + def active_preset_id(self) -> str | None: + if self.active_effect is None: + return None + return self.active_effect.active_preset_id + + @property + def active_preset_modified(self) -> bool: + return ( + self.active_effect.active_preset_modified if self.active_effect is not None else False + ) + + @property + def paused(self) -> bool: + return self.status.paused + + @property + def zones(self) -> tuple[Zone, ...]: + if self.active_scene is None: + return () + return tuple(self.active_scene.groups) + + @property + def renderable_zones(self) -> tuple[Zone, ...]: + return tuple(zone for zone in self.zones if not zone.is_display) + + def zone(self, zone_id: str) -> Zone | None: + return next((zone for zone in self.renderable_zones if zone.id == zone_id), None) + + +@dataclass(frozen=True, slots=True) +class HypercolorCatalog: + effects: CatalogIndex[EffectSummary] + scenes: CatalogIndex[Scene] + profiles: CatalogIndex[ProfileSummary] + layouts: CatalogIndex[LayoutSummary] + preset_effect_id: str | None + presets: CatalogIndex[EffectPreset] + + @classmethod + def build( + cls, + *, + effects: Iterable[EffectSummary], + scenes: Iterable[Scene], + profiles: Iterable[ProfileSummary], + layouts: Iterable[LayoutSummary], + preset_effect_id: str | None, + presets: Iterable[EffectPreset], + ) -> Self: + return cls( + effects=CatalogIndex.build(effects), + scenes=CatalogIndex.build(scenes), + profiles=CatalogIndex.build(profiles), + layouts=CatalogIndex.build(layouts), + preset_effect_id=preset_effect_id, + presets=CatalogIndex.build( + presets, + collision_label=_preset_collision_label, + ), + ) + + +@dataclass(frozen=True, slots=True) +class HypercolorAudio: + devices: AudioDevices | None = None + spectrum: SpectrumData | None = None + beat_until: float | None = None + + +@dataclass(frozen=True, slots=True) +class HypercolorSnapshot: + state: HypercolorState + catalog: HypercolorCatalog + devices: tuple[Device, ...] + metrics: JsonObject = field(default_factory=dict) + audio: HypercolorAudio = field(default_factory=HypercolorAudio) + + @property + def active_effect_summary(self) -> EffectSummary | None: + active_id = self.state.active_effect_id + return self.catalog.effects.by_id.get(active_id) if active_id is not None else None + + @property + def active_effect_audio_reactive(self) -> bool: + effect = self.active_effect_summary + return effect.audio_reactive if effect is not None else False + + @property + def active_effect_presets(self) -> CatalogIndex[EffectPreset]: + if self.catalog.preset_effect_id != self.state.active_effect_id: + return CatalogIndex.build(()) + return self.catalog.presets + + def device(self, device_id: str) -> Device | None: + return next((device for device in self.devices if device.id == device_id), None) + + def with_metrics(self, metrics: JsonObject) -> Self: + return replace(self, metrics=metrics) + + def with_spectrum(self, spectrum: SpectrumData, beat_until: float | None) -> Self: + return replace(self, audio=replace(self.audio, spectrum=spectrum, beat_until=beat_until)) + + def with_push_telemetry(self, current: Self) -> Self: + return replace( + self, + metrics=current.metrics, + audio=replace( + self.audio, + spectrum=current.audio.spectrum, + beat_until=current.audio.beat_until, + ), + ) + + +def control_scalar(value: Any) -> Any: + if isinstance(value, dict) and len(value) == 1: + inner = next(iter(value.values())) + if isinstance(inner, (int, float, str, bool)): + return inner + return value + + +def _preset_collision_label(preset: EffectPreset) -> str: + origin = "Built-in" if preset.origin is EffectPresetOrigin.BUNDLED else "Saved" + return f"{preset.name} ({origin})" diff --git a/custom_components/hypercolor/number.py b/custom_components/hypercolor/number.py index f4801ca..b1c5ca1 100644 --- a/custom_components/hypercolor/number.py +++ b/custom_components/hypercolor/number.py @@ -1,16 +1,17 @@ from __future__ import annotations import re -from typing import Any from homeassistant.components.number import NumberEntity, NumberMode from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from hypercolor.models import ControlDefinition from .const import CONF_LIVE_CONTROLS_ENABLED, LIVE_CONTROL_IDS, OPTIONS_DEFAULTS -from .entity import control_scalar, hub_device_info, read_field +from .entity import HypercolorEntity, hub_device_info +from .models import control_scalar from .runtime_data import HypercolorRuntimeData _DEFAULTS = { @@ -36,14 +37,13 @@ async def async_setup_entry( ) -class HypercolorLiveControlNumber(CoordinatorEntity, NumberEntity): +class HypercolorLiveControlNumber(HypercolorEntity, NumberEntity): _attr_has_entity_name = True _attr_mode = NumberMode.SLIDER def __init__(self, entry: ConfigEntry[HypercolorRuntimeData], control_id: str) -> None: + super().__init__(entry) runtime = entry.runtime_data - super().__init__(runtime.coordinators["state"]) - self._entry = entry self._control_id = control_id self._attr_name = control_id.replace("_", " ").title() self._attr_device_info = hub_device_info(runtime, entry.data) @@ -56,59 +56,58 @@ def available(self) -> bool: @property def native_min_value(self) -> float: control = self._control - if control is not None and (value := read_field(control, "min")) is not None: - return float(value) - return _DEFAULTS[self._control_id][0] + return control.min if control is not None and control.min is not None else self._default(0) @property def native_max_value(self) -> float: control = self._control - if control is not None and (value := read_field(control, "max")) is not None: - return float(value) - return _DEFAULTS[self._control_id][1] + return control.max if control is not None and control.max is not None else self._default(1) @property def native_step(self) -> float: control = self._control - if control is not None and (value := read_field(control, "step")) is not None: - return float(value) - return _DEFAULTS[self._control_id][2] + return ( + control.step if control is not None and control.step is not None else self._default(2) + ) @property def native_value(self) -> float | None: control = self._control - if control is None: + active_effect = self.snapshot.state.active_effect + if control is None or active_effect is None: return None - active = read_field(self.coordinator.data, "active_effect_detail") - values = read_field(active, "control_values", {}) - value = control_scalar(read_field(values, read_field(control, "id"))) + value = control_scalar(active_effect.control_values.get(control.id)) if value is None: - value = control_scalar(read_field(control, "value", read_field(control, "default"))) + value = control_scalar(control.value) if value is None: - value = control_scalar(read_field(control, "default_value")) + value = control_scalar(control.default) return float(value) if isinstance(value, (int, float)) else None async def async_set_native_value(self, value: float) -> None: control = self._control if control is None: return - await self._entry.runtime_data.client.update_controls( - {str(read_field(control, "id")): value} + await self._runtime.async_mutate( + lambda: self._runtime.client.update_controls({control.id: value}) ) - await self.coordinator.async_request_refresh() @property - def _control(self) -> Any | None: - active = read_field(self.coordinator.data, "active_effect_detail") - for control in read_field(active, "controls", []) or []: - names = { - _normalize(str(read_field(control, "id", ""))), - _normalize(str(read_field(control, "label", ""))), - _normalize(str(read_field(control, "name", ""))), - } - if _normalize(self._control_id) in names: - return control - return None + def _control(self) -> ControlDefinition | None: + active_effect = self.snapshot.state.active_effect + if active_effect is None: + return None + expected = _normalize(self._control_id) + return next( + ( + control + for control in active_effect.controls + if expected in {_normalize(control.id), _normalize(control.label)} + ), + None, + ) + + def _default(self, index: int) -> float: + return _DEFAULTS[self._control_id][index] def _normalize(value: str) -> str: diff --git a/custom_components/hypercolor/runtime_data.py b/custom_components/hypercolor/runtime_data.py index 5171fb0..8addcd0 100644 --- a/custom_components/hypercolor/runtime_data.py +++ b/custom_components/hypercolor/runtime_data.py @@ -1,57 +1,150 @@ from __future__ import annotations import asyncio -from collections.abc import Callable +import contextlib +from collections.abc import Awaitable, Callable, Iterable from dataclasses import dataclass, field from datetime import UTC, datetime -from typing import Any +from enum import StrEnum +from typing import TYPE_CHECKING, Any, TypeVar -from homeassistant.core import CALLBACK_TYPE, callback +from hypercolor import HypercolorClient, HypercolorNotFoundError from .api import ServerInfo +from .models import HypercolorSnapshot + +if TYPE_CHECKING: + from .coordinator import HypercolorCoordinator + +ResultT = TypeVar("ResultT") + +_NO_ACTIVE_EFFECT = "No effect is currently active" + + +class ConnectionSource(StrEnum): + SNAPSHOT = "snapshot" + WEBSOCKET = "websocket" @dataclass(slots=True) -class ConnectionState: +class SourceHealth: connected: bool = False last_connected_at: datetime | None = None last_disconnected_at: datetime | None = None last_error: str | None = None + + +@dataclass(slots=True) +class ConnectionState: + sources: dict[ConnectionSource, SourceHealth] = field( + default_factory=lambda: {source: SourceHealth() for source in ConnectionSource} + ) _listeners: set[Callable[[], None]] = field(default_factory=set) - def set_connected(self) -> bool: - changed = not self.connected or self.last_error is not None - self.connected = True - if changed: - self.last_connected_at = datetime.now(UTC) - self.last_error = None - if changed: - self._notify() - return changed - - def set_disconnected(self, error: BaseException | None = None) -> bool: - message = str(error) if error else None - changed = self.connected or self.last_disconnected_at is None - self.connected = False - if changed: - self.last_disconnected_at = datetime.now(UTC) - self.last_error = message - if changed: - self._notify() - return changed - - @callback - def async_add_listener(self, listener: Callable[[], None]) -> CALLBACK_TYPE: - self._listeners.add(listener) + @property + def connected(self) -> bool: + return any(source.connected for source in self.sources.values()) + + @property + def last_connected_at(self) -> datetime | None: + return _latest(source.last_connected_at for source in self.sources.values()) + + @property + def last_disconnected_at(self) -> datetime | None: + return _latest(source.last_disconnected_at for source in self.sources.values()) + + @property + def last_error(self) -> str | None: + errors = [ + (source.last_disconnected_at, source.last_error) + for source in self.sources.values() + if source.last_error is not None + ] + if not errors: + return None + return max(errors, key=lambda item: item[0] or datetime.min.replace(tzinfo=UTC))[1] - @callback - def remove_listener() -> None: - self._listeners.discard(listener) + def set_connected(self, source: ConnectionSource) -> bool: + state = self.sources[source] + if state.connected: + return False + state.connected = True + state.last_connected_at = datetime.now(UTC) + state.last_error = None + self._notify_listeners() + return True - return remove_listener + def set_disconnected( + self, + source: ConnectionSource, + error: BaseException | None = None, + ) -> bool: + state = self.sources[source] + error_text = str(error) if error else None + outage_started = state.connected or state.last_disconnected_at is None + error_changed = state.last_error != error_text + if not outage_started and not error_changed: + return False + state.connected = False + if outage_started: + state.last_disconnected_at = datetime.now(UTC) + state.last_error = error_text + self._notify_listeners() + return True - @callback - def _notify(self) -> None: + def is_connected(self, grace_s: int = 0) -> bool: + if self.connected: + return True + if self.last_connected_at is None: + return False + disconnected_at = self.last_disconnected_at + if disconnected_at is None: + return False + return (datetime.now(UTC) - disconnected_at).total_seconds() < grace_s + + def is_source_connected(self, source: ConnectionSource, grace_s: int = 0) -> bool: + unavailable_in = self.source_unavailable_in(source, grace_s) + return unavailable_in is None or unavailable_in > 0 + + def source_unavailable_in( + self, + source: ConnectionSource, + grace_s: int, + ) -> float | None: + health = self.sources[source] + if health.connected: + return None + if health.last_connected_at is None: + return 0 + if health.last_disconnected_at is None: + return 0 + outage_age_s = (datetime.now(UTC) - health.last_disconnected_at).total_seconds() + return max(grace_s - outage_age_s, 0) + + def is_available(self, unavailable_after_s: int) -> bool: + unavailable_in = self.unavailable_in(unavailable_after_s) + return unavailable_in is None or unavailable_in > 0 + + def unavailable_in(self, unavailable_after_s: int) -> float | None: + snapshot = self.sources[ConnectionSource.SNAPSHOT] + if not snapshot.connected and snapshot.last_connected_at is None: + return 0 + now = datetime.now(UTC) + deadlines = [ + max( + unavailable_after_s - (now - state.last_disconnected_at).total_seconds(), + 0, + ) + for state in self.sources.values() + if not state.connected and state.last_disconnected_at is not None + ] + return min(deadlines, default=None) + + def add_listener(self, listener: Callable[[], None]) -> Callable[[], None]: + self._listeners.add(listener) + return lambda: self._listeners.discard(listener) + + def _notify_listeners(self) -> None: for listener in tuple(self._listeners): listener() @@ -61,15 +154,54 @@ def snapshot(self) -> dict[str, Any]: "last_connected_at": self.last_connected_at, "last_disconnected_at": self.last_disconnected_at, "last_error": self.last_error, + "sources": { + source.value: { + "connected": state.connected, + "last_connected_at": state.last_connected_at, + "last_disconnected_at": state.last_disconnected_at, + "last_error": state.last_error, + } + for source, state in self.sources.items() + }, } @dataclass(slots=True) class HypercolorRuntimeData: - client: Any + client: HypercolorClient server: ServerInfo - coordinators: dict[str, Any] = field(default_factory=dict) + coordinator: HypercolorCoordinator connection_state: ConnectionState = field(default_factory=ConnectionState) + per_device_entity_ids: set[str] = field(default_factory=set) ws_task: asyncio.Task[None] | None = None reconcile_task: asyncio.Task[None] | None = None - unavailable_task: asyncio.Task[None] | None = None + refresh_tasks: set[asyncio.Task[None]] = field(default_factory=set) + + @property + def snapshot(self) -> HypercolorSnapshot: + return self.coordinator.data + + async def async_mutate(self, operation: Callable[[], Awaitable[ResultT]]) -> ResultT: + try: + result = await operation() + except Exception: + with contextlib.suppress(Exception): + await self.coordinator.async_refresh() + raise + await self.coordinator.async_refresh() + return result + + async def async_stop_effect(self) -> None: + async def stop() -> None: + try: + await self.client.stop_effect() + except HypercolorNotFoundError as exc: + if str(exc) != _NO_ACTIVE_EFFECT: + raise + + await self.async_mutate(stop) + + +def _latest(values: Iterable[datetime | None]) -> datetime | None: + present = [value for value in values if value is not None] + return max(present, default=None) diff --git a/custom_components/hypercolor/select.py b/custom_components/hypercolor/select.py index 4919f20..c2a68cc 100644 --- a/custom_components/hypercolor/select.py +++ b/custom_components/hypercolor/select.py @@ -1,27 +1,23 @@ from __future__ import annotations -import asyncio from collections.abc import Awaitable, Callable -from typing import Any +from typing import Literal from homeassistant.components.select import SelectEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from hypercolor.models import EffectPreset, LayoutSummary, ProfileSummary, Scene from .const import CONF_CHANNELS_AUDIO -from .entity import ( - MultiCoordinatorEntity, - catalog_items, - hub_device_info, - item_id, - item_name, - option_map, - read_field, -) +from .entity import HypercolorEntity, hub_device_info +from .models import CatalogIndex from .runtime_data import HypercolorRuntimeData +type CatalogKind = Literal["scenes", "profiles", "layouts"] +type SelectIndex = CatalogIndex[Scene] | CatalogIndex[ProfileSummary] | CatalogIndex[LayoutSummary] + async def async_setup_entry( hass: HomeAssistant, @@ -31,26 +27,23 @@ async def async_setup_entry( entities: list[SelectEntity] = [ HypercolorCatalogSelect( entry, - key="scenes", + kind="scenes", name="Scene", unique_suffix="scene", - active_key="active_scene", action=entry.runtime_data.client.activate_scene, ), HypercolorCatalogSelect( entry, - key="profiles", + kind="profiles", name="Profile", unique_suffix="profile", - active_key=None, action=entry.runtime_data.client.apply_profile, ), HypercolorCatalogSelect( entry, - key="layouts", + kind="layouts", name="Layout", unique_suffix="layout", - active_key="active_layout", action=entry.runtime_data.client.apply_layout, ), HypercolorPresetSelect(entry), @@ -60,25 +53,21 @@ async def async_setup_entry( async_add_entities(entities) -class HypercolorCatalogSelect(MultiCoordinatorEntity, SelectEntity): +class HypercolorCatalogSelect(HypercolorEntity, SelectEntity): _attr_has_entity_name = True def __init__( self, entry: ConfigEntry[HypercolorRuntimeData], *, - key: str, + kind: CatalogKind, name: str, unique_suffix: str, - active_key: str | None, - action: Callable[[str], Awaitable[Any]], + action: Callable[[str], Awaitable[object]], ) -> None: + super().__init__(entry) runtime = entry.runtime_data - super().__init__(runtime.coordinators["catalog"], runtime.coordinators["state"]) - self._entry = entry - self._state = runtime.coordinators["state"] - self._key = key - self._active_key = active_key + self._kind = kind self._action = action self._attr_name = name self._attr_device_info = hub_device_info(runtime, entry.data) @@ -86,145 +75,92 @@ def __init__( @property def options(self) -> list[str]: - return list(option_map(self._items)) + return self._index.options @property def current_option(self) -> str | None: - if self._active_key is None: - return None - active_id = read_field(self._state.data, self._active_key) - if not active_id: + state = self.snapshot.state + if self._kind == "scenes": + active_id = state.active_scene.id if state.active_scene is not None else None + elif self._kind == "layouts": + active_id = state.active_layout.id if state.active_layout is not None else None + else: return None - return next( - ( - option - for option, identifier in option_map(self._items).items() - if identifier == str(active_id) - ), - None, - ) + return self._index.label(active_id) async def async_select_option(self, option: str) -> None: - mapping = option_map(self._items) - await self._action(mapping.get(option, option)) - await self._state.async_request_refresh() - await self.coordinator.async_request_refresh() + selected_id = self._index.resolve(option) + await self._runtime.async_mutate(lambda: self._action(selected_id)) @property - def _items(self) -> list[Any]: - return catalog_items(self.coordinator.data, self._key) - - -class HypercolorPresetSelect(MultiCoordinatorEntity, SelectEntity): + def _index(self) -> SelectIndex: + catalog = self.snapshot.catalog + if self._kind == "scenes": + return catalog.scenes + if self._kind == "profiles": + return catalog.profiles + if self._kind == "layouts": + return catalog.layouts + raise AssertionError(self._kind) + + +class HypercolorPresetSelect(HypercolorEntity, SelectEntity): _attr_has_entity_name = True _attr_name = "Preset" def __init__(self, entry: ConfigEntry[HypercolorRuntimeData]) -> None: + super().__init__(entry) runtime = entry.runtime_data - super().__init__(runtime.coordinators["catalog"], runtime.coordinators["state"]) - self._entry = entry - self._state = runtime.coordinators["state"] self._attr_device_info = hub_device_info(runtime, entry.data) self._attr_unique_id = f"{runtime.server.instance_id}:preset" @property def options(self) -> list[str]: - return list(_preset_option_map(self._items)) + return self._index.options @property def current_option(self) -> str | None: - active_id = read_field(self._state.data, "active_preset") - if not active_id: - return None - for option, preset in _preset_option_map(self._items).items(): - if item_id(preset) == str(active_id): - return option - return None + return self._index.label(self.snapshot.state.active_preset_id) @property - def extra_state_attributes(self) -> dict[str, Any]: - return { - "active_preset_modified": bool( - read_field(self._state.data, "active_preset_modified", False) - ) - } + def extra_state_attributes(self) -> dict[str, bool]: + return {"active_preset_modified": self.snapshot.state.active_preset_modified} async def async_select_option(self, option: str) -> None: - preset = _preset_option_map(self._items).get(option) - if preset is None: - return - await self._entry.runtime_data.client.apply_effect_preset( - str(read_field(preset, "effect_id")), - item_id(preset), - ) - await asyncio.gather( - self._state.async_refresh(), - self.coordinator.async_refresh(), + preset = self._index.by_id[self._index.resolve(option)] + await self._runtime.async_mutate( + lambda: self._runtime.client.apply_effect_preset(preset.effect_id, preset.id) ) @property - def _items(self) -> list[Any]: - catalog_effect_id = read_field(self.coordinator.data, "preset_effect_id") - active_effect_id = read_field(self._state.data, "active_effect_id") - if not catalog_effect_id or str(catalog_effect_id) != str(active_effect_id): - return [] - return catalog_items(self.coordinator.data, "presets") - - -def _preset_option_map(items: list[Any]) -> dict[str, Any]: - counts: dict[str, int] = {} - for item in items: - name = item_name(item) - counts[name] = counts.get(name, 0) + 1 - - options: dict[str, Any] = {} - for item in items: - name = item_name(item) - option = name - if counts[name] > 1: - origin = read_field(item, "origin") - origin_value = str(getattr(origin, "value", origin)) - label = "Built-in" if origin_value == "bundled" else "Saved" - option = f"{name} ({label})" - if option in options: - option = f"{option} [{item_id(item)[:8]}]" - options[option] = item - return options - - -class HypercolorAudioDeviceSelect(CoordinatorEntity, SelectEntity): + def _index(self) -> CatalogIndex[EffectPreset]: + return self.snapshot.active_effect_presets + + +class HypercolorAudioDeviceSelect(HypercolorEntity, SelectEntity): _attr_has_entity_name = True _attr_name = "Audio device" def __init__(self, entry: ConfigEntry[HypercolorRuntimeData]) -> None: + super().__init__(entry) runtime = entry.runtime_data - super().__init__(runtime.coordinators["audio"]) - self._entry = entry self._attr_device_info = hub_device_info(runtime, entry.data) self._attr_unique_id = f"{runtime.server.instance_id}:audio_device" @property def options(self) -> list[str]: - return list(option_map(self._devices)) + return self._index.options @property def current_option(self) -> str | None: - current = read_field(read_field(self.coordinator.data, "devices"), "current") - return next( - ( - option - for option, identifier in option_map(self._devices).items() - if identifier == current - ), - None, - ) + devices = self.snapshot.audio.devices + return self._index.label(devices.current) if devices is not None else None async def async_select_option(self, option: str) -> None: - mapping = option_map(self._devices) - await self._entry.runtime_data.client.set_audio_device(mapping.get(option, option)) - await self.coordinator.async_request_refresh() + device_id = self._index.resolve(option) + await self._runtime.async_mutate(lambda: self._runtime.client.set_audio_device(device_id)) @property - def _devices(self) -> list[Any]: - devices = read_field(read_field(self.coordinator.data, "devices"), "devices", []) - return list(devices) if isinstance(devices, list) else [] + def _index(self): + devices = self.snapshot.audio.devices + return CatalogIndex.build(devices.devices if devices is not None else ()) diff --git a/custom_components/hypercolor/sensor.py b/custom_components/hypercolor/sensor.py index 51a5184..5684932 100644 --- a/custom_components/hypercolor/sensor.py +++ b/custom_components/hypercolor/sensor.py @@ -6,10 +6,9 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import CONF_CHANNELS_AUDIO, CONF_CHANNELS_METRICS -from .entity import hub_device_info, read_field +from .entity import HypercolorEntity, HypercolorWebsocketEntity, hub_device_info from .runtime_data import HypercolorRuntimeData @@ -22,84 +21,86 @@ async def async_setup_entry( HypercolorActiveEffectSensor(entry), ] if entry.options.get(CONF_CHANNELS_METRICS, False): - entities.extend([HypercolorFpsSensor(entry), HypercolorRenderTimeSensor(entry)]) + entities.extend( + [ + HypercolorFpsSensor(entry), + HypercolorRenderTimeSensor(entry), + ] + ) if entry.options.get(CONF_CHANNELS_AUDIO, False): entities.append(HypercolorAudioEnergySensor(entry)) async_add_entities(entities) -class HypercolorActiveEffectSensor(CoordinatorEntity, SensorEntity): +class HypercolorActiveEffectSensor(HypercolorEntity, SensorEntity): _attr_has_entity_name = True _attr_name = "Active effect" def __init__(self, entry: ConfigEntry[HypercolorRuntimeData]) -> None: + super().__init__(entry) runtime = entry.runtime_data - super().__init__(runtime.coordinators["state"]) self._attr_device_info = hub_device_info(runtime, entry.data) self._attr_unique_id = f"{runtime.server.instance_id}:active_effect" @property def native_value(self) -> str | None: - value = read_field(self.coordinator.data, "active_effect") - return str(value) if value else None + return self.snapshot.state.active_effect_name -class HypercolorFpsSensor(CoordinatorEntity, SensorEntity): +class HypercolorFpsSensor(HypercolorWebsocketEntity, SensorEntity): _attr_has_entity_name = True _attr_name = "FPS" _attr_native_unit_of_measurement = "fps" _attr_state_class = SensorStateClass.MEASUREMENT def __init__(self, entry: ConfigEntry[HypercolorRuntimeData]) -> None: + super().__init__(entry) runtime = entry.runtime_data - super().__init__(runtime.coordinators["metrics"]) self._attr_device_info = hub_device_info(runtime, entry.data) self._attr_unique_id = f"{runtime.server.instance_id}:fps" @property def native_value(self) -> float | None: - fps = read_field(self.coordinator.data, "fps", {}) - return _first_number(fps, "actual", "delivered", "target") + return _nested_number(self.snapshot.metrics, "fps", "actual") -class HypercolorRenderTimeSensor(CoordinatorEntity, SensorEntity): +class HypercolorRenderTimeSensor(HypercolorWebsocketEntity, SensorEntity): _attr_has_entity_name = True _attr_name = "Render time" _attr_native_unit_of_measurement = "ms" _attr_state_class = SensorStateClass.MEASUREMENT def __init__(self, entry: ConfigEntry[HypercolorRuntimeData]) -> None: + super().__init__(entry) runtime = entry.runtime_data - super().__init__(runtime.coordinators["metrics"]) self._attr_device_info = hub_device_info(runtime, entry.data) self._attr_unique_id = f"{runtime.server.instance_id}:render_time" @property def native_value(self) -> float | None: - frame_time = read_field(self.coordinator.data, "frame_time", {}) - return _first_number(frame_time, "avg_ms", "p95_ms") + return _nested_number(self.snapshot.metrics, "frame_time", "avg_ms") -class HypercolorAudioEnergySensor(CoordinatorEntity, SensorEntity): +class HypercolorAudioEnergySensor(HypercolorWebsocketEntity, SensorEntity): _attr_has_entity_name = True _attr_name = "Audio energy" _attr_state_class = SensorStateClass.MEASUREMENT def __init__(self, entry: ConfigEntry[HypercolorRuntimeData]) -> None: + super().__init__(entry) runtime = entry.runtime_data - super().__init__(runtime.coordinators["audio"]) self._attr_device_info = hub_device_info(runtime, entry.data) self._attr_unique_id = f"{runtime.server.instance_id}:audio_energy" @property def native_value(self) -> float | None: - spectrum = read_field(self.coordinator.data, "spectrum", {}) - return _first_number(spectrum, "level", "energy") + spectrum = self.snapshot.audio.spectrum + return spectrum.level if spectrum is not None else None -def _first_number(data: Any, *keys: str) -> float | None: - for key in keys: - value = read_field(data, key) - if isinstance(value, (int, float)): - return float(value) - return None +def _nested_number(data: dict[str, Any], section: str, field: str) -> float | None: + values = data.get(section) + if not isinstance(values, dict): + return None + value = values.get(field) + return float(value) if isinstance(value, (int, float)) else None diff --git a/custom_components/hypercolor/services.py b/custom_components/hypercolor/services.py index fc6212f..46f9d5c 100644 --- a/custom_components/hypercolor/services.py +++ b/custom_components/hypercolor/services.py @@ -51,7 +51,7 @@ def async_setup_services(hass: HomeAssistant) -> None: _apply_effect, _schema( { - vol.Optional("effect_id"): cv.string, + vol.Required("effect_id"): cv.string, vol.Optional("controls"): dict, vol.Optional("transition"): dict, vol.Optional("preset_id"): cv.string, @@ -280,70 +280,71 @@ def _schema(fields: dict[Any, Any]) -> vol.Schema: async def _apply_effect(call: ServiceCall) -> None: entry = _entry(call.hass, call) + runtime = entry.runtime_data zone_id = call.data.get("zone_id") - if preset_id := call.data.get("preset_id"): - effect_id = call.data.get("effect_id") - if effect_id is None: - raise HomeAssistantError("effect_id is required when preset_id is set") - await entry.runtime_data.client.apply_effect_preset( - effect_id, - preset_id, + await runtime.async_mutate( + lambda: runtime.client.apply_effect( + call.data["effect_id"], + controls=call.data.get("controls"), + transition=call.data.get("transition"), + preset_id=call.data.get("preset_id"), render_group=zone_id, ) - return - effect_id = call.data.get("effect_id") - if effect_id is None: - raise HomeAssistantError("effect_id or preset_id is required") - await entry.runtime_data.client.apply_effect( - effect_id, - controls=call.data.get("controls"), - transition=call.data.get("transition"), - render_group=zone_id, ) async def _set_color(call: ServiceCall) -> None: entry = _entry(call.hass, call) color = _color_value(call.data) - await entry.runtime_data.client.apply_effect( - "solid_color", - controls={"color": color}, + runtime = entry.runtime_data + await runtime.async_mutate( + lambda: runtime.client.apply_effect( + "solid_color", + controls={"color": color}, + ) ) async def _set_control(call: ServiceCall) -> None: entry = _entry(call.hass, call) - await entry.runtime_data.client.update_controls( - {call.data["control_name"]: call.data["value"]} + runtime = entry.runtime_data + await runtime.async_mutate( + lambda: runtime.client.update_controls({call.data["control_name"]: call.data["value"]}) ) async def _activate_scene(call: ServiceCall) -> None: entry = _entry(call.hass, call) - await entry.runtime_data.client.activate_scene(call.data["scene_id"]) + runtime = entry.runtime_data + await runtime.async_mutate(lambda: runtime.client.activate_scene(call.data["scene_id"])) async def _deactivate_scene(call: ServiceCall) -> None: entry = _entry(call.hass, call) - await entry.runtime_data.client.deactivate_scene() + await entry.runtime_data.async_mutate(entry.runtime_data.client.deactivate_scene) async def _set_zone(call: ServiceCall) -> None: entry = _entry(call.hass, call) - client = entry.runtime_data.client + runtime = entry.runtime_data scene_id = await _resolve_scene_id(entry, call.data.get("scene_id")) - updates: dict[str, Any] = {} - if (name := call.data.get(CONF_NAME)) is not None: - updates["name"] = name - if (brightness := call.data.get("brightness")) is not None: - updates["brightness"] = round(int(brightness) / 100, 4) - if (enabled := call.data.get("enabled")) is not None: - updates["enabled"] = enabled - if call.data.get("make_primary"): - updates["make_primary"] = True - if not updates: + name = call.data.get(CONF_NAME) + brightness_value = call.data.get("brightness") + brightness = round(int(brightness_value) / 100, 4) if brightness_value is not None else None + enabled = call.data.get("enabled") + make_primary = bool(call.data.get("make_primary")) or None + if name is None and brightness is None and enabled is None and make_primary is None: raise HomeAssistantError("set_zone needs at least one field to change") - await client.update_zone(scene_id, call.data["zone_id"], **updates) + await runtime.async_mutate( + lambda: runtime.client.update_zone( + scene_id, + call.data["zone_id"], + name=name, + brightness=brightness, + enabled=enabled, + make_primary=make_primary, + ) + ) async def _list_zones(call: ServiceCall) -> dict[str, Any]: @@ -353,8 +354,8 @@ async def _list_zones(call: ServiceCall) -> dict[str, Any]: result = await client.get_zones(scene_id) return { "scene_id": scene_id, - "groups_revision": _field(result, "groups_revision"), - "zones": [_jsonable(zone) for zone in _field(result, "items") or []], + "groups_revision": result.groups_revision, + "zones": [_jsonable(zone) for zone in result.items], } @@ -368,7 +369,9 @@ async def _set_unassigned_behavior(call: ServiceCall) -> None: if not fallback_zone_id: raise HomeAssistantError("fallback behavior requires fallback_zone_id") behavior = {"fallback": fallback_zone_id} - await client.set_unassigned_behavior(scene_id, behavior) + await entry.runtime_data.async_mutate( + lambda: client.set_unassigned_behavior(scene_id, behavior) + ) async def _resolve_scene_id( @@ -377,100 +380,119 @@ async def _resolve_scene_id( ) -> str: if scene_id: return scene_id - active = await entry.runtime_data.client.get_active_scene() - resolved = _field(active, "id") - if not resolved: + active = entry.runtime_data.snapshot.state.active_scene + if active is None: raise HomeAssistantError("No active Hypercolor scene") - return str(resolved) + return active.id async def _create_scene(call: ServiceCall) -> dict[str, Any]: entry = _entry(call.hass, call) - scene = await entry.runtime_data.client.create_scene( - call.data[CONF_NAME], - description=call.data.get("description"), - enabled=call.data.get("enabled"), - mutation_mode=call.data.get("mutation_mode"), + runtime = entry.runtime_data + scene = await runtime.async_mutate( + lambda: runtime.client.create_scene( + call.data[CONF_NAME], + description=call.data.get("description"), + enabled=call.data.get("enabled"), + mutation_mode=call.data.get("mutation_mode"), + ) ) return {"scene": _jsonable(scene)} async def _activate_profile(call: ServiceCall) -> None: entry = _entry(call.hass, call) - await entry.runtime_data.client.apply_profile(call.data["profile_id"]) + runtime = entry.runtime_data + await runtime.async_mutate(lambda: runtime.client.apply_profile(call.data["profile_id"])) async def _save_profile(call: ServiceCall) -> dict[str, Any]: entry = _entry(call.hass, call) - profile = await entry.runtime_data.client.save_profile( - call.data[CONF_NAME], - description=call.data.get("description"), - brightness=call.data.get("brightness"), - force=bool(call.data.get("force", False)), + runtime = entry.runtime_data + profile = await runtime.async_mutate( + lambda: runtime.client.save_profile( + call.data[CONF_NAME], + description=call.data.get("description"), + brightness=call.data.get("brightness"), + force=bool(call.data.get("force", False)), + ) ) return {"profile": _jsonable(profile)} async def _apply_layout(call: ServiceCall) -> None: entry = _entry(call.hass, call) - await entry.runtime_data.client.apply_layout(call.data["layout_id"]) + runtime = entry.runtime_data + await runtime.async_mutate(lambda: runtime.client.apply_layout(call.data["layout_id"])) async def _apply_preset(call: ServiceCall) -> None: entry = _entry(call.hass, call) - await entry.runtime_data.client.apply_effect_preset( - call.data["effect_id"], - call.data["preset_id"], + runtime = entry.runtime_data + await runtime.async_mutate( + lambda: runtime.client.apply_effect_preset( + call.data["effect_id"], + call.data["preset_id"], + ) ) async def _save_preset(call: ServiceCall) -> dict[str, Any]: entry = _entry(call.hass, call) - state = entry.runtime_data.coordinators["state"].data - effect_id = call.data.get("effect_id") or state.get("active_effect_id") + runtime = entry.runtime_data + effect_id = call.data.get("effect_id") or runtime.snapshot.state.active_effect_id if not effect_id: raise HomeAssistantError("effect_id is required when no effect is active") - preset = await entry.runtime_data.client.save_preset( - call.data[CONF_NAME], - effect_id, - description=call.data.get("description"), - controls=call.data.get("controls"), - tags=call.data.get("tags"), + preset = await runtime.async_mutate( + lambda: runtime.client.save_preset( + call.data[CONF_NAME], + effect_id, + description=call.data.get("description"), + controls=call.data.get("controls"), + tags=call.data.get("tags"), + ) ) return {"preset": _jsonable(preset)} async def _delete_preset(call: ServiceCall) -> None: entry = _entry(call.hass, call) - await entry.runtime_data.client.delete_preset(call.data["preset_id"]) + runtime = entry.runtime_data + await runtime.async_mutate(lambda: runtime.client.delete_preset(call.data["preset_id"])) async def _list_presets(call: ServiceCall) -> dict[str, Any]: entry = _entry(call.hass, call) - state = entry.runtime_data.coordinators["state"].data - effect_id = call.data.get("effect_id") or state.get("active_effect_id") + runtime = entry.runtime_data + effect_id = call.data.get("effect_id") or runtime.snapshot.state.active_effect_id if not effect_id: raise HomeAssistantError("effect_id is required when no effect is active") - presets = await entry.runtime_data.client.get_effect_presets(effect_id) + presets = await runtime.client.get_effect_presets(effect_id) return {"presets": [_jsonable(preset) for preset in presets]} async def _identify_device(call: ServiceCall) -> None: entry = _entry(call.hass, call) - await entry.runtime_data.client.identify_device( - call.data["device_id"], - duration_ms=call.data.get("duration_ms"), + runtime = entry.runtime_data + await runtime.async_mutate( + lambda: runtime.client.identify_device( + call.data["device_id"], + duration_ms=call.data.get("duration_ms"), + ) ) async def _set_display_face(call: ServiceCall) -> None: entry = _entry(call.hass, call) - await entry.runtime_data.client.set_display_face( - call.data["display_id"], - call.data["effect_id"], - controls=call.data.get("controls"), - blend_mode=call.data.get("blend_mode"), - opacity=call.data.get("opacity"), + runtime = entry.runtime_data + await runtime.async_mutate( + lambda: runtime.client.set_display_face( + call.data["display_id"], + call.data["effect_id"], + controls=call.data.get("controls"), + blend_mode=call.data.get("blend_mode"), + opacity=call.data.get("opacity"), + ) ) @@ -484,7 +506,7 @@ async def _upload_effect(call: ServiceCall) -> dict[str, Any]: raise HomeAssistantError("path or html is required") try: effect_path = await call.hass.async_add_executor_job( - partial(Path(path).resolve, strict=True), + partial(Path(path).resolve, strict=True) ) except OSError as exc: raise HomeAssistantError(f"Unable to read effect file: {exc}") from exc @@ -500,9 +522,12 @@ async def _upload_effect(call: ServiceCall) -> dict[str, Any]: content_size = len(content.encode()) if isinstance(content, str) else len(content) if content_size > _MAX_EFFECT_SIZE_BYTES: raise HomeAssistantError("Effect content exceeds the 1 MiB upload limit") - result = await entry.runtime_data.client.upload_effect( - file_name or "hypercolor-effect.html", - content, + runtime = entry.runtime_data + result = await runtime.async_mutate( + lambda: runtime.client.upload_effect( + file_name or "hypercolor-effect.html", + content, + ) ) return {"effect": result} @@ -547,10 +572,7 @@ async def _run_diagnostics(call: ServiceCall) -> dict[str, Any]: }, "server": asdict(runtime.server), "connection": runtime.connection_state.snapshot(), - "coordinators": { - name: coordinator.last_update_success - for name, coordinator in runtime.coordinators.items() - }, + "snapshot_coordinator": runtime.coordinator.last_update_success, } @@ -576,12 +598,6 @@ def _jsonable(value: Any) -> Any: return value -def _field(value: Any, name: str) -> Any: - if isinstance(value, dict): - return value.get(name) - return getattr(value, name, None) - - def _entry( hass: HomeAssistant, call: ServiceCall, diff --git a/custom_components/hypercolor/services.yaml b/custom_components/hypercolor/services.yaml index cef00f2..724c2b5 100644 --- a/custom_components/hypercolor/services.yaml +++ b/custom_components/hypercolor/services.yaml @@ -1,8 +1,4 @@ apply_effect: - target: - entity: - integration: hypercolor - domain: light fields: config_entry_id: required: true @@ -10,6 +6,7 @@ apply_effect: config_entry: integration: hypercolor effect_id: + required: true selector: text: preset_id: diff --git a/custom_components/hypercolor/switch.py b/custom_components/hypercolor/switch.py index 541c6e6..cc34dfd 100644 --- a/custom_components/hypercolor/switch.py +++ b/custom_components/hypercolor/switch.py @@ -6,10 +6,16 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from hypercolor.models import Device from .const import CONF_CHANNELS_AUDIO -from .entity import add_configured_device_entities, child_device_info, hub_device_info, read_field +from .entity import ( + HypercolorDeviceEntity, + HypercolorEntity, + add_configured_device_entities, + hub_device_info, +) from .runtime_data import HypercolorRuntimeData _AUDIO_DEVICE_DEFAULT = "default" @@ -28,66 +34,59 @@ async def async_setup_entry( add_configured_device_entities(entry, async_add_entities, HypercolorDeviceEnabledSwitch) -class HypercolorAudioReactiveSwitch(CoordinatorEntity, SwitchEntity): +class HypercolorAudioReactiveSwitch(HypercolorEntity, SwitchEntity): _attr_has_entity_name = True _attr_name = "Audio reactive" def __init__(self, entry: ConfigEntry[HypercolorRuntimeData]) -> None: + super().__init__(entry) runtime = entry.runtime_data - super().__init__(runtime.coordinators["audio"]) - self._entry = entry self._attr_device_info = hub_device_info(runtime, entry.data) self._attr_unique_id = f"{runtime.server.instance_id}:audio_reactive" @property def is_on(self) -> bool | None: - current = read_field(read_field(self.coordinator.data, "devices"), "current") - return audio_device_enabled(current) + devices = self.snapshot.audio.devices + return audio_device_enabled(devices.current if devices is not None else None) async def async_turn_on(self, **kwargs: Any) -> None: - await self._entry.runtime_data.client.set_audio_device(_AUDIO_DEVICE_DEFAULT) - await self.coordinator.async_request_refresh() + await self._runtime.async_mutate( + lambda: self._runtime.client.set_audio_device(_AUDIO_DEVICE_DEFAULT) + ) async def async_turn_off(self, **kwargs: Any) -> None: - await self._entry.runtime_data.client.set_audio_device(_AUDIO_DEVICE_NONE) - await self.coordinator.async_request_refresh() + await self._runtime.async_mutate( + lambda: self._runtime.client.set_audio_device(_AUDIO_DEVICE_NONE) + ) -class HypercolorDeviceEnabledSwitch(CoordinatorEntity, SwitchEntity): +class HypercolorDeviceEnabledSwitch(HypercolorDeviceEntity, SwitchEntity): _attr_has_entity_name = True _attr_name = "Enabled" - def __init__(self, entry: ConfigEntry[HypercolorRuntimeData], device: Any) -> None: + def __init__(self, entry: ConfigEntry[HypercolorRuntimeData], device: Device) -> None: + super().__init__(entry, device) runtime = entry.runtime_data - super().__init__(runtime.coordinators["devices"]) - self._entry = entry - self._device_id = str(read_field(device, "id")) - self._attr_device_info = child_device_info(runtime, device) self._attr_unique_id = f"{runtime.server.instance_id}:device:{self._device_id}:enabled" @property def is_on(self) -> bool | None: - if device := self._device: - return bool(read_field(device, "enabled", True)) - return None + return device.enabled if (device := self._device) is not None else None async def async_turn_on(self, **kwargs: Any) -> None: - await self._entry.runtime_data.client.update_device(self._device_id, enabled=True) - await self.coordinator.async_request_refresh() + async def operation() -> None: + await self._runtime.client.update_device(self._device_id, enabled=True) + + await self._runtime.async_mutate(operation) async def async_turn_off(self, **kwargs: Any) -> None: - await self._entry.runtime_data.client.update_device(self._device_id, enabled=False) - await self.coordinator.async_request_refresh() + async def operation() -> None: + await self._runtime.client.update_device(self._device_id, enabled=False) - @property - def _device(self) -> Any | None: - for device in self.coordinator.data or []: - if str(read_field(device, "id")) == self._device_id: - return device - return None + await self._runtime.async_mutate(operation) -def audio_device_enabled(device_id: Any) -> bool | None: +def audio_device_enabled(device_id: str | None) -> bool | None: if device_id is None: return None - return str(device_id).lower() not in {"", "disabled", _AUDIO_DEVICE_NONE} + return device_id.lower() not in {"", "disabled", _AUDIO_DEVICE_NONE} diff --git a/custom_components/hypercolor/translations/en.json b/custom_components/hypercolor/translations/en.json index 05be4d6..ae1211a 100644 --- a/custom_components/hypercolor/translations/en.json +++ b/custom_components/hypercolor/translations/en.json @@ -36,6 +36,35 @@ "missing_instance_id": "The discovered daemon did not include an instance ID." } }, + "options": { + "step": { + "init": { + "title": "Hypercolor options", + "data": { + "reconcile_interval_s": "Reconcile interval", + "channels.audio": "Audio entities", + "channels.metrics": "Metrics entities", + "live_controls_enabled": "Live control number entities", + "audio_beat_hold_ms": "Audio beat hold", + "disconnect_grace_s": "Disconnect grace", + "unavailable_after_s": "Unavailable after", + "per_device_entities": "Per-device entities" + } + } + } + }, + "entity": { + "binary_sensor": { + "connected": { + "name": "Connected" + } + }, + "sensor": { + "active_effect": { + "name": "Active effect" + } + } + }, "issues": { "auth_invalid": { "title": "Hypercolor API key was rejected", diff --git a/justfile b/justfile index dc659c6..04c2050 100644 --- a/justfile +++ b/justfile @@ -1,6 +1,6 @@ set shell := ["bash", "-eu", "-o", "pipefail", "-c"] -# ๐Ÿ’œ hypercolor-hass โ€” list available recipes +# ๐Ÿ’œ hypercolor-hass: list available recipes default: just --list @@ -24,15 +24,15 @@ typecheck: # ๐ŸŒŠ run pytest test: - uv run pytest + uv run pytest --cov=custom_components/hypercolor --cov-report=term-missing -# ๐Ÿฆ‹ end-to-end pytest (mocked daemon) +# ๐Ÿฆ‹ end-to-end pytest (fake daemon) e2e: - uv run pytest tests/test_hass_e2e.py + uv run pytest tests/test_hass_control_surface.py tests/test_hass_entity_lifecycle.py # โšก end-to-end pytest against a real daemon e2e-real: - HYPERCOLOR_HASS_REAL_E2E=1 uv run pytest tests/test_hass_e2e.py -m e2e + HYPERCOLOR_HASS_REAL_E2E=1 uv run pytest tests/test_hass_real_daemon.py -m e2e # ๐Ÿ’Ž build sdist + wheel build: @@ -55,7 +55,7 @@ hass-check: uv run python scripts/hass_dev.py --setup-only uv run hass --script check_config --config .dev/hass/config -# ๐ŸŒˆ the full pipeline โ€” what CI runs +# ๐ŸŒˆ the full pipeline: what CI runs verify: lint typecheck test metadata build # ๐Ÿ”„ reset transient HA state under .dev/ diff --git a/scripts/release.py b/scripts/release.py new file mode 100644 index 0000000..20ddea2 --- /dev/null +++ b/scripts/release.py @@ -0,0 +1,243 @@ +from __future__ import annotations + +import argparse +import json +import os +import re +import subprocess +import sys +import tomllib +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from enum import StrEnum +from pathlib import Path +from typing import Self + +VERSION_PATTERN = re.compile( + r"^(?:v)?(?P0|[1-9][0-9]*)\.(?P0|[1-9][0-9]*)\.(?P0|[1-9][0-9]*)$" +) + + +class ReleasePlanError(ValueError): + pass + + +class Bump(StrEnum): + PATCH = "patch" + MINOR = "minor" + MAJOR = "major" + + +class ReleaseMode(StrEnum): + CREATE = "create" + RESUME = "resume" + + +@dataclass(frozen=True, order=True) +class Version: + major: int + minor: int + patch: int + + @classmethod + def parse(cls, value: str) -> Self: + match = VERSION_PATTERN.fullmatch(value) + if match is None: + raise ReleasePlanError(f"invalid version {value!r}; expected X.Y.Z") + return cls(*(int(match[group]) for group in ("major", "minor", "patch"))) + + def bump(self, bump: Bump) -> Version: + match bump: + case Bump.MAJOR: + return Version(self.major + 1, 0, 0) + case Bump.MINOR: + return Version(self.major, self.minor + 1, 0) + case Bump.PATCH: + return Version(self.major, self.minor, self.patch + 1) + + @property + def tag(self) -> str: + return f"v{self}" + + def __str__(self) -> str: + return f"{self.major}.{self.minor}.{self.patch}" + + +@dataclass(frozen=True) +class ReleaseRequest: + project_version: Version + explicit_version: Version | None + bump: Bump + head_commit: str + tag_commits: Mapping[Version, str] + published_versions: frozenset[Version] + + +@dataclass(frozen=True) +class ReleasePlan: + current_version: Version + version: Version + latest_version: Version | None + mode: ReleaseMode + + @property + def tag_exists(self) -> bool: + return self.mode is ReleaseMode.RESUME + + def github_outputs(self) -> dict[str, str]: + return { + "current": str(self.current_version), + "latest_tag": self.latest_version.tag if self.latest_version else "", + "mode": self.mode.value, + "tag": self.version.tag, + "tag_exists": str(self.tag_exists).lower(), + "version": str(self.version), + } + + +def plan_release(request: ReleaseRequest) -> ReleasePlan: + latest = max(request.tag_commits, default=None) + target = _target_version(request, latest) + tag_commit = request.tag_commits.get(target) + + if target in request.published_versions: + raise ReleasePlanError(f"GitHub release {target.tag} already exists") + + if tag_commit is not None: + if target != request.project_version or tag_commit != request.head_commit: + raise ReleasePlanError( + f"tag {target.tag} exists but does not match the current release state" + ) + mode = ReleaseMode.RESUME + else: + if latest is not None and target <= latest: + raise ReleasePlanError(f"version {target} is not above the latest tag {latest.tag}") + mode = ReleaseMode.CREATE + + return ReleasePlan( + current_version=request.project_version, + version=target, + latest_version=latest, + mode=mode, + ) + + +def _target_version(request: ReleaseRequest, latest: Version | None) -> Version: + if request.explicit_version is not None: + return request.explicit_version + if latest is None or request.project_version > latest: + return request.project_version + return latest.bump(request.bump) + + +def inspect_repository(root: Path, *, explicit_version: str, bump: Bump) -> ReleaseRequest: + project_version = _read_project_version(root / "pyproject.toml") + manifest_version = _read_manifest_version( + root / "custom_components" / "hypercolor" / "manifest.json" + ) + if project_version != manifest_version: + raise ReleasePlanError( + f"project version {project_version} does not match manifest {manifest_version}" + ) + + return ReleaseRequest( + project_version=project_version, + explicit_version=Version.parse(explicit_version) if explicit_version else None, + bump=bump, + head_commit=_run(root, "git", "rev-parse", "HEAD"), + tag_commits=_read_tag_commits(root), + published_versions=_read_published_versions(root), + ) + + +def _read_project_version(path: Path) -> Version: + with path.open("rb") as file: + document = tomllib.load(file) + return Version.parse(document["project"]["version"]) + + +def _read_manifest_version(path: Path) -> Version: + document = json.loads(path.read_text(encoding="utf-8")) + return Version.parse(document["version"]) + + +def _read_tag_commits(root: Path) -> dict[Version, str]: + tags: dict[Version, str] = {} + for tag in _run(root, "git", "tag", "--list").splitlines(): + if not tag.startswith("v") or VERSION_PATTERN.fullmatch(tag) is None: + continue + version = Version.parse(tag) + tags[version] = _run(root, "git", "rev-list", "-n", "1", tag) + return tags + + +def _read_published_versions(root: Path) -> frozenset[Version]: + raw = _run(root, "gh", "release", "list", "--limit", "1000", "--json", "tagName") + releases = json.loads(raw) + if len(releases) == 1000: + raise ReleasePlanError("cannot prove release state because GitHub returned 1000 releases") + + versions = set() + for release in releases: + tag = release.get("tagName") + if isinstance(tag, str) and tag.startswith("v") and VERSION_PATTERN.fullmatch(tag): + versions.add(Version.parse(tag)) + return frozenset(versions) + + +def _run(root: Path, *command: str) -> str: + try: + result = subprocess.run( + command, + cwd=root, + check=True, + capture_output=True, + text=True, + ) + except FileNotFoundError as error: + raise ReleasePlanError(f"required command not found: {command[0]}") from error + except subprocess.CalledProcessError as error: + detail = error.stderr.strip() or error.stdout.strip() or f"exit {error.returncode}" + raise ReleasePlanError(f"{' '.join(command)} failed: {detail}") from error + return result.stdout.strip() + + +def _write_github_outputs(path: Path, plan: ReleasePlan) -> None: + with path.open("a", encoding="utf-8") as file: + for name, value in plan.github_outputs().items(): + file.write(f"{name}={value}\n") + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Plan a safe Hypercolor HASS release.") + parser.add_argument("--root", type=Path, default=Path.cwd()) + parser.add_argument("--version", default="") + parser.add_argument("--bump", type=Bump, choices=tuple(Bump), default=Bump.PATCH) + parser.add_argument( + "--github-output", + type=Path, + default=Path(os.environ["GITHUB_OUTPUT"]) if "GITHUB_OUTPUT" in os.environ else None, + ) + return parser + + +def main(argv: Sequence[str] | None = None) -> int: + args = _parser().parse_args(argv) + try: + request = inspect_repository( + args.root.resolve(), + explicit_version=args.version, + bump=args.bump, + ) + plan = plan_release(request) + if args.github_output is not None: + _write_github_outputs(args.github_output, plan) + print(json.dumps(plan.github_outputs(), sort_keys=True)) + except (KeyError, json.JSONDecodeError, ReleasePlanError) as error: + print(f"release plan failed: {error}", file=sys.stderr) + return 2 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/support/__init__.py b/tests/support/__init__.py new file mode 100644 index 0000000..faf9aad --- /dev/null +++ b/tests/support/__init__.py @@ -0,0 +1 @@ +"""Shared Home Assistant integration test support.""" diff --git a/tests/support/fixtures.py b/tests/support/fixtures.py new file mode 100644 index 0000000..67b5efb --- /dev/null +++ b/tests/support/fixtures.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from collections.abc import AsyncIterator, Callable + +import pytest +from aiohttp import web + +from .hypercolor_daemon import FakeHypercolorDaemon + + +@pytest.fixture +async def fake_daemon( + unused_tcp_port_factory: Callable[[], int], + socket_enabled: None, +) -> AsyncIterator[FakeHypercolorDaemon]: + daemon = FakeHypercolorDaemon() + app = web.Application() + app.router.add_get("/api/v1/ws", daemon.websocket) + app.router.add_post( + "/api/v1/effects/{effect_id}/presets/{preset_id}/apply", + daemon.apply_effect_preset, + ) + app.router.add_post("/api/v1/effects/{effect_id}/apply", daemon.apply_effect) + app.router.add_patch("/api/v1/effects/current/controls", daemon.update_controls) + app.router.add_put("/api/v1/settings/brightness", daemon.set_brightness) + app.router.add_put("/api/v1/output/power", daemon.set_output_power) + app.router.add_put("/api/v1/devices/{device_id}", daemon.update_device) + app.router.add_post("/api/v1/effects/stop", daemon.stop_effect) + app.router.add_patch("/api/v1/scenes/{scene_id}/zones/{zone_id}", daemon.update_zone) + app.router.add_route("*", "/api/v1/{tail:.*}", daemon.handle_api) + runner = web.AppRunner(app) + await runner.setup() + daemon.port = unused_tcp_port_factory() + site = web.TCPSite(runner, "127.0.0.1", daemon.port) + await site.start() + try: + yield daemon + finally: + await runner.cleanup() diff --git a/tests/support/hass.py b/tests/support/hass.py new file mode 100644 index 0000000..f477194 --- /dev/null +++ b/tests/support/hass.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +from collections.abc import Callable + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.core import HomeAssistant, State +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from custom_components.hypercolor.const import ( + CONF_API_KEY, + CONF_CHANNELS_AUDIO, + CONF_CHANNELS_METRICS, + CONF_LIVE_CONTROLS_ENABLED, + CONF_PER_DEVICE_ENTITIES, + CONF_RECONCILE_INTERVAL_S, + DOMAIN, + OPTIONS_DEFAULTS, +) + + +async def setup_entry( + hass: HomeAssistant, + *, + host: str = "127.0.0.1", + port: int, + setup: bool = True, +) -> MockConfigEntry: + entry = MockConfigEntry( + domain=DOMAIN, + title="Hypercolor E2E", + unique_id="srv_e2e", + data={ + CONF_HOST: host, + CONF_PORT: port, + CONF_API_KEY: None, + }, + options={ + **OPTIONS_DEFAULTS, + CONF_RECONCILE_INTERVAL_S: 3600, + CONF_CHANNELS_AUDIO: False, + CONF_CHANNELS_METRICS: False, + CONF_LIVE_CONTROLS_ENABLED: True, + CONF_PER_DEVICE_ENTITIES: ["wled-studio"], + }, + ) + entry.add_to_hass(hass) + if setup: + await activate_entry(hass, entry) + return entry + + +async def activate_entry(hass: HomeAssistant, entry: MockConfigEntry) -> None: + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + await hass.async_block_till_done() + assert entry.state is ConfigEntryState.LOADED + + +def first_state( + hass: HomeAssistant, + domain: str, + predicate: Callable[[State], bool], +) -> State: + for state in hass.states.async_all(domain): + if predicate(state): + return state + msg = f"No {domain} entity matched predicate" + raise AssertionError(msg) diff --git a/tests/support/hypercolor_daemon.py b/tests/support/hypercolor_daemon.py new file mode 100644 index 0000000..10d0763 --- /dev/null +++ b/tests/support/hypercolor_daemon.py @@ -0,0 +1,290 @@ +from __future__ import annotations + +import json +from collections.abc import Callable +from typing import Any, NotRequired, TypedDict + +from aiohttp import web + +from . import hypercolor_payloads as payloads +from .hypercolor_payloads import JsonObject + + +class AppliedEffect(TypedDict): + effect_id: str + controls: dict[str, Any] + render_group: NotRequired[str] + preset_id: NotRequired[str] + + +class DeviceUpdate(TypedDict): + device_id: str + enabled: NotRequired[bool] + brightness: NotRequired[int] + + +class ZoneUpdate(TypedDict): + scene_id: str + zone_id: str + brightness: NotRequired[float] + enabled: NotRequired[bool] + + +class FakeHypercolorDaemon: + def __init__(self) -> None: + self.port = 0 + self.active_effect_id = "rainbow" + self.active_preset_id: str | None = "preset-rainbow" + self.active_preset_modified = False + self.paused = False + self.brightness = 80 + self.control_values: dict[str, Any] = {"speed": 60.0, "brightness": 80.0} + self.control_updates: list[dict[str, Any]] = [] + self.applied_effects: list[AppliedEffect] = [] + self.device_updates: list[DeviceUpdate] = [] + self.zone_updates: list[ZoneUpdate] = [] + self.pause_requests = 0 + self.resume_requests = 0 + self.stop_requests = 0 + + async def websocket(self, request: web.Request) -> web.WebSocketResponse: + ws = web.WebSocketResponse(protocols=("hypercolor-v1",)) + await ws.prepare(request) + await ws.send_json( + { + "type": "hello", + "version": "1.0", + "state": { + "active_effect": payloads.effect_name(self.active_effect_id), + "active_effect_id": self.active_effect_id, + "global_brightness": self.brightness, + "device_count": 1, + "scene_count": 1, + }, + "capabilities": ["events"], + "subscriptions": [], + } + ) + async for message in ws: + if message.type == web.WSMsgType.TEXT: + await ws.send_json({"type": "subscribed", "channels": ["events"]}) + return ws + + async def handle_api(self, request: web.Request) -> web.Response: + route = f"{request.method} {request.path.removeprefix('/api/v1')}" + parts = request.path.removeprefix("/api/v1/").split("/") + if ( + request.method == "GET" + and len(parts) == 3 + and parts[0] == "effects" + and parts[2] == "presets" + ): + presets = [payloads.effect_preset()] if parts[1] == "rainbow" else [] + return self._ok(self._items(presets)) + responses: dict[str, Callable[[], JsonObject]] = { + "GET /server": self._server, + "GET /output/power": lambda: {"state": "paused" if self.paused else "running"}, + "POST /diagnose": lambda: {"checks": {}}, + "GET /status": self._status, + "GET /effects": lambda: self._items(payloads.effects()), + "GET /effects/active": self._active_effect, + "GET /devices": lambda: self._items([payloads.device()]), + "GET /scenes": lambda: self._items([payloads.scene()]), + "GET /scenes/active": self._active_scene, + "GET /profiles": lambda: self._items([payloads.profile()]), + "GET /layouts": lambda: self._items([payloads.layout_summary()]), + "GET /layouts/active": payloads.layout, + } + if response := responses.get(route): + return self._ok(response()) + return web.json_response({"error": {"code": "not_found", "message": route}}, status=404) + + async def apply_effect(self, request: web.Request) -> web.Response: + body = await _json_body(request) + effect_id = request.match_info["effect_id"] + self.active_effect_id = effect_id + self.active_preset_id = str(body["preset_id"]) if body.get("preset_id") else None + self.active_preset_modified = False + self.paused = False + controls = dict(body.get("controls") or {}) + self.control_values.update(controls) + applied: AppliedEffect = {"effect_id": effect_id, "controls": controls} + if render_group := body.get("render_group"): + applied["render_group"] = str(render_group) + if preset_id := body.get("preset_id"): + applied["preset_id"] = str(preset_id) + self.applied_effects.append(applied) + return self._ok( + { + "effect": {"id": effect_id, "name": payloads.effect_name(effect_id)}, + "applied_controls": controls, + } + ) + + async def apply_effect_preset(self, request: web.Request) -> web.Response: + body = await _json_body(request) + effect_id = request.match_info["effect_id"] + preset_id = request.match_info["preset_id"] + controls = dict(payloads.effect_preset()["controls"]) + self.active_effect_id = effect_id + self.active_preset_id = preset_id + self.active_preset_modified = False + self.paused = False + self.control_values.update(controls) + applied: AppliedEffect = { + "effect_id": effect_id, + "controls": controls, + "preset_id": preset_id, + } + if render_group := body.get("render_group"): + applied["render_group"] = str(render_group) + self.applied_effects.append(applied) + return self._ok( + { + "effect": {"id": effect_id, "name": payloads.effect_name(effect_id)}, + "applied_controls": controls, + } + ) + + async def update_controls(self, request: web.Request) -> web.Response: + body = await _json_body(request) + controls = dict(body.get("controls") or {}) + self.control_values.update(controls) + self.control_updates.append(controls) + self.active_preset_modified = self.active_preset_id is not None + return self._ok({"effect": self.active_effect_id, "applied": controls, "rejected": []}) + + async def set_brightness(self, request: web.Request) -> web.Response: + body = await _json_body(request) + self.brightness = int(body["brightness"]) + return self._ok({"brightness": self.brightness}) + + async def update_device(self, request: web.Request) -> web.Response: + body = await _json_body(request) + self.device_updates.append({"device_id": request.match_info["device_id"], **body}) + return self._ok(payloads.device()) + + async def stop_effect(self, request: web.Request) -> web.Response: + self.stop_requests += 1 + if not self.active_effect_id: + return web.json_response( + { + "error": { + "code": "not_found", + "message": "No effect is currently active", + } + }, + status=404, + ) + self.active_effect_id = "" + self.active_preset_id = None + self.active_preset_modified = False + self.paused = False + return self._ok({"stopped": True}) + + async def set_output_power(self, request: web.Request) -> web.Response: + body = await _json_body(request) + self.paused = body["state"] == "paused" + if self.paused: + self.pause_requests += 1 + else: + self.resume_requests += 1 + return self._ok({"state": body["state"]}) + + async def update_zone(self, request: web.Request) -> web.Response: + body = await _json_body(request) + self.zone_updates.append( + { + "scene_id": request.match_info["scene_id"], + "zone_id": request.match_info["zone_id"], + **body, + } + ) + zone = self._active_scene()["groups"][0] + zone.update(body) + return self._ok({"zone": zone, "groups_revision": 3}) + + def _server(self) -> JsonObject: + return { + "instance_id": "srv_e2e", + "instance_name": "Hypercolor E2E", + "version": "0.1.0", + "auth_required": False, + "device_count": 1, + } + + def _status(self) -> JsonObject: + return { + "running": True, + "version": "0.1.0", + "server": { + "instance_id": "srv_e2e", + "instance_name": "Hypercolor E2E", + "version": "0.1.0", + }, + "config_path": "/var/lib/hypercolor/config.toml", + "data_dir": "/var/lib/hypercolor", + "cache_dir": "/var/cache/hypercolor", + "uptime_seconds": 42, + "device_count": 1, + "effect_count": 2, + "scene_count": 1, + "global_brightness": self.brightness, + "audio_available": True, + "capture_available": False, + "render_loop": { + "state": "paused" if self.paused else "running", + "fps_tier": "30fps", + "total_frames": 123, + }, + "event_bus_subscribers": 1, + "active_effect": payloads.effect_name(self.active_effect_id), + } + + def _active_effect(self) -> JsonObject: + active = payloads.active_effect( + self.active_effect_id, + self.control_values, + self.active_preset_id, + ) + active["state"] = "paused" if self.paused else "running" + active["active_preset_modified"] = self.active_preset_modified + return active + + def _active_scene(self) -> JsonObject: + return payloads.active_scene(self.active_effect_id) + + @staticmethod + def _items(items: list[JsonObject]) -> JsonObject: + return { + "items": items, + "pagination": { + "offset": 0, + "limit": 50, + "total": len(items), + "has_more": False, + }, + } + + @staticmethod + def _ok(data: JsonObject) -> web.Response: + return web.json_response( + { + "data": data, + "meta": { + "api_version": "1.0", + "request_id": "req_e2e", + "timestamp": "2026-05-05T00:00:00Z", + }, + } + ) + + +async def _json_body(request: web.Request) -> JsonObject: + if not request.can_read_body: + return {} + try: + body = await request.json() + except json.JSONDecodeError: + return {} + return dict(body) if isinstance(body, dict) else {} diff --git a/tests/support/hypercolor_payloads.py b/tests/support/hypercolor_payloads.py new file mode 100644 index 0000000..5b8cee9 --- /dev/null +++ b/tests/support/hypercolor_payloads.py @@ -0,0 +1,232 @@ +from __future__ import annotations + +from typing import Any, cast + +import msgspec +from hypercolor.models.device import DeviceConnection, DeviceOrigin, DevicePresentation + +from hypercolor.models import ( + ActiveScene, + Device, + DeviceZone, + EffectPreset, + EffectPresetOrigin, + EffectSummary, + LayoutOutput, + LayoutSummary, + NormalizedPosition, + ProfileSummary, + Scene, + SpatialLayout, + Zone, +) + +type JsonObject = dict[str, Any] + + +def effects() -> list[JsonObject]: + return [ + _model( + EffectSummary( + id="rainbow", + name="Rainbow", + description="Test rainbow", + author="Hypercolor", + category="ambient", + source="builtin", + runnable=True, + version="1.0.0", + audio_reactive=False, + tags=["test"], + ) + ), + _model( + EffectSummary( + id="solid_color", + name="Solid Color", + description="Test solid color", + author="Hypercolor", + category="static", + source="builtin", + runnable=True, + version="1.0.0", + audio_reactive=False, + tags=["test"], + ) + ), + ] + + +def active_effect( + effect_id: str, + control_values: dict[str, Any], + active_preset_id: str | None, +) -> JsonObject: + payload: JsonObject = { + "id": effect_id, + "name": effect_name(effect_id), + "state": "running" if effect_id else "idle", + "controls": [ + _wire_control("speed", "Speed", 50.0), + _wire_control("brightness", "Brightness", 80.0), + ], + "control_values": { + key: {"float": float(value)} if isinstance(value, (int, float)) else value + for key, value in control_values.items() + }, + "active_preset_id": active_preset_id, + "render_group_id": "zone-primary", + "controls_version": 1, + } + if effect_id: + payload["cover_image_url"] = f"/api/v1/effects/{effect_id}/cover" + return payload + + +def device() -> JsonObject: + return _model( + Device( + id="wled-studio", + layout_device_id="wled:c8c9a33a9091", + name="WLED - Studio", + origin=DeviceOrigin(driver_id="wled", backend_id="wled", transport="network"), + presentation=DevicePresentation(label="WLED", short_label="WLED", icon="lightbulb"), + status="known", + brightness=100, + firmware_version="0.15.0-b3", + connection=DeviceConnection( + transport="network", + endpoint="wled-studio.local", + ip="10.4.22.169", + hostname="wled-studio.local", + ), + total_leds=275, + zones=[ + DeviceZone( + id="zone_0", + name="Main", + led_count=275, + topology="strip", + topology_hint={"type": "strip"}, + ) + ], + ) + ) + + +def scene() -> JsonObject: + return _model(Scene(id="default", name="Default")) + + +def active_scene(effect_id: str) -> JsonObject: + layout = SpatialLayout( + id="zone-layout", + name="Default zone", + canvas_width=640, + canvas_height=480, + zones=[ + LayoutOutput( + id="wled-studio:zone_0", + name="WLED - Studio", + device_id="wled-studio", + zone_name="zone_0", + position=NormalizedPosition(x=0.5, y=0.5), + size=NormalizedPosition(x=1.0, y=1.0), + rotation=0.0, + topology={"type": "strip", "count": 275, "direction": "left_to_right"}, + ) + ], + ) + return _model( + ActiveScene( + id="default", + name="Default", + priority=50, + kind="ephemeral", + groups=[ + Zone( + id="zone-primary", + name="Default zone", + effect_id=effect_id, + layout=layout, + brightness=1.0, + enabled=True, + role="primary", + controls_version=1, + ) + ], + groups_revision=2, + ) + ) + + +def profile() -> JsonObject: + return _model( + ProfileSummary( + id="profile-default", + name="Default Profile", + brightness=80, + effect_id="rainbow", + effect_name="Rainbow", + ) + ) + + +def layout_summary() -> JsonObject: + return _model( + LayoutSummary( + id="default", + name="Default Layout", + canvas_width=640, + canvas_height=480, + zone_count=1, + is_active=True, + ) + ) + + +def layout() -> JsonObject: + return _model( + SpatialLayout( + id="default", + name="Default Layout", + canvas_width=640, + canvas_height=480, + ) + ) + + +def effect_preset() -> JsonObject: + return _model( + EffectPreset( + id="preset-rainbow", + name="Rainbow Soft", + effect_id="rainbow", + origin=EffectPresetOrigin.BUNDLED, + editable=False, + description="A softer bundled look", + controls={"speed": 60}, + tags=["test"], + ) + ) + + +def effect_name(effect_id: str) -> str: + return {"rainbow": "Rainbow", "solid_color": "Solid Color"}.get(effect_id, effect_id) + + +def _wire_control(control_id: str, name: str, default: float) -> JsonObject: + return { + "id": control_id, + "name": name, + "kind": "number", + "control_type": "slider", + "default_value": {"float": default}, + "min": 0, + "max": 100, + "step": 1, + } + + +def _model(value: object) -> JsonObject: + return cast(JsonObject, msgspec.to_builtins(value)) diff --git a/tests/test_api.py b/tests/test_api.py index 4d82748..1319ff3 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -77,6 +77,8 @@ def handler(request: httpx.Request) -> httpx.Response: } }, ) + if request.url.path == "/api/v1/effects": + return httpx.Response(200, json={"data": []}) return httpx.Response(403, json={"error": {"code": "forbidden"}}) async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: @@ -90,10 +92,10 @@ def handler(request: httpx.Request) -> httpx.Response: async def test_validate_daemon_uses_non_mutating_control_probe() -> None: - requests: list[tuple[str, str, bytes]] = [] + requests: list[tuple[str, str]] = [] def handler(request: httpx.Request) -> httpx.Response: - requests.append((request.method, request.url.path, request.content)) + requests.append((request.method, request.url.path)) if request.url.path == "/api/v1/server": return httpx.Response( 200, @@ -108,22 +110,24 @@ def handler(request: httpx.Request) -> httpx.Response: } }, ) - return httpx.Response(200, json={"data": {}}) + if request.url.path == "/api/v1/output/power": + return httpx.Response(200, json={"data": {"state": "running"}}) + return httpx.Response(200, json={"data": {"checks": {}}}) async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: - await async_validate_daemon( + server = await async_validate_daemon( client, host="127.0.0.1", port=9420, api_key="hc_ak_control", ) - assert [(method, path) for method, path, _ in requests] == [ + assert server.instance_id == "srv_1" + assert requests == [ ("GET", "/api/v1/server"), ("GET", "/api/v1/output/power"), ("POST", "/api/v1/diagnose"), ] - assert all(path != "/api/v1/effects/current/controls" for _, path, _ in requests) async def test_validate_daemon_rejects_missing_output_power_contract() -> None: @@ -133,9 +137,12 @@ def handler(request: httpx.Request) -> httpx.Response: 200, json={ "data": { - "instance_id": "srv_1", - "instance_name": "Hyperia", - "version": "0.3.1", + "identity": { + "instance_id": "srv_1", + "instance_name": "Hyperia", + "version": "0.1.0", + }, + "auth_required": True, } }, ) @@ -147,5 +154,5 @@ def handler(request: httpx.Request) -> httpx.Response: client, host="127.0.0.1", port=9420, - api_key=None, + api_key="hc_ak_control", ) diff --git a/tests/test_binary_sensor.py b/tests/test_binary_sensor.py deleted file mode 100644 index 6f31001..0000000 --- a/tests/test_binary_sensor.py +++ /dev/null @@ -1,33 +0,0 @@ -from __future__ import annotations - -from custom_components.hypercolor.binary_sensor import active_effect_audio_reactive - - -def test_active_effect_audio_reactive_reads_catalog_metadata() -> None: - state = {"active_effect": "Aurora", "active_effect_id": "aurora"} - catalog = {"effects": [{"id": "aurora", "name": "Aurora", "audio_reactive": True}]} - - assert active_effect_audio_reactive(state, catalog) is True - - -def test_active_effect_audio_reactive_prefers_live_state_when_present() -> None: - state = { - "active_effect": "Aurora", - "active_effect_id": "aurora", - "active_effect_detail": {"audio_reactive": False}, - } - catalog = {"effects": [{"id": "aurora", "name": "Aurora", "audio_reactive": True}]} - - assert active_effect_audio_reactive(state, catalog) is False - - -def test_active_effect_audio_reactive_prefers_id_over_ambiguous_name() -> None: - state = {"active_effect": "Aurora", "active_effect_id": "aurora-v2"} - catalog = { - "effects": [ - {"id": "aurora-v1", "name": "Aurora", "audio_reactive": False}, - {"id": "aurora-v2", "name": "Aurora", "audio_reactive": True}, - ] - } - - assert active_effect_audio_reactive(state, catalog) is True diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py deleted file mode 100644 index 2e6d123..0000000 --- a/tests/test_config_flow.py +++ /dev/null @@ -1,49 +0,0 @@ -from __future__ import annotations - -from types import SimpleNamespace -from typing import Any - -from homeassistant.config_entries import ConfigEntryState - -from custom_components.hypercolor import async_migrate_entry -from custom_components.hypercolor.config_flow import _device_options - - -def test_device_options_use_live_devices_and_preserve_selected_missing_ids() -> None: - entry: Any = SimpleNamespace( - state=ConfigEntryState.LOADED, - runtime_data=SimpleNamespace( - coordinators={ - "devices": SimpleNamespace(data=[{"id": "wled-office", "name": "Office WLED"}]) - } - ), - ) - - options = _device_options(entry, ["corsair-offline"]) - - assert options == [ - {"value": "corsair-offline", "label": "corsair-offline"}, - {"value": "wled-office", "label": "Office WLED"}, - ] - - -async def test_migration_disables_legacy_default_polling_and_dead_channel() -> None: - updates: dict[str, Any] = {} - hass: Any = SimpleNamespace( - config_entries=SimpleNamespace( - async_update_entry=lambda _entry, **values: updates.update(values) - ) - ) - entry: Any = SimpleNamespace( - version=1, - minor_version=1, - options={ - "reconcile_interval_s": 60, - "channels.device_metrics": True, - }, - ) - - assert await async_migrate_entry(hass, entry) - assert updates["minor_version"] == 2 - assert updates["options"]["reconcile_interval_s"] == 0 - assert "channels.device_metrics" not in updates["options"] diff --git a/tests/test_coordinator.py b/tests/test_coordinator.py index 1f394d3..9e0bff5 100644 --- a/tests/test_coordinator.py +++ b/tests/test_coordinator.py @@ -2,482 +2,533 @@ import asyncio from types import SimpleNamespace -from typing import Any +from typing import Any, cast import pytest -from hypercolor.websocket import EventMessage, HelloMessage, MetricsMessage +from homeassistant.helpers.update_coordinator import UpdateFailed +from hypercolor.models.system import RenderLoopStatus +from hypercolor.websocket import EventMessage, MetricsMessage, SpectrumData from websockets.datastructures import Headers from websockets.exceptions import InvalidStatus from websockets.http11 import Response +from custom_components.hypercolor import coordinator as coordinator_module from custom_components.hypercolor.coordinator import ( - _handle_ws_message, + HypercolorCoordinator, _mark_disconnected, _normalize_websocket_error, _process_ws_message, - _reconcile_after_reconnect, - _seed_hello, + _websocket_channels, + event_requires_refresh, load_catalog, + load_snapshot, load_state, ) -from custom_components.hypercolor.runtime_data import ConnectionState -from hypercolor import HypercolorAuthenticationError - - -async def test_load_state_flattens_status_and_active_resources() -> None: - client = SimpleNamespace( - get_status=_async_value( - SimpleNamespace( - active_effect="Aurora", - global_brightness=66, - device_count=2, - scene_count=3, - render_loop={"fps": 60}, - audio_available=True, - ) - ), - get_active_effect=_async_value( - SimpleNamespace( - id="aurora", - name="Aurora", - state="paused", - active_preset_id="soft", - active_preset_modified=True, - cover_image_url="/api/v1/effects/aurora/cover", - ) - ), - get_active_scene=_async_value( - SimpleNamespace( - id="scene-1", - name="Battlestation", - groups=[ - SimpleNamespace(id="zone-1", name="Desk", role="primary"), - SimpleNamespace(id="zone-2", name="LCD", role="display"), - ], - groups_revision=7, - ) - ), - get_active_layout=_async_value(SimpleNamespace(id="layout-1")), - get_effect=_async_value(SimpleNamespace(presets=[])), - active_effect_cover_image_url=lambda: ( - "http://hyperia.test:9420/api/v1/effects/active/cover" - ), - ) +from custom_components.hypercolor.models import HypercolorSnapshot +from custom_components.hypercolor.runtime_data import ( + ConnectionSource, + ConnectionState, +) +from hypercolor import HypercolorAuthenticationError, HypercolorConnectionError +from hypercolor.models import ( + ActiveEffect, + ActiveScene, + AudioDevices, + Device, + EffectPreset, + EffectPresetOrigin, + EffectSummary, + LayoutSummary, + ProfileSummary, + Scene, + ServerIdentity, + SpatialLayout, + SystemState, +) - state = await load_state(client) - assert state["active_effect"] == "Aurora" - assert state["active_effect_id"] == "aurora" - assert ( - state["active_effect_cover_image_url"] - == "http://hyperia.test:9420/api/v1/effects/active/cover" - ) - assert state["active_preset"] == "soft" - assert state["active_preset_modified"] is True - assert state["active_effect_state"] == "paused" - assert state["global_brightness"] == 66 - assert state["active_scene"] == "scene-1" - assert state["active_scene_name"] == "Battlestation" - assert state["active_layout"] == "layout-1" - assert [zone.id for zone in state["zones"]] == ["zone-1", "zone-2"] - assert state["groups_revision"] == 7 - - -async def test_load_state_uses_client_active_cover_url() -> None: - client = SimpleNamespace( - get_status=_async_value(SimpleNamespace(active_effect="Aurora")), - get_active_effect=_async_value( - SimpleNamespace(id="aurora", name="Aurora", cover_image_url="effects/aurora/cover") - ), - get_active_scene=_async_value(None), - get_active_layout=_async_value(None), - get_effect=_async_value(SimpleNamespace(presets=[])), - active_effect_cover_image_url=lambda: ( - "http://hyperia.test:9420/api/v1/effects/active/cover" - ), - ) +async def test_load_state_joins_active_resources_concurrently() -> None: + client = SnapshotClientFixture() state = await load_state(client) - assert ( - state["active_effect_cover_image_url"] - == "http://hyperia.test:9420/api/v1/effects/active/cover" - ) + assert state.active_effect_id == "aurora" + assert state.active_effect_name == "Aurora" + assert state.active_preset_id == "soft" + assert state.active_scene is not None + assert state.active_scene.id == "scene-1" + assert state.active_layout is not None + assert state.active_layout.id == "layout-1" + assert state.active_effect_cover_image_url is not None + assert state.active_effect_cover_image_url.endswith("/effects/active/cover") + assert client.max_in_flight == 4 -async def test_load_catalog_gathers_home_assistant_picker_lists() -> None: - client = SimpleNamespace( - get_active_effect=_async_value(SimpleNamespace(id="aurora")), - get_effects=_async_value(["effect"]), - get_scenes=_async_value(["scene"]), - get_profiles=_async_value(["profile"]), - get_layouts=_async_value(["layout"]), - get_effect_presets=_async_effect_presets("aurora", ["preset"]), - ) - - catalog = await load_catalog(client) - - assert catalog == { - "effects": ["effect"], - "scenes": ["scene"], - "profiles": ["profile"], - "layouts": ["layout"], - "preset_effect_id": "aurora", - "presets": ["preset"], - } +async def test_load_state_never_uses_display_name_as_effect_id() -> None: + client = SnapshotClientFixture(with_active_effect=False) + state = await load_state(client) -async def test_load_catalog_has_empty_preset_stack_without_active_effect() -> None: - client = SimpleNamespace( - get_active_effect=_async_value(None), - get_effects=_async_value([]), - get_scenes=_async_value([]), - get_profiles=_async_value([]), - get_layouts=_async_value([]), - ) + assert state.active_effect_id is None + assert state.active_effect_name == "Aurora" + assert state.active_effect_cover_image_url is None + + +async def test_load_catalog_builds_unique_picker_indexes_concurrently() -> None: + client = SnapshotClientFixture() + client.effects = [ + _effect("aurora-v1", "Aurora"), + _effect("aurora-v2", "Aurora"), + ] + client.presets = [ + EffectPreset( + id="soft-bundled", + name="Soft", + effect_id="aurora", + origin=EffectPresetOrigin.BUNDLED, + editable=False, + ), + EffectPreset( + id="soft-saved", + name="Soft", + effect_id="aurora", + origin=EffectPresetOrigin.SAVED, + editable=True, + ), + ] catalog = await load_catalog(client) - assert catalog["preset_effect_id"] is None - assert catalog["presets"] == [] + assert catalog.effects.options == ["Aurora (aurora-v1)", "Aurora (aurora-v2)"] + assert catalog.effects.resolve("Aurora (aurora-v2)") == "aurora-v2" + assert catalog.preset_effect_id == "aurora" + assert catalog.presets.options == ["Soft (Built-in)", "Soft (Saved)"] + assert client.max_in_flight == 5 -def test_ws_events_refresh_only_the_owning_coordinator() -> None: - state = _FakeCoordinator({"active_effect": "Aurora"}) - runtime: Any = SimpleNamespace( - connection_state=SimpleNamespace(set_connected=lambda: False), - coordinators={ - "state": state, - "catalog": _FakeCoordinator({}), - "devices": _FakeCoordinator([]), - }, - ) +async def test_snapshot_hides_preset_stack_from_a_newer_effect() -> None: + client = SnapshotClientFixture() + state = await load_state(client) + client.active_effect = ActiveEffect(id="rainbow", name="Rainbow", state="running") + client.presets = [] - _handle_ws_message( - runtime, - EventMessage(event="effect_degraded", timestamp="now", data={"state": "failed"}), - {}, + catalog = await load_catalog(client) + snapshot = HypercolorSnapshot(state=state, catalog=catalog, devices=()) + + assert catalog.preset_effect_id == "rainbow" + assert state.active_effect_id == "aurora" + assert snapshot.active_effect_presets.items == () + + +async def test_rest_refresh_preserves_websocket_telemetry() -> None: + client = SnapshotClientFixture() + initial = await load_snapshot(client, load_audio=True) + spectrum = SpectrumData( + timestamp_ms=123, + bin_count=2, + level=0.8, + bass=0.7, + mid=0.4, + treble=0.2, + beat=True, + beat_confidence=0.9, + bins=[0.7, 0.2], ) - - assert state.data == {"active_effect": "Aurora"} - assert state.hass.scheduled == 1 - assert runtime.coordinators["catalog"].hass.scheduled == 0 - assert runtime.coordinators["devices"].hass.scheduled == 0 - - -def test_ws_effect_switch_refreshes_state_and_effect_scoped_presets() -> None: - runtime: Any = SimpleNamespace( - connection_state=SimpleNamespace(set_connected=lambda: False), - coordinators={ - "state": _FakeCoordinator({}), - "catalog": _FakeCoordinator({}), - "devices": _FakeCoordinator([]), - }, + streamed = initial.with_metrics({"fps": {"actual": 59.8}}).with_spectrum( + spectrum, + 42.0, ) - _handle_ws_message( - runtime, - EventMessage(event="effect_started", timestamp="now", data={"effect": "Aurora"}), - {}, - ) + refreshed = await load_snapshot(client, load_audio=True, previous=streamed) - assert runtime.coordinators["state"].hass.scheduled == 1 - assert runtime.coordinators["catalog"].hass.scheduled == 1 - assert runtime.coordinators["devices"].hass.scheduled == 0 + assert refreshed.metrics == {"fps": {"actual": 59.8}} + assert refreshed.audio.devices == client.audio_devices + assert refreshed.audio.spectrum is spectrum + assert refreshed.audio.beat_until == 42.0 -def test_ws_pause_resume_and_brightness_patch_state_without_http_refresh() -> None: - state = _FakeCoordinator({"active_effect": "Aurora", "active_effect_state": "running"}) - runtime: Any = SimpleNamespace( - connection_state=SimpleNamespace(set_connected=lambda: False), - coordinators={"state": state}, - ) +def test_event_refresh_filter_covers_state_bearing_daemon_taxonomy() -> None: + for event in ( + "device_error", + "effect_degraded", + "control_surface_changed", + "scene_enabled", + "layer_stack_changed", + "audio_source_changed", + "profile_loaded", + "asset_changed", + "layout_updated", + "config_changed", + ): + assert event_requires_refresh(event) is True - _handle_ws_message(runtime, EventMessage(event="paused", timestamp="now", data={}), {}) - assert state.data["active_effect_state"] == "paused" + for event in ( + "audio_level_update", + "beat_detected", + "device_metrics", + "frame_rendered", + "future_unknown", + ): + assert event_requires_refresh(event) is False - _handle_ws_message( - runtime, - EventMessage(event="brightness_changed", timestamp="now", data={"new_value": 42}), - {}, - ) - assert state.data["global_brightness"] == 42 - assert state.hass.scheduled == 0 - _handle_ws_message(runtime, EventMessage(event="resumed", timestamp="now", data={}), {}) - assert state.data["active_effect_state"] == "running" +def test_websocket_channels_intersect_daemon_capabilities() -> None: + options = {"channels.metrics": True, "channels.audio": True} + assert _websocket_channels(options, capabilities={"events", "metrics"}) == [ + "events", + "metrics", + ] -def test_ws_resync_hint_refreshes_every_coordinator() -> None: - runtime: Any = SimpleNamespace( - connection_state=SimpleNamespace(set_connected=lambda: False), - coordinators={ - name: _FakeCoordinator({}) - for name in ("state", "catalog", "devices", "metrics", "audio") - }, - ) - _handle_ws_message( - runtime, - EventMessage( - event="resync_required", - timestamp="now", - data={"dropped_events": 17}, - ), - {}, - ) +async def test_ws_resync_is_a_barrier_before_newer_state() -> None: + order: list[str] = [] - assert all(coordinator.hass.scheduled == 1 for coordinator in runtime.coordinators.values()) + async def prior_refresh() -> None: + await asyncio.sleep(0) + order.append("prior") + class Coordinator: + async def async_request_refresh(self) -> None: + order.append("resync") -async def test_ws_resync_is_a_barrier_before_newer_events() -> None: - release_refresh = asyncio.Event() - refresh_started = asyncio.Event() - state = _BarrierCoordinator(release_refresh, refresh_started) + task = asyncio.create_task(prior_refresh()) runtime: Any = SimpleNamespace( - connection_state=SimpleNamespace(set_connected=lambda: False), - coordinators={"state": state}, - ) - resync = EventMessage( - event="resync_required", - timestamp="now", - data={"dropped_events": 1}, + coordinator=Coordinator(), + refresh_tasks={task}, ) - barrier = asyncio.create_task(_process_ws_message(runtime, resync, {})) - await refresh_started.wait() - - assert not barrier.done() - release_refresh.set() - await barrier await _process_ws_message( runtime, - EventMessage(event="resumed", timestamp="now", data={}), + EventMessage(event="resync_required", timestamp="", data={}), {}, ) - assert state.data["active_effect_state"] == "running" + assert order == ["prior", "resync"] -def test_ws_catalog_audio_and_device_events_are_targeted() -> None: - runtime: Any = SimpleNamespace( - connection_state=SimpleNamespace(set_connected=lambda: False), - coordinators={ - name: _FakeCoordinator({} if name != "devices" else []) - for name in ("state", "catalog", "devices", "audio") - }, - ) +def test_ws_rejected_handshake_is_typed_as_authentication_failure() -> None: + response = Response(401, "Unauthorized", Headers(), b"") + error = _normalize_websocket_error(InvalidStatus(response)) - _handle_ws_message( - runtime, - EventMessage(event="library_store_changed", timestamp="now", data={}), - {}, - ) - assert runtime.coordinators["catalog"].hass.scheduled == 1 - assert runtime.coordinators["state"].hass.scheduled == 0 + assert isinstance(error, HypercolorAuthenticationError) - _handle_ws_message( - runtime, - EventMessage(event="audio_source_changed", timestamp="now", data={}), - {}, - ) - assert runtime.coordinators["audio"].hass.scheduled == 1 - assert runtime.coordinators["state"].hass.scheduled == 1 - _handle_ws_message( - runtime, - EventMessage(event="device_connected", timestamp="now", data={}), - {}, +async def test_websocket_disconnect_creates_unavailable_issue_after_threshold( + monkeypatch, +) -> None: + created_issues: list[str] = [] + monkeypatch.setattr( + coordinator_module, + "async_create_unavailable_issue", + lambda _hass, entry_id: created_issues.append(entry_id), ) - assert runtime.coordinators["devices"].hass.scheduled == 1 - - _handle_ws_message( - runtime, - EventMessage(event="control_surface_changed", timestamp="now", data={}), - {}, + monkeypatch.setattr( + coordinator_module, + "async_delete_unavailable_issue", + lambda _hass, _entry_id: None, ) - assert runtime.coordinators["devices"].hass.scheduled == 2 + state = ConnectionState() + state.set_connected(ConnectionSource.SNAPSHOT) + state.set_connected(ConnectionSource.WEBSOCKET) + coordinator = _repair_coordinator(state, unavailable_after_s=0) + runtime: Any = SimpleNamespace(connection_state=state, coordinator=coordinator) + _mark_disconnected(runtime, ConnectionError("offline")) -def test_ws_metrics_keep_nested_daemon_schema() -> None: - metrics = _FakeCoordinator({}) - runtime: Any = SimpleNamespace( - connection_state=SimpleNamespace(set_connected=lambda: False), - coordinators={"metrics": metrics}, - ) + assert state.is_available(0) is False + assert created_issues == ["entry-1"] - _handle_ws_message( - runtime, - MetricsMessage( - timestamp="now", - data={"fps": {"actual": 58.5}, "frame_time": {"avg_ms": 4.2}}, - ), - {}, + +async def test_sdk_failure_waits_for_shared_unavailable_deadline(monkeypatch) -> None: + created_issues: list[str] = [] + monkeypatch.setattr( + coordinator_module, + "async_create_unavailable_issue", + lambda _hass, entry_id: created_issues.append(entry_id), + ) + monkeypatch.setattr( + coordinator_module, + "async_delete_unavailable_issue", + lambda _hass, _entry_id: None, ) + state = ConnectionState() + state.set_connected(ConnectionSource.SNAPSHOT) + coordinator = _repair_coordinator(state, unavailable_after_s=30) - assert metrics.data["fps"]["actual"] == 58.5 - assert metrics.data["frame_time"]["avg_ms"] == 4.2 + async def fail(_previous: HypercolorSnapshot | None) -> HypercolorSnapshot: + raise HypercolorConnectionError("offline") + coordinator._loader = fail + cast(Any, coordinator).data = None -def test_ws_preset_library_event_refreshes_catalog_and_state() -> None: - runtime: Any = SimpleNamespace( - connection_state=SimpleNamespace(set_connected=lambda: False), - coordinators={ - "state": _FakeCoordinator({}), - "catalog": _FakeCoordinator({}), - }, - ) + with pytest.raises(UpdateFailed, match="Failed to refresh Hypercolor snapshot"): + await coordinator._async_update_data() - _handle_ws_message( - runtime, - EventMessage( - event="library_store_changed", - timestamp="now", - data={"collection": "presets", "kind": "updated"}, - ), - {}, - ) + assert state.is_available(30) is True + assert created_issues == [] + assert coordinator.unavailable_task is not None - assert runtime.coordinators["catalog"].hass.scheduled == 1 - assert runtime.coordinators["state"].hass.scheduled == 1 + unavailable_task = coordinator.unavailable_task + coordinator.mark_connected(ConnectionSource.SNAPSHOT) + await asyncio.gather(unavailable_task, return_exceptions=True) + assert coordinator.unavailable_task is None + assert created_issues == [] -def test_ws_hello_patches_canonical_state_and_metrics_fields() -> None: - state = _FakeCoordinator( - {"active_effect": "Old", "active_effect_id": "old", "active_effect_state": "running"} - ) - metrics = _FakeCoordinator({}) - runtime: Any = SimpleNamespace(coordinators={"state": state, "metrics": metrics}) - _seed_hello( - runtime, - HelloMessage( - version="1", - state={ - "paused": True, - "brightness": 42, - "effect": {"id": "aurora", "name": "Aurora"}, - "scene": {"id": "scene-1", "name": "Desk"}, - "device_count": 3, - "fps": {"actual": 58.5, "target": 60}, - }, - capabilities=[], - subscriptions=[], - ), - ) +async def test_unexpected_loader_bug_does_not_poison_connection_health() -> None: + state = ConnectionState() + state.set_connected(ConnectionSource.SNAPSHOT) + coordinator = _repair_coordinator(state, unavailable_after_s=30) - assert state.data["active_effect_state"] == "paused" - assert state.data["global_brightness"] == 42 - assert state.data["active_effect_id"] == "aurora" - assert state.data["active_scene"] == "scene-1" - assert state.data["device_count"] == 3 - assert metrics.data["fps"]["actual"] == 58.5 + async def fail(_previous: HypercolorSnapshot | None) -> HypercolorSnapshot: + raise TypeError("integration bug") + coordinator._loader = fail + cast(Any, coordinator).data = None -def test_ws_rejected_handshake_is_typed_as_authentication_failure() -> None: - response = Response(401, "Unauthorized", Headers()) + with pytest.raises(TypeError, match="integration bug"): + await coordinator._async_update_data() - error = _normalize_websocket_error(InvalidStatus(response)) + assert state.is_source_connected(ConnectionSource.SNAPSHOT) is True + assert coordinator.unavailable_task is None - assert isinstance(error, HypercolorAuthenticationError) - assert error.status_code == 401 + +def test_healthy_push_does_not_resync_repair_registry(monkeypatch) -> None: + deleted_issues: list[str] = [] + monkeypatch.setattr( + coordinator_module, + "async_delete_unavailable_issue", + lambda _hass, entry_id: deleted_issues.append(entry_id), + ) + state = ConnectionState() + state.set_connected(ConnectionSource.SNAPSHOT) + coordinator = _repair_coordinator(state, unavailable_after_s=30) + + coordinator.mark_connected(ConnectionSource.WEBSOCKET) + coordinator.mark_connected(ConnectionSource.WEBSOCKET) + + assert deleted_issues == ["entry-1"] -async def test_websocket_disconnect_marks_all_coordinators_unavailable_after_threshold( - monkeypatch: Any, +async def test_websocket_messages_preserve_typed_push_telemetry( + monkeypatch, ) -> None: - created_issues: list[str] = [] monkeypatch.setattr( - "custom_components.hypercolor.coordinator.async_create_unavailable_issue", - lambda _hass, entry_id: created_issues.append(entry_id), - ) - hass = SimpleNamespace(async_create_task=asyncio.create_task) - state: Any = _FakeCoordinator({}) - state.hass = hass - state.config_entry = SimpleNamespace(entry_id="entry-1") - catalog = _FakeCoordinator({}) - runtime: Any = SimpleNamespace( - connection_state=ConnectionState(connected=True), - coordinators={"state": state, "catalog": catalog}, - unavailable_task=None, + coordinator_module, + "async_delete_unavailable_issue", + lambda _hass, _entry_id: None, ) + coordinator = _PushCoordinator(await load_snapshot(SnapshotClientFixture(), load_audio=True)) + runtime: Any = _PushRuntime(coordinator) - _mark_disconnected(runtime, {"unavailable_after_s": 0}, ConnectionError("offline")) - await runtime.unavailable_task + await _process_ws_message( + runtime, + MetricsMessage( + timestamp="2026-08-12T00:00:00Z", + data={ + "fps": {"actual": 59.8}, + "frame_time": {"avg_ms": 16.7}, + "queue_depth": 2, + }, + ), + {}, + ) + spectrum = SpectrumData( + timestamp_ms=123, + bin_count=2, + level=0.8, + bass=0.7, + mid=0.4, + treble=0.2, + beat=True, + beat_confidence=0.9, + bins=[0.7, 0.2], + ) + await _process_ws_message(runtime, spectrum, {"audio_beat_hold_ms": 200}) + await _process_ws_message( + runtime, + EventMessage(event="effect_started", timestamp="", data={}), + {}, + ) + await coordinator.refreshed.wait() - assert isinstance(state.update_error, ConnectionError) - assert isinstance(catalog.update_error, ConnectionError) - assert created_issues == ["entry-1"] + assert coordinator.data.metrics == { + "fps": {"actual": 59.8}, + "frame_time": {"avg_ms": 16.7}, + "queue_depth": 2, + } + assert coordinator.data.audio.spectrum is spectrum + assert coordinator.data.audio.beat_until is not None + assert runtime.connection_state.is_source_connected(ConnectionSource.WEBSOCKET) + assert coordinator.refreshes == 1 -async def test_reconnect_reconciliation_does_not_swallow_refresh_failures() -> None: - coordinator = _RetryCoordinator() - runtime: Any = SimpleNamespace(coordinators={"state": coordinator}) +class SnapshotClientFixture: + def __init__(self, *, with_active_effect: bool = True) -> None: + self.status = _status() + self.active_effect = ( + ActiveEffect( + id="aurora", + name="Aurora", + state="running", + active_preset_id="soft", + cover_image_url="/api/v1/effects/aurora/cover", + ) + if with_active_effect + else None + ) + self.active_scene = ActiveScene(id="scene-1", name="Battlestation") + self.active_layout = SpatialLayout( + id="layout-1", + name="Desk", + canvas_width=640, + canvas_height=480, + ) + self.effects = [_effect("aurora", "Aurora")] + self.scenes = [Scene(id="scene-1", name="Battlestation")] + self.profiles = [ProfileSummary(id="profile-1", name="Default")] + self.layouts = [ + LayoutSummary(id="layout-1", name="Desk", canvas_width=640, canvas_height=480) + ] + self.presets = [ + EffectPreset( + id="soft", + name="Soft", + effect_id="aurora", + origin=EffectPresetOrigin.BUNDLED, + editable=False, + ) + ] + self.audio_devices = AudioDevices(current="none") + self.in_flight = 0 + self.max_in_flight = 0 - with pytest.raises(ConnectionError, match="retry me"): - await _reconcile_after_reconnect(runtime, {}) - await _reconcile_after_reconnect(runtime, {}) + async def get_status(self) -> SystemState: + return await self._load(self.status) - assert coordinator.calls == 2 + async def get_active_effect(self) -> ActiveEffect | None: + return await self._load(self.active_effect) + async def get_active_scene(self) -> ActiveScene | None: + return await self._load(self.active_scene) -def _async_value(value: object): - async def _loader(*_args: object) -> object: - return value + async def get_active_layout(self) -> SpatialLayout | None: + return await self._load(self.active_layout) - return _loader + async def get_effects(self) -> list[EffectSummary]: + return await self._load(self.effects) + async def get_scenes(self) -> list[Scene]: + return await self._load(self.scenes) -def _async_effect_presets(expected_effect_id: str, value: object): - async def _loader(effect_id: str) -> object: - assert effect_id == expected_effect_id - return value + async def get_profiles(self) -> list[ProfileSummary]: + return await self._load(self.profiles) - return _loader + async def get_layouts(self) -> list[LayoutSummary]: + return await self._load(self.layouts) + async def get_effect_presets(self, effect_id: str) -> list[EffectPreset]: + assert self.active_effect is not None + assert effect_id == self.active_effect.id + return await self._load(self.presets) -class _FakeHass: - def __init__(self) -> None: - self.scheduled = 0 + async def get_devices(self) -> list[Device]: + return await self._load([]) - def async_create_task(self, coro: Any) -> None: - self.scheduled += 1 - coro.close() + async def get_audio_devices(self) -> AudioDevices: + return await self._load(self.audio_devices) + def active_effect_cover_image_url(self) -> str: + return "http://hyperia.test:9420/api/v1/effects/active/cover" -class _FakeCoordinator: - def __init__(self, data: Any) -> None: - self.data: Any = data - self.hass = _FakeHass() - self.update_error: BaseException | None = None + async def _load[ValueT](self, value: ValueT) -> ValueT: + self.in_flight += 1 + self.max_in_flight = max(self.max_in_flight, self.in_flight) + await asyncio.sleep(0) + self.in_flight -= 1 + return value - async def async_request_refresh(self) -> None: - return None - def async_set_updated_data(self, data: Any) -> None: +class _PushCoordinator: + def __init__(self, data: HypercolorSnapshot) -> None: self.data = data + self.hass = SimpleNamespace(async_create_task=asyncio.create_task) + self.config_entry = SimpleNamespace(entry_id="entry-1", options={}) + self.refreshed = asyncio.Event() + self.refreshes = 0 + self.connection_state = ConnectionState() - def async_set_update_error(self, error: BaseException) -> None: - self.update_error = error - - -class _BarrierCoordinator(_FakeCoordinator): - def __init__(self, release_refresh: asyncio.Event, refresh_started: asyncio.Event) -> None: - super().__init__({"active_effect_state": "running"}) - self._release_refresh = release_refresh - self._refresh_started = refresh_started + def async_set_updated_data(self, data: HypercolorSnapshot) -> None: + self.data = data async def async_request_refresh(self) -> None: - self._refresh_started.set() - await self._release_refresh.wait() - self.data = {"active_effect_state": "paused"} - + self.refreshes += 1 + self.refreshed.set() + + def mark_connected(self, source: ConnectionSource) -> None: + self.connection_state.set_connected(source) + + +class _PushRuntime: + def __init__(self, coordinator: _PushCoordinator) -> None: + self.coordinator = coordinator + self.connection_state = coordinator.connection_state + self.refresh_tasks: set[asyncio.Task[None]] = set() + + @property + def snapshot(self) -> HypercolorSnapshot: + return self.coordinator.data + + +def _repair_coordinator( + state: ConnectionState, + *, + unavailable_after_s: int, +) -> HypercolorCoordinator: + coordinator = object.__new__(HypercolorCoordinator) + coordinator.hass = SimpleNamespace(async_create_task=asyncio.create_task) + coordinator.config_entry = SimpleNamespace( + entry_id="entry-1", + options={"unavailable_after_s": unavailable_after_s}, + ) + coordinator._connection_state = state + coordinator.unavailable_task = None + return coordinator + + +def _status() -> SystemState: + return SystemState( + running=True, + version="0.3.1", + server=ServerIdentity(instance_id="srv-1", instance_name="Hyperia", version="0.3.1"), + config_path="/config", + data_dir="/data", + cache_dir="/cache", + uptime_seconds=12, + device_count=2, + effect_count=3, + scene_count=1, + global_brightness=66, + audio_available=True, + capture_available=False, + render_loop=RenderLoopStatus(state="running", fps_tier="full", total_frames=10), + event_bus_subscribers=1, + active_effect="Aurora", + ) -class _RetryCoordinator: - def __init__(self) -> None: - self.calls = 0 - async def async_request_refresh(self) -> None: - self.calls += 1 - if self.calls == 1: - raise ConnectionError("retry me") +def _effect(effect_id: str, name: str) -> EffectSummary: + return EffectSummary( + id=effect_id, + name=name, + description="Cascading neon", + author="Aurora Labs", + category="ambient", + source="builtin", + runnable=True, + version="1.2.0", + audio_reactive=True, + tags=["cyberpunk", "rain"], + ) diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py new file mode 100644 index 0000000..2e8ca11 --- /dev/null +++ b/tests/test_diagnostics.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +from types import SimpleNamespace +from typing import Any, cast + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntry +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from custom_components.hypercolor.api import ServerInfo +from custom_components.hypercolor.const import CONF_API_KEY, DOMAIN +from custom_components.hypercolor.diagnostics import ( + async_get_config_entry_diagnostics, + async_get_device_diagnostics, +) +from custom_components.hypercolor.runtime_data import ConnectionState + + +async def test_config_entry_diagnostics_redact_credentials(hass: HomeAssistant) -> None: + entry = MockConfigEntry( + domain=DOMAIN, + data={"host": "hyperia.local", CONF_API_KEY: "control-key"}, + options={"reconcile_interval_s": 60}, + ) + entry.runtime_data = SimpleNamespace( + server=ServerInfo( + instance_id="srv-1", + instance_name="Hyperia", + version="0.3.2", + auth_required=True, + device_count=3, + ), + connection_state=ConnectionState(), + coordinator=SimpleNamespace(last_update_success=True), + ) + + diagnostics = await async_get_config_entry_diagnostics(hass, cast(Any, entry)) + + assert diagnostics["config"][CONF_API_KEY] == "**REDACTED**" + assert diagnostics["config"]["host"] == "**REDACTED**" + assert diagnostics["server"]["instance_id"] == "srv-1" + assert diagnostics["snapshot_coordinator"] is True + + +async def test_device_diagnostics_serialize_registry_identity(hass: HomeAssistant) -> None: + entry = MockConfigEntry(domain=DOMAIN, entry_id="entry-1") + device = cast( + DeviceEntry, + SimpleNamespace( + id="device-1", + identifiers={(DOMAIN, "wled-studio")}, + name="Studio WLED", + ), + ) + + diagnostics = await async_get_device_diagnostics(hass, cast(Any, entry), device) + + assert diagnostics == { + "config_entry_id": "entry-1", + "device": { + "id": "device-1", + "identifiers": [[DOMAIN, "wled-studio"]], + "name": "Studio WLED", + }, + } diff --git a/tests/test_entity.py b/tests/test_entity.py index 3f4e065..dbeb2c1 100644 --- a/tests/test_entity.py +++ b/tests/test_entity.py @@ -1,16 +1,179 @@ from __future__ import annotations +from datetime import UTC, datetime, timedelta from types import SimpleNamespace from typing import Any, cast -from custom_components.hypercolor.entity import add_configured_device_entities +from custom_components.hypercolor import ( + binary_sensor as binary_sensor_module, + entity as entity_module, +) +from custom_components.hypercolor.binary_sensor import HypercolorConnectedBinarySensor +from custom_components.hypercolor.button import ( + HypercolorActionButton, + HypercolorIdentifyDeviceButton, +) +from custom_components.hypercolor.entity import ( + HypercolorEntity, + HypercolorWebsocketEntity, + add_configured_device_entities, +) +from custom_components.hypercolor.light import HypercolorDeviceLight +from custom_components.hypercolor.runtime_data import ConnectionSource, ConnectionState +from custom_components.hypercolor.switch import HypercolorDeviceEnabledSwitch +from hypercolor.models import Device + + +def test_entity_availability_honors_source_outage_deadlines(monkeypatch) -> None: + scheduled: list[tuple[float, Any]] = [] + + def schedule(_hass: Any, delay: float, callback: Any) -> Any: + scheduled.append((delay, callback)) + return lambda: None + + monkeypatch.setattr(entity_module, "async_call_later", schedule) + state = ConnectionState() + state.set_connected(ConnectionSource.SNAPSHOT) + state.sources[ConnectionSource.SNAPSHOT].last_connected_at = datetime.now(UTC) - timedelta( + minutes=10 + ) + state.set_disconnected(ConnectionSource.SNAPSHOT, ConnectionError("offline")) + coordinator = SimpleNamespace(last_update_success=False) + entry: Any = SimpleNamespace( + options={"unavailable_after_s": 30}, + runtime_data=SimpleNamespace( + coordinator=coordinator, + connection_state=state, + ), + ) + entity = _TestEntity(entry) + entity.hass = cast(Any, SimpleNamespace()) + + entity._availability_updated() + + assert entity.available is True + assert 29 < scheduled[0][0] <= 30 + + state.sources[ConnectionSource.SNAPSHOT].last_disconnected_at = datetime.now(UTC) - timedelta( + seconds=31 + ) + scheduled[0][1]() + + assert entity.available is False + assert entity.writes == 2 + + state.set_connected(ConnectionSource.SNAPSHOT) + state.set_connected(ConnectionSource.WEBSOCKET) + state.set_disconnected(ConnectionSource.WEBSOCKET, ConnectionError("socket offline")) + entity._availability_updated() + + assert entity.available is True + assert 29 < scheduled[-1][0] <= 30 + + state.sources[ConnectionSource.WEBSOCKET].last_disconnected_at = datetime.now(UTC) - timedelta( + seconds=31 + ) + scheduled[-1][1]() + + assert entity.available is False + assert entity.writes == 4 + + +def test_websocket_entity_reschedules_against_original_outage(monkeypatch) -> None: + scheduled: list[tuple[float, Any]] = [] + + def schedule(_hass: Any, delay: float, callback: Any) -> Any: + scheduled.append((delay, callback)) + return lambda: None + + monkeypatch.setattr(entity_module, "async_call_later", schedule) + state = ConnectionState() + state.set_connected(ConnectionSource.SNAPSHOT) + state.set_connected(ConnectionSource.WEBSOCKET) + state.set_disconnected(ConnectionSource.WEBSOCKET, ConnectionError("offline")) + entry: Any = SimpleNamespace( + options={"disconnect_grace_s": 5, "unavailable_after_s": 30}, + runtime_data=SimpleNamespace( + coordinator=SimpleNamespace(last_update_success=True), + connection_state=state, + ), + ) + entity = _TestWebsocketEntity(entry) + entity.hass = cast(Any, SimpleNamespace()) + + entity._connection_updated() + + assert entity.available is True + assert 4 < scheduled[-1][0] <= 5 + + websocket = state.sources[ConnectionSource.WEBSOCKET] + websocket.last_disconnected_at = datetime.now(UTC) - timedelta(seconds=3) + state.set_disconnected(ConnectionSource.WEBSOCKET, ConnectionError("still offline")) + entity._connection_updated() + + assert 1 < scheduled[-1][0] <= 2 + + websocket.last_disconnected_at = datetime.now(UTC) - timedelta(seconds=6) + scheduled[-1][1]() + + assert entity.available is False + assert entity.writes == 3 + + +def test_connectivity_sensor_reschedules_against_original_outage(monkeypatch) -> None: + scheduled: list[tuple[float, Any]] = [] + + def schedule(_hass: Any, delay: float, callback: Any) -> Any: + scheduled.append((delay, callback)) + return lambda: None + + monkeypatch.setattr(binary_sensor_module, "async_call_later", schedule) + state = ConnectionState() + state.set_connected(ConnectionSource.WEBSOCKET) + state.set_disconnected(ConnectionSource.WEBSOCKET, ConnectionError("offline")) + entry: Any = SimpleNamespace( + data={"host": "127.0.0.1", "port": 9420}, + options={"disconnect_grace_s": 5}, + runtime_data=SimpleNamespace( + connection_state=state, + server=SimpleNamespace( + instance_id="instance-1", + instance_name="Test Hypercolor", + version="0.3.2", + ), + ), + ) + entity = HypercolorConnectedBinarySensor(entry) + entity.hass = cast(Any, SimpleNamespace()) + entity.async_write_ha_state = cast(Any, lambda: None) + + entity._connection_updated() + + assert entity.is_on is True + assert 4 < scheduled[-1][0] <= 5 + + websocket = state.sources[ConnectionSource.WEBSOCKET] + websocket.last_disconnected_at = datetime.now(UTC) - timedelta(seconds=3) + state.set_disconnected(ConnectionSource.WEBSOCKET, ConnectionError("still offline")) + entity._connection_updated() + + assert 1 < scheduled[-1][0] <= 2 + + websocket.last_disconnected_at = datetime.now(UTC) - timedelta(seconds=6) + scheduled[-1][1]() + + assert entity.is_on is False def test_configured_device_entities_follow_live_discovery() -> None: - coordinator = _Coordinator([{"id": "wled-office"}]) + coordinator = _Coordinator() + devices = [SimpleNamespace(id="wled-office")] entry: Any = SimpleNamespace( options={"per_device_entities": ["wled-office", "corsair-lcd"]}, - runtime_data=SimpleNamespace(coordinators={"devices": coordinator}), + runtime_data=SimpleNamespace( + coordinator=coordinator, + snapshot=SimpleNamespace(devices=devices), + ), async_on_unload=lambda remove: None, ) added: list[str] = [] @@ -21,20 +184,107 @@ def add_entities(entities: list[Any]) -> None: add_configured_device_entities( entry, cast(Any, add_entities), - cast(Any, lambda _entry, device: str(device["id"])), + cast(Any, lambda _entry, device: str(device.id)), ) - coordinator.data.append({"id": "corsair-lcd"}) + devices.append(SimpleNamespace(id="corsair-lcd")) coordinator.listener() coordinator.listener() assert added == ["wled-office", "corsair-lcd"] +def test_device_entities_become_unavailable_when_device_disappears() -> None: + device = cast( + Device, + SimpleNamespace( + id="wled-office", + name="WLED Office", + backend="wled", + firmware_version="0.15.0", + ), + ) + current_device: list[Any] = [device] + state = ConnectionState() + state.set_connected(ConnectionSource.SNAPSHOT) + runtime = SimpleNamespace( + coordinator=SimpleNamespace(last_update_success=True), + connection_state=state, + server=SimpleNamespace(instance_id="instance-1"), + snapshot=SimpleNamespace( + device=lambda device_id: ( + current_device[0] if current_device and current_device[0].id == device_id else None + ) + ), + ) + entry: Any = SimpleNamespace( + data={"host": "127.0.0.1", "port": 9420}, + options={"unavailable_after_s": 30}, + runtime_data=runtime, + ) + entities = ( + HypercolorDeviceLight(entry, device), + HypercolorDeviceEnabledSwitch(entry, device), + HypercolorIdentifyDeviceButton(entry, device), + ) + + assert all(entity.available for entity in entities) + + current_device.clear() + + assert all(not entity.available for entity in entities) + + +def test_action_buttons_follow_hub_availability() -> None: + state = ConnectionState() + entry: Any = SimpleNamespace( + data={"host": "127.0.0.1", "port": 9420}, + options={"unavailable_after_s": 0}, + runtime_data=SimpleNamespace( + coordinator=SimpleNamespace(last_update_success=False), + connection_state=state, + server=SimpleNamespace( + instance_id="instance-1", + instance_name="Test Hypercolor", + version="0.3.2", + ), + ), + ) + + async def action() -> None: + return None + + button = HypercolorActionButton( + entry, + name="Discover devices", + unique_suffix="discover_devices", + action=action, + ) + + assert button.available is False + + class _Coordinator: - def __init__(self, data: list[dict[str, str]]) -> None: - self.data = data + def __init__(self) -> None: self.listener = lambda: None def async_add_listener(self, listener: Any) -> Any: self.listener = listener return lambda: None + + +class _TestEntity(HypercolorEntity): + def __init__(self, entry: Any) -> None: + super().__init__(entry) + self.writes = 0 + + def async_write_ha_state(self) -> None: + self.writes += 1 + + +class _TestWebsocketEntity(HypercolorWebsocketEntity): + def __init__(self, entry: Any) -> None: + super().__init__(entry) + self.writes = 0 + + def async_write_ha_state(self) -> None: + self.writes += 1 diff --git a/tests/test_hass_config_flow.py b/tests/test_hass_config_flow.py new file mode 100644 index 0000000..9b2222b --- /dev/null +++ b/tests/test_hass_config_flow.py @@ -0,0 +1,207 @@ +from __future__ import annotations + +from ipaddress import IPv4Address +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock + +from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF, ConfigEntryState +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from custom_components.hypercolor import async_migrate_entry, config_flow +from custom_components.hypercolor.api import CannotConnectError, InvalidAuthError, ServerInfo +from custom_components.hypercolor.const import CONF_API_KEY, DOMAIN, OPTIONS_DEFAULTS + + +def test_device_options_use_live_devices_and_preserve_selected_missing_ids() -> None: + entry: Any = SimpleNamespace( + state=ConfigEntryState.LOADED, + runtime_data=SimpleNamespace( + snapshot=SimpleNamespace( + devices=(SimpleNamespace(id="wled-office", name="Office WLED"),) + ) + ), + ) + + options = config_flow._device_options(entry, ["corsair-offline"]) + + assert options == [ + {"value": "corsair-offline", "label": "corsair-offline"}, + {"value": "wled-office", "label": "Office WLED"}, + ] + + +async def test_migration_disables_legacy_polling_and_dead_channel() -> None: + updates: dict[str, Any] = {} + hass: Any = SimpleNamespace( + config_entries=SimpleNamespace( + async_update_entry=lambda _entry, **values: updates.update(values) + ) + ) + entry: Any = SimpleNamespace( + version=1, + minor_version=1, + options={ + "reconcile_interval_s": 60, + "channels.device_metrics": True, + }, + ) + + assert await async_migrate_entry(hass, entry) + assert updates["minor_version"] == 2 + assert updates["options"]["reconcile_interval_s"] == 0 + assert "channels.device_metrics" not in updates["options"] + + +async def test_user_flow_creates_entry( + hass: HomeAssistant, + enable_custom_integrations: None, + monkeypatch, +) -> None: + validate = AsyncMock(return_value=_server()) + monkeypatch.setattr(config_flow, "_validate", validate) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "hyperia.local", CONF_PORT: 9420, CONF_API_KEY: " control-key "}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Hyperia" + assert result["data"] == { + CONF_HOST: "hyperia.local", + CONF_PORT: 9420, + CONF_API_KEY: "control-key", + } + assert result["options"] == OPTIONS_DEFAULTS + validate.assert_awaited_once() + + +async def test_user_flow_reports_connection_and_auth_failures( + hass: HomeAssistant, + enable_custom_integrations: None, + monkeypatch, +) -> None: + for error, expected in ( + (CannotConnectError(), "cannot_connect"), + (InvalidAuthError(), "invalid_auth"), + ): + monkeypatch.setattr(config_flow, "_validate", AsyncMock(side_effect=error)) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "hyperia.local", CONF_PORT: 9420}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": expected} + + +async def test_zeroconf_flow_decodes_identity_and_confirms( + hass: HomeAssistant, + enable_custom_integrations: None, + monkeypatch, +) -> None: + monkeypatch.setattr(config_flow, "_validate", AsyncMock(return_value=_server())) + monkeypatch.setattr( + "custom_components.hypercolor.async_setup_entry", + AsyncMock(return_value=True), + ) + address = IPv4Address("192.168.1.50") + discovery = ZeroconfServiceInfo( + ip_address=address, + ip_addresses=[address], + port=9420, + hostname="hyperia.local.", + type="_hypercolor._tcp.local.", + name="Hyperia._hypercolor._tcp.local.", + properties={"id": b"srv-1", "name": b"Hyperia", "version": b"0.3.1"}, + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=discovery, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "zeroconf_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "control-key"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"][CONF_HOST] == "192.168.1.50" + + +async def test_zeroconf_flow_aborts_duplicate_instance( + hass: HomeAssistant, + enable_custom_integrations: None, +) -> None: + existing = MockConfigEntry(domain=DOMAIN, unique_id="srv-1") + existing.add_to_hass(hass) + address = IPv4Address("192.168.1.50") + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=ZeroconfServiceInfo( + ip_address=address, + ip_addresses=[address], + port=9420, + hostname="hyperia.local.", + type="_hypercolor._tcp.local.", + name="Hyperia._hypercolor._tcp.local.", + properties={"id": "srv-1", "name": "Hyperia"}, + ), + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert existing.data[CONF_HOST] == "192.168.1.50" + + +async def test_options_flow_replaces_complete_option_set( + hass: HomeAssistant, + enable_custom_integrations: None, +) -> None: + entry = MockConfigEntry(domain=DOMAIN, options={**OPTIONS_DEFAULTS}) + entry.add_to_hass(hass) + + result = await hass.config_entries.options.async_init(entry.entry_id) + assert result["type"] is FlowResultType.FORM + + options = { + **OPTIONS_DEFAULTS, + "reconcile_interval_s": 120, + "channels.audio": True, + "per_device_entities": [], + } + result = await hass.config_entries.options.async_configure(result["flow_id"], options) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == options + + +def _server() -> ServerInfo: + return ServerInfo( + instance_id="srv-1", + instance_name="Hyperia", + version="0.3.1", + auth_required=True, + device_count=3, + ) diff --git a/tests/test_hass_control_surface.py b/tests/test_hass_control_surface.py new file mode 100644 index 0000000..91fcb1a --- /dev/null +++ b/tests/test_hass_control_surface.py @@ -0,0 +1,141 @@ +from __future__ import annotations + +from homeassistant.core import HomeAssistant + +from custom_components.hypercolor.const import DOMAIN +from custom_components.hypercolor.services import CONF_CONFIG_ENTRY_ID, SERVICE_SET_COLOR +from tests.support.hass import first_state, setup_entry +from tests.support.hypercolor_daemon import FakeHypercolorDaemon + +pytest_plugins = ("tests.support.fixtures",) + + +async def test_config_entry_boots_and_controls_fake_daemon( + hass: HomeAssistant, + enable_custom_integrations: None, + fake_daemon: FakeHypercolorDaemon, +) -> None: + entry = await setup_entry(hass, port=fake_daemon.port) + + master = first_state(hass, "light", lambda state: state.attributes.get("effect") == "Rainbow") + assert master.state == "on" + assert master.attributes["active_effect_id"] == "rainbow" + assert ( + master.attributes["active_effect_cover_image_url"] + == f"http://127.0.0.1:{fake_daemon.port}/api/v1/effects/active/cover" + ) + assert ( + master.attributes["effect_image"] + == f"http://127.0.0.1:{fake_daemon.port}/api/v1/effects/active/cover" + ) + assert "Solid Color" in master.attributes["effect_list"] + + assert master.attributes["effect_description"] == "Test rainbow" + assert master.attributes["effect_publisher"] == "Hypercolor" + assert master.attributes["effect_audio_reactive"] is False + assert master.attributes["effect_tags"] == ["test"] + assert master.attributes["effect_category"] == "ambient" + controls_by_id = {control["id"]: control for control in master.attributes["effect_controls"]} + assert {"speed", "brightness"} <= set(controls_by_id) + assert controls_by_id["speed"]["kind"] == "number" + + preset_select = first_state( + hass, + "select", + lambda state: state.entity_id.endswith("_preset"), + ) + assert preset_select.attributes["options"] == ["Rainbow Soft"] + await hass.services.async_call( + "select", + "select_option", + {"entity_id": preset_select.entity_id, "option": "Rainbow Soft"}, + blocking=True, + ) + assert fake_daemon.applied_effects[-1] == { + "effect_id": "rainbow", + "controls": {"speed": 60}, + "preset_id": "preset-rainbow", + } + + speed = first_state(hass, "number", lambda state: "speed" in state.entity_id) + assert float(speed.state) == 60.0 + + await hass.services.async_call( + "number", + "set_value", + {"entity_id": speed.entity_id, "value": 35}, + blocking=True, + ) + + assert fake_daemon.control_updates[-1] == {"speed": 35.0} + preset_select = hass.states.get(preset_select.entity_id) + assert preset_select is not None + assert preset_select.state == "Rainbow Soft" + assert preset_select.attributes["active_preset_modified"] is True + + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": master.entity_id, "effect": "Solid Color"}, + blocking=True, + ) + + assert fake_daemon.applied_effects[-1] == { + "effect_id": "solid_color", + "controls": {}, + } + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_COLOR, + { + CONF_CONFIG_ENTRY_ID: entry.entry_id, + "hex": "#80ff00", + }, + blocking=True, + ) + + assert fake_daemon.applied_effects[-1] == { + "effect_id": "solid_color", + "controls": {"color": "#80ff00"}, + } + + zone = first_state( + hass, "light", lambda state: state.attributes.get("zone_id") == "zone-primary" + ) + assert zone.state == "on" + assert zone.attributes["role"] == "primary" + assert zone.attributes["scene_id"] == "default" + assert zone.attributes["output_count"] == 1 + + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": zone.entity_id, "brightness": 128, "effect": "Rainbow"}, + blocking=True, + ) + + assert fake_daemon.zone_updates[-1] == { + "scene_id": "default", + "zone_id": "zone-primary", + "brightness": 0.502, + } + assert fake_daemon.applied_effects[-1] == { + "effect_id": "rainbow", + "controls": {}, + "render_group": "zone-primary", + } + + await hass.services.async_call( + "light", + "turn_off", + {"entity_id": zone.entity_id}, + blocking=True, + ) + + assert fake_daemon.zone_updates[-1] == { + "scene_id": "default", + "zone_id": "zone-primary", + "enabled": False, + } + assert await hass.config_entries.async_unload(entry.entry_id) diff --git a/tests/test_hass_e2e.py b/tests/test_hass_e2e.py deleted file mode 100644 index 45baba2..0000000 --- a/tests/test_hass_e2e.py +++ /dev/null @@ -1,879 +0,0 @@ -from __future__ import annotations - -import json -import os -from collections.abc import AsyncIterator, Callable -from typing import Any - -import pytest -from aiohttp import web -from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_HOST, CONF_PORT -from homeassistant.core import HomeAssistant, State -from homeassistant.helpers import device_registry as dr, entity_registry as er -from pytest_homeassistant_custom_component.common import MockConfigEntry - -from custom_components.hypercolor.const import ( - CONF_API_KEY, - CONF_CHANNELS_AUDIO, - CONF_CHANNELS_METRICS, - CONF_LIVE_CONTROLS_ENABLED, - CONF_PER_DEVICE_ENTITIES, - CONF_RECONCILE_INTERVAL_S, - DOMAIN, - OPTIONS_DEFAULTS, -) -from custom_components.hypercolor.services import CONF_CONFIG_ENTRY_ID, SERVICE_SET_COLOR - - -@pytest.fixture -async def fake_daemon( - unused_tcp_port_factory: Callable[[], int], - socket_enabled: None, -) -> AsyncIterator[_FakeHypercolorDaemon]: - daemon = _FakeHypercolorDaemon() - app = web.Application() - app.router.add_get("/api/v1/ws", daemon.websocket) - app.router.add_post( - "/api/v1/effects/{effect_id}/presets/{preset_id}/apply", - daemon.apply_effect_preset, - ) - app.router.add_post("/api/v1/effects/{effect_id}/apply", daemon.apply_effect) - app.router.add_patch("/api/v1/effects/current/controls", daemon.update_controls) - app.router.add_put("/api/v1/settings/brightness", daemon.set_brightness) - app.router.add_put("/api/v1/devices/{device_id}", daemon.update_device) - app.router.add_put("/api/v1/output/power", daemon.set_output_power) - app.router.add_post("/api/v1/effects/stop", daemon.stop_effect) - app.router.add_patch("/api/v1/scenes/{scene_id}/zones/{zone_id}", daemon.update_zone) - app.router.add_route("*", "/api/v1/{tail:.*}", daemon.handle_api) - runner = web.AppRunner(app) - await runner.setup() - daemon.port = unused_tcp_port_factory() - site = web.TCPSite(runner, "127.0.0.1", daemon.port) - await site.start() - try: - yield daemon - finally: - await runner.cleanup() - - -async def test_config_entry_boots_and_controls_fake_daemon( - hass: HomeAssistant, - enable_custom_integrations: None, - fake_daemon: _FakeHypercolorDaemon, -) -> None: - entry = await _setup_entry(hass, port=fake_daemon.port) - assert entry.runtime_data.reconcile_task is None - device_registry = dr.async_get(hass) - hub = device_registry.async_get_device(identifiers={(DOMAIN, "srv_e2e")}) - child = device_registry.async_get_device(identifiers={(DOMAIN, "srv_e2e:device:wled-studio")}) - assert hub is not None - assert child is not None - assert child.via_device_id == hub.id - - master = _first_state(hass, "light", lambda state: state.attributes.get("effect") == "Rainbow") - assert master.state == "on" - assert master.attributes["active_effect_id"] == "rainbow" - assert ( - master.attributes["active_effect_cover_image_url"] - == f"http://127.0.0.1:{fake_daemon.port}/api/v1/effects/active/cover" - ) - assert ( - master.attributes["effect_image"] - == f"http://127.0.0.1:{fake_daemon.port}/api/v1/effects/active/cover" - ) - assert "Solid Color" in master.attributes["effect_list"] - - # Card-facing effect metadata is projected from the catalog + running effect. - assert master.attributes["effect_description"] == "Test rainbow" - assert master.attributes["effect_publisher"] == "Hypercolor" - assert master.attributes["effect_audio_reactive"] is False - assert master.attributes["effect_tags"] == ["test"] - assert master.attributes["effect_category"] == "ambient" - controls_by_id = {control["id"]: control for control in master.attributes["effect_controls"]} - assert {"speed", "brightness"} <= set(controls_by_id) - # Canonical widget kind survives the real client's payload normalization - # (daemon `control_type: "slider"` -> legacy `type: "number"` -> `number`). - assert controls_by_id["speed"]["kind"] == "number" - - speed = _first_state(hass, "number", lambda state: "speed" in state.entity_id) - assert float(speed.state) == 60.0 - - await hass.services.async_call( - "number", - "set_value", - {"entity_id": speed.entity_id, "value": 35}, - blocking=True, - ) - - assert fake_daemon.control_updates[-1] == {"speed": 35.0} - - await hass.services.async_call( - "light", - "turn_on", - {"entity_id": master.entity_id, "effect": "Solid Color"}, - blocking=True, - ) - - assert fake_daemon.applied_effects[-1] == { - "effect_id": "solid_color", - "controls": {}, - } - preset_select = _first_state( - hass, - "select", - lambda state: state.entity_id.endswith("_preset"), - ) - assert preset_select.attributes["options"] == [] - - await hass.services.async_call( - DOMAIN, - SERVICE_SET_COLOR, - { - CONF_CONFIG_ENTRY_ID: entry.entry_id, - "hex": "#80ff00", - }, - blocking=True, - ) - - assert fake_daemon.applied_effects[-1] == { - "effect_id": "solid_color", - "controls": {"color": "#80ff00"}, - } - - zone = _first_state( - hass, "light", lambda state: state.attributes.get("zone_id") == "zone-primary" - ) - assert zone.state == "on" - assert zone.attributes["role"] == "primary" - assert zone.attributes["scene_id"] == "default" - assert zone.attributes["output_count"] == 1 - - await hass.services.async_call( - "light", - "turn_on", - {"entity_id": zone.entity_id, "brightness": 128, "effect": "Rainbow"}, - blocking=True, - ) - - assert fake_daemon.zone_updates[-1] == { - "scene_id": "default", - "zone_id": "zone-primary", - "brightness": 0.502, - } - assert fake_daemon.applied_effects[-1] == { - "effect_id": "rainbow", - "controls": {}, - "render_group": "zone-primary", - } - - await hass.services.async_call( - "light", - "turn_off", - {"entity_id": zone.entity_id}, - blocking=True, - ) - - assert fake_daemon.zone_updates[-1] == { - "scene_id": "default", - "zone_id": "zone-primary", - "enabled": False, - } - assert await hass.config_entries.async_unload(entry.entry_id) - - -@pytest.mark.e2e -@pytest.mark.skipif( - os.environ.get("HYPERCOLOR_HASS_REAL_E2E") != "1", - reason="set HYPERCOLOR_HASS_REAL_E2E=1 to use a running local daemon", -) -async def test_real_daemon_config_entry_boots( - hass: HomeAssistant, - enable_custom_integrations: None, - socket_enabled: None, -) -> None: - entry = await _setup_entry( - hass, - host=os.environ.get("HYPERCOLOR_HOST", "127.0.0.1"), - port=int(os.environ.get("HYPERCOLOR_PORT", "9420")), - ) - - master = _first_state(hass, "light", lambda state: bool(state.attributes.get("effect_list"))) - assert master.state in {"on", "off"} - assert master.attributes["effect_list"] - assert await hass.config_entries.async_unload(entry.entry_id) - - -async def test_stale_zone_entities_are_pruned_at_setup( - hass: HomeAssistant, - enable_custom_integrations: None, - fake_daemon: _FakeHypercolorDaemon, -) -> None: - entry = await _setup_entry(hass, port=fake_daemon.port, setup=False) - entity_registry = er.async_get(hass) - stale = entity_registry.async_get_or_create( - "light", - DOMAIN, - "srv_e2e:zone:zone-deleted-long-ago", - config_entry=entry, - ) - - await _activate_entry(hass, entry) - - assert entity_registry.async_get(stale.entity_id) is None - assert ( - entity_registry.async_get_entity_id("light", DOMAIN, "srv_e2e:zone:zone-primary") - is not None - ) - assert await hass.config_entries.async_unload(entry.entry_id) - - -async def test_offline_opted_out_device_entities_are_pruned_at_setup( - hass: HomeAssistant, - enable_custom_integrations: None, - fake_daemon: _FakeHypercolorDaemon, -) -> None: - entry = await _setup_entry(hass, port=fake_daemon.port, setup=False) - entity_registry = er.async_get(hass) - stale = [ - entity_registry.async_get_or_create( - domain, - DOMAIN, - f"srv_e2e:device:corsair-offline:{suffix}", - config_entry=entry, - ) - for domain, suffix in ( - ("light", "light"), - ("button", "identify"), - ("switch", "enabled"), - ) - ] - - await _activate_entry(hass, entry) - - assert all(entity_registry.async_get(item.entity_id) is None for item in stale) - assert await hass.config_entries.async_unload(entry.entry_id) - - -async def test_master_pause_resume_preserves_exact_effect_state( - hass: HomeAssistant, - enable_custom_integrations: None, - fake_daemon: _FakeHypercolorDaemon, -) -> None: - entry = await _setup_entry(hass, port=fake_daemon.port) - master = _first_state(hass, "light", lambda state: "active_effect_id" in state.attributes) - assert master.state == "on" - assert master.attributes["effect"] == "Rainbow" - - await hass.services.async_call( - "light", "turn_off", {"entity_id": master.entity_id}, blocking=True - ) - stopped = hass.states.get(master.entity_id) - assert stopped is not None - assert stopped.state == "off" - - await hass.services.async_call( - "light", "turn_on", {"entity_id": master.entity_id}, blocking=True - ) - assert fake_daemon.pause_requests == 1 - assert fake_daemon.resume_requests == 1 - assert fake_daemon.active_effect_id == "rainbow" - assert fake_daemon.active_preset_id == "preset-rainbow" - assert fake_daemon.control_values == {"speed": 60.0, "brightness": 80.0} - assert fake_daemon.applied_effects == [] - resumed = hass.states.get(master.entity_id) - assert resumed is not None - assert resumed.state == "on" - assert resumed.attributes["effect"] == "Rainbow" - assert await hass.config_entries.async_unload(entry.entry_id) - - -async def test_preset_select_applies_unified_effect_preset( - hass: HomeAssistant, - enable_custom_integrations: None, - fake_daemon: _FakeHypercolorDaemon, -) -> None: - entry = await _setup_entry(hass, port=fake_daemon.port) - preset_select = _first_state( - hass, - "select", - lambda state: state.entity_id.endswith("_preset"), - ) - - assert preset_select.attributes["options"] == ["Rainbow Soft"] - await hass.services.async_call( - "select", - "select_option", - {"entity_id": preset_select.entity_id, "option": "Rainbow Soft"}, - blocking=True, - ) - - assert { - "effect_id": "rainbow", - "controls": {"speed": 60}, - "preset_id": "preset-rainbow", - } in fake_daemon.applied_effects - selected = hass.states.get(preset_select.entity_id) - assert selected is not None - assert selected.state == "Rainbow Soft" - assert selected.attributes["active_preset_modified"] is False - assert await hass.config_entries.async_unload(entry.entry_id) - - -async def test_master_turn_off_and_stop_button_are_idempotent( - hass: HomeAssistant, - enable_custom_integrations: None, - fake_daemon: _FakeHypercolorDaemon, -) -> None: - entry = await _setup_entry(hass, port=fake_daemon.port) - master = _first_state(hass, "light", lambda state: "active_effect_id" in state.attributes) - stop_button = _first_state( - hass, - "button", - lambda state: state.entity_id.endswith("_stop_effect"), - ) - - for _ in range(2): - await hass.services.async_call( - "light", "turn_off", {"entity_id": master.entity_id}, blocking=True - ) - await hass.services.async_call( - "button", "press", {"entity_id": stop_button.entity_id}, blocking=True - ) - await hass.services.async_call( - "button", "press", {"entity_id": stop_button.entity_id}, blocking=True - ) - - assert fake_daemon.pause_requests == 2 - assert fake_daemon.stop_requests == 2 - assert await hass.config_entries.async_unload(entry.entry_id) - - -async def test_selecting_effect_while_paused_uses_effect_apply_wake( - hass: HomeAssistant, - enable_custom_integrations: None, - fake_daemon: _FakeHypercolorDaemon, -) -> None: - entry = await _setup_entry(hass, port=fake_daemon.port) - master = _first_state(hass, "light", lambda state: "active_effect_id" in state.attributes) - - await hass.services.async_call( - "light", "turn_off", {"entity_id": master.entity_id}, blocking=True - ) - await hass.services.async_call( - "light", - "turn_on", - {"entity_id": master.entity_id, "effect": "Solid Color"}, - blocking=True, - ) - - assert fake_daemon.active_effect_id == "solid_color" - assert fake_daemon.paused is False - assert fake_daemon.resume_requests == 0 - assert await hass.config_entries.async_unload(entry.entry_id) - - -async def _setup_entry( - hass: HomeAssistant, - *, - host: str = "127.0.0.1", - port: int, - setup: bool = True, -) -> MockConfigEntry: - entry = MockConfigEntry( - domain=DOMAIN, - title="Hypercolor E2E", - unique_id="srv_e2e", - data={ - CONF_HOST: host, - CONF_PORT: port, - CONF_API_KEY: None, - }, - options={ - **OPTIONS_DEFAULTS, - CONF_RECONCILE_INTERVAL_S: 0, - CONF_CHANNELS_AUDIO: False, - CONF_CHANNELS_METRICS: False, - CONF_LIVE_CONTROLS_ENABLED: True, - CONF_PER_DEVICE_ENTITIES: ["wled-studio"], - }, - ) - entry.add_to_hass(hass) - if setup: - await _activate_entry(hass, entry) - return entry - - -async def _activate_entry(hass: HomeAssistant, entry: MockConfigEntry) -> None: - assert await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - await hass.async_block_till_done() - assert entry.state is ConfigEntryState.LOADED - - -def _first_state( - hass: HomeAssistant, - domain: str, - predicate: Callable[[State], bool], -) -> State: - for state in hass.states.async_all(domain): - if predicate(state): - return state - msg = f"No {domain} entity matched predicate" - raise AssertionError(msg) - - -class _FakeHypercolorDaemon: - def __init__(self) -> None: - self.port = 0 - self.active_effect_id = "rainbow" - self.active_preset_id = "preset-rainbow" - self.paused = False - self.brightness = 80 - self.control_values: dict[str, Any] = {"speed": 60.0, "brightness": 80.0} - self.control_updates: list[dict[str, Any]] = [] - self.applied_effects: list[dict[str, Any]] = [] - self.device_updates: list[dict[str, Any]] = [] - self.zone_updates: list[dict[str, Any]] = [] - self.stop_requests = 0 - self.pause_requests = 0 - self.resume_requests = 0 - - async def websocket(self, request: web.Request) -> web.WebSocketResponse: - ws = web.WebSocketResponse(protocols=("hypercolor-v1",)) - await ws.prepare(request) - await ws.send_json( - { - "type": "hello", - "version": "1.0", - "state": { - "active_effect": self._active_effect()["name"], - "active_effect_id": self.active_effect_id, - "global_brightness": self.brightness, - "device_count": 1, - "scene_count": 1, - }, - "capabilities": ["events"], - "subscriptions": [], - } - ) - async for message in ws: - if message.type == web.WSMsgType.TEXT: - await ws.send_json({"type": "subscribed", "channels": ["events"]}) - return ws - - async def handle_api(self, request: web.Request) -> web.Response: - route = f"{request.method} {request.path.removeprefix('/api/v1')}" - parts = request.path.removeprefix("/api/v1/").split("/") - if ( - request.method == "GET" - and len(parts) == 3 - and parts[0] == "effects" - and parts[2] == "presets" - ): - presets = [self._preset()] if parts[1] == "rainbow" else [] - return self._ok(self._items(presets)) - responses = { - "GET /server": self._server, - "GET /output/power": lambda: {"state": "paused" if self.paused else "running"}, - "GET /status": self._status, - "GET /effects": lambda: self._items(self._effects()), - "GET /effects/active": self._active_effect, - "GET /devices": lambda: self._items([self._device()]), - "GET /scenes": lambda: self._items([self._scene()]), - "GET /scenes/active": self._active_scene, - "GET /profiles": lambda: self._items([self._profile()]), - "GET /layouts": lambda: self._items([self._layout_summary()]), - "GET /layouts/active": self._layout, - "GET /library/presets": lambda: self._items([self._preset()]), - } - if response := responses.get(route): - return self._ok(response()) - return web.json_response({"error": {"code": "not_found", "message": route}}, status=404) - - async def apply_effect(self, request: web.Request) -> web.Response: - body = await _json_body(request) - effect_id = request.match_info["effect_id"] - self.active_effect_id = effect_id - self.active_preset_id = body.get("preset_id") - self.paused = False - controls = dict(body.get("controls") or {}) - self.control_values.update(controls) - applied = {"effect_id": effect_id, "controls": controls} - if body.get("render_group"): - applied["render_group"] = body["render_group"] - if body.get("preset_id"): - applied["preset_id"] = body["preset_id"] - self.applied_effects.append(applied) - return self._ok( - { - "effect": {"id": effect_id, "name": self._effect_name(effect_id)}, - "applied_controls": controls, - } - ) - - async def apply_effect_preset(self, request: web.Request) -> web.Response: - body = await _json_body(request) - effect_id = request.match_info["effect_id"] - preset_id = request.match_info["preset_id"] - controls = dict(self._preset()["controls"]) - self.active_effect_id = effect_id - self.active_preset_id = preset_id - self.control_values.update(controls) - applied = { - "effect_id": effect_id, - "controls": controls, - "preset_id": preset_id, - } - if body.get("render_group"): - applied["render_group"] = body["render_group"] - self.applied_effects.append(applied) - return self._ok( - { - "effect": {"id": effect_id, "name": self._effect_name(effect_id)}, - "applied_controls": controls, - } - ) - - async def update_controls(self, request: web.Request) -> web.Response: - body = await _json_body(request) - controls = dict(body.get("controls") or {}) - self.control_values.update(controls) - self.control_updates.append(controls) - return self._ok({"effect": self.active_effect_id, "applied": controls, "rejected": []}) - - async def set_brightness(self, request: web.Request) -> web.Response: - body = await _json_body(request) - self.brightness = int(body["brightness"]) - return self._ok({"brightness": self.brightness}) - - async def update_device(self, request: web.Request) -> web.Response: - body = await _json_body(request) - self.device_updates.append({"device_id": request.match_info["device_id"], **body}) - return self._ok(self._device()) - - async def stop_effect(self, request: web.Request) -> web.Response: - self.stop_requests += 1 - if not self.active_effect_id: - return web.json_response( - { - "error": { - "code": "not_found", - "message": "No effect is currently active", - } - }, - status=404, - ) - self.active_effect_id = "" - self.active_preset_id = None - self.paused = False - return self._ok({"stopped": True}) - - async def set_output_power(self, request: web.Request) -> web.Response: - body = await _json_body(request) - self.paused = body["state"] == "paused" - if self.paused: - self.pause_requests += 1 - else: - self.resume_requests += 1 - return self._ok({"state": body["state"]}) - - async def update_zone(self, request: web.Request) -> web.Response: - body = await _json_body(request) - self.zone_updates.append( - { - "scene_id": request.match_info["scene_id"], - "zone_id": request.match_info["zone_id"], - **body, - } - ) - zone = self._active_scene()["groups"][0] - zone.update(body) - return self._ok({"zone": zone, "groups_revision": 3}) - - def _server(self) -> dict[str, Any]: - return { - "instance_id": "srv_e2e", - "instance_name": "Hypercolor E2E", - "version": "0.1.0", - "auth_required": False, - "device_count": 1, - } - - def _status(self) -> dict[str, Any]: - return { - "running": True, - "version": "0.1.0", - "server": { - "instance_id": "srv_e2e", - "instance_name": "Hypercolor E2E", - "version": "0.1.0", - }, - "config_path": "/var/lib/hypercolor/config.toml", - "data_dir": "/var/lib/hypercolor", - "cache_dir": "/var/cache/hypercolor", - "uptime_seconds": 42, - "device_count": 1, - "effect_count": 2, - "scene_count": 1, - "global_brightness": self.brightness, - "audio_available": True, - "capture_available": False, - "render_loop": { - "state": "paused" if self.paused else "running", - "fps_tier": "30fps", - "total_frames": 123, - }, - "event_bus_subscribers": 1, - "active_effect": self._effect_name(self.active_effect_id), - } - - def _effects(self) -> list[dict[str, Any]]: - return [ - { - "id": "rainbow", - "name": "Rainbow", - "description": "Test rainbow", - "author": "Hypercolor", - "category": "ambient", - "source": "builtin", - "runnable": True, - "version": "1.0.0", - "audio_reactive": False, - "tags": ["test"], - }, - { - "id": "solid_color", - "name": "Solid Color", - "description": "Test solid color", - "author": "Hypercolor", - "category": "static", - "source": "builtin", - "runnable": True, - "version": "1.0.0", - "audio_reactive": False, - "tags": ["test"], - }, - ] - - def _active_effect(self) -> dict[str, Any]: - effect = { - "id": self.active_effect_id, - "name": self._effect_name(self.active_effect_id), - "state": "paused" if self.paused else "running", - "controls": [ - { - "id": "speed", - "name": "Speed", - "kind": "number", - "control_type": "slider", - "default_value": {"float": 50.0}, - "min": 0, - "max": 100, - "step": 1, - }, - { - "id": "brightness", - "name": "Brightness", - "kind": "number", - "control_type": "slider", - "default_value": {"float": 80.0}, - "min": 0, - "max": 100, - "step": 1, - }, - ], - "control_values": { - key: {"float": float(value)} if isinstance(value, (int, float)) else value - for key, value in self.control_values.items() - }, - "active_preset_id": self.active_preset_id, - "render_group_id": "zone-primary", - "controls_version": 1, - } - if self.active_effect_id: - effect["cover_image_url"] = f"/api/v1/effects/{self.active_effect_id}/cover" - return effect - - def _device(self) -> dict[str, Any]: - return { - "id": "wled-studio", - "layout_device_id": "wled:c8c9a33a9091", - "name": "WLED - Studio", - "origin": { - "driver_id": "wled", - "backend_id": "wled", - "transport": "network", - }, - "presentation": { - "label": "WLED", - "short_label": "WLED", - "icon": "lightbulb", - }, - "status": "known", - "brightness": 100, - "firmware_version": "0.15.0-b3", - "connection": { - "transport": "network", - "endpoint": "wled-studio.local", - "ip": "10.4.22.169", - "hostname": "wled-studio.local", - }, - "total_leds": 275, - "zones": [ - { - "id": "zone_0", - "name": "Main", - "led_count": 275, - "topology": "strip", - "topology_hint": {"type": "strip"}, - } - ], - } - - @staticmethod - def _scene() -> dict[str, Any]: - return {"id": "default", "name": "Default", "description": None, "enabled": True} - - def _active_scene(self) -> dict[str, Any]: - return { - "id": "default", - "name": "Default", - "description": None, - "enabled": True, - "priority": 50, - "kind": "ephemeral", - "mutation_mode": "live", - "groups": [ - { - "id": "zone-primary", - "name": "Default zone", - "description": None, - "effect_id": self.active_effect_id, - "controls": {}, - "preset_id": None, - "layers": [], - "layout": { - "id": "zone-layout", - "name": "Default zone", - "description": None, - "canvas_width": 640, - "canvas_height": 480, - "zones": [ - { - "id": "wled-studio:zone_0", - "name": "WLED - Studio", - "device_id": "wled-studio", - "zone_name": "zone_0", - "position": {"x": 0.5, "y": 0.5}, - "size": {"x": 1.0, "y": 1.0}, - "rotation": 0.0, - "orientation": None, - "topology": { - "type": "strip", - "count": 275, - "direction": "left_to_right", - }, - "sampling_mode": None, - "edge_behavior": None, - "shape": None, - "shape_preset": None, - } - ], - "version": 1, - }, - "brightness": 1.0, - "enabled": True, - "color": None, - "role": "primary", - "controls_version": 1, - "layers_version": 0, - } - ], - "groups_revision": 2, - "unassigned_behavior": "off", - } - - @staticmethod - def _profile() -> dict[str, Any]: - return { - "id": "profile-default", - "name": "Default Profile", - "description": None, - "brightness": 80, - "effect_id": "rainbow", - "effect_name": "Rainbow", - } - - @staticmethod - def _layout_summary() -> dict[str, Any]: - return { - "id": "default", - "name": "Default Layout", - "canvas_width": 640, - "canvas_height": 480, - "zone_count": 1, - "is_active": True, - } - - @staticmethod - def _layout() -> dict[str, Any]: - return { - "id": "default", - "name": "Default Layout", - "canvas_width": 640, - "canvas_height": 480, - "zones": [], - } - - @staticmethod - def _preset() -> dict[str, Any]: - return { - "id": "preset-rainbow", - "name": "Rainbow Soft", - "effect_id": "rainbow", - "origin": "bundled", - "editable": False, - "description": "A softer bundled look", - "controls": {"speed": 60}, - "tags": ["test"], - } - - @staticmethod - def _items(items: list[dict[str, Any]]) -> dict[str, Any]: - return { - "items": items, - "pagination": { - "offset": 0, - "limit": 50, - "total": len(items), - "has_more": False, - }, - } - - @staticmethod - def _effect_name(effect_id: str) -> str: - return {"rainbow": "Rainbow", "solid_color": "Solid Color"}.get(effect_id, effect_id) - - @staticmethod - def _ok(data: dict[str, Any]) -> web.Response: - return web.json_response( - { - "data": data, - "meta": { - "api_version": "1.0", - "request_id": "req_e2e", - "timestamp": "2026-05-05T00:00:00Z", - }, - } - ) - - -async def _json_body(request: web.Request) -> dict[str, Any]: - if not request.can_read_body: - return {} - try: - body = await request.json() - except json.JSONDecodeError: - return {} - return dict(body) if isinstance(body, dict) else {} diff --git a/tests/test_hass_entity_lifecycle.py b/tests/test_hass_entity_lifecycle.py new file mode 100644 index 0000000..39841fb --- /dev/null +++ b/tests/test_hass_entity_lifecycle.py @@ -0,0 +1,129 @@ +from __future__ import annotations + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from custom_components.hypercolor.const import DOMAIN +from tests.support.hass import activate_entry, first_state, setup_entry +from tests.support.hypercolor_daemon import FakeHypercolorDaemon + +pytest_plugins = ("tests.support.fixtures",) + + +async def test_stale_zone_entities_are_pruned_at_setup( + hass: HomeAssistant, + enable_custom_integrations: None, + fake_daemon: FakeHypercolorDaemon, +) -> None: + entry = await setup_entry(hass, port=fake_daemon.port, setup=False) + entity_registry = er.async_get(hass) + stale = entity_registry.async_get_or_create( + "light", + DOMAIN, + "srv_e2e:zone:zone-deleted-long-ago", + config_entry=entry, + ) + + await activate_entry(hass, entry) + + assert entity_registry.async_get(stale.entity_id) is None + assert ( + entity_registry.async_get_entity_id("light", DOMAIN, "srv_e2e:zone:zone-primary") + is not None + ) + assert await hass.config_entries.async_unload(entry.entry_id) + + +async def test_master_pause_resume_preserves_exact_effect_state( + hass: HomeAssistant, + enable_custom_integrations: None, + fake_daemon: FakeHypercolorDaemon, +) -> None: + entry = await setup_entry(hass, port=fake_daemon.port) + master = first_state(hass, "light", lambda state: "active_effect_id" in state.attributes) + assert master.state == "on" + assert master.attributes["effect"] == "Rainbow" + + await hass.services.async_call( + "light", "turn_off", {"entity_id": master.entity_id}, blocking=True + ) + stopped = hass.states.get(master.entity_id) + assert stopped is not None + assert stopped.state == "off" + + await hass.services.async_call( + "light", "turn_on", {"entity_id": master.entity_id}, blocking=True + ) + assert fake_daemon.pause_requests == 1 + assert fake_daemon.resume_requests == 1 + assert fake_daemon.active_effect_id == "rainbow" + assert fake_daemon.active_preset_id == "preset-rainbow" + assert fake_daemon.control_values == {"speed": 60.0, "brightness": 80.0} + assert fake_daemon.applied_effects == [] + resumed = hass.states.get(master.entity_id) + assert resumed is not None + assert resumed.state == "on" + assert resumed.attributes["effect"] == "Rainbow" + assert await hass.config_entries.async_unload(entry.entry_id) + + +async def test_master_turn_off_and_stop_button_are_idempotent( + hass: HomeAssistant, + enable_custom_integrations: None, + fake_daemon: FakeHypercolorDaemon, +) -> None: + entry = await setup_entry(hass, port=fake_daemon.port) + master = first_state(hass, "light", lambda state: "active_effect_id" in state.attributes) + stop_button = first_state( + hass, + "button", + lambda state: state.entity_id.endswith("_stop_effect"), + ) + + for _ in range(2): + await hass.services.async_call( + "light", + "turn_off", + {"entity_id": master.entity_id}, + blocking=True, + ) + await hass.services.async_call( + "button", + "press", + {"entity_id": stop_button.entity_id}, + blocking=True, + ) + await hass.services.async_call( + "button", + "press", + {"entity_id": stop_button.entity_id}, + blocking=True, + ) + + assert fake_daemon.pause_requests == 2 + assert fake_daemon.stop_requests == 2 + assert await hass.config_entries.async_unload(entry.entry_id) + + +async def test_selecting_effect_while_paused_uses_effect_apply_wake( + hass: HomeAssistant, + enable_custom_integrations: None, + fake_daemon: FakeHypercolorDaemon, +) -> None: + entry = await setup_entry(hass, port=fake_daemon.port) + master = first_state(hass, "light", lambda state: "active_effect_id" in state.attributes) + + await hass.services.async_call( + "light", "turn_off", {"entity_id": master.entity_id}, blocking=True + ) + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": master.entity_id, "effect": "Solid Color"}, + blocking=True, + ) + + assert fake_daemon.active_effect_id == "solid_color" + assert fake_daemon.paused is False + assert fake_daemon.resume_requests == 0 + assert await hass.config_entries.async_unload(entry.entry_id) diff --git a/tests/test_hass_real_daemon.py b/tests/test_hass_real_daemon.py new file mode 100644 index 0000000..e462562 --- /dev/null +++ b/tests/test_hass_real_daemon.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +import os + +import pytest +from homeassistant.core import HomeAssistant + +from tests.support.hass import first_state, setup_entry + + +@pytest.mark.e2e +@pytest.mark.skipif( + os.environ.get("HYPERCOLOR_HASS_REAL_E2E") != "1", + reason="set HYPERCOLOR_HASS_REAL_E2E=1 to use a running local daemon", +) +async def test_real_daemon_config_entry_boots( + hass: HomeAssistant, + enable_custom_integrations: None, + socket_enabled: None, +) -> None: + entry = await setup_entry( + hass, + host=os.environ.get("HYPERCOLOR_HOST", "127.0.0.1"), + port=int(os.environ.get("HYPERCOLOR_PORT", "9420")), + ) + + master = first_state(hass, "light", lambda state: bool(state.attributes.get("effect_list"))) + assert master.state in {"on", "off"} + assert master.attributes["effect_list"] + assert await hass.config_entries.async_unload(entry.entry_id) diff --git a/tests/test_light.py b/tests/test_light.py index 0b3dc4a..0df0909 100644 --- a/tests/test_light.py +++ b/tests/test_light.py @@ -1,162 +1,80 @@ from __future__ import annotations from custom_components.hypercolor.light import ( - active_effect_entry, effect_controls_payload, - effect_id_for_name, effect_metadata, - effect_name_for_id, - effect_names, - first_effect_id, - renderable_zones, + first_id, ) +from custom_components.hypercolor.models import CatalogIndex +from hypercolor.models import ActiveEffect, ControlDefinition, EffectSummary -def test_effect_names_prefer_display_name() -> None: - catalog = [{"id": "neon_rain", "name": "Neon Rain"}] +def test_catalog_index_makes_duplicate_names_unambiguous() -> None: + index = CatalogIndex.build([_effect("aurora-v1", "Aurora"), _effect("aurora-v2", "Aurora")]) - assert effect_names(catalog) == ["Neon Rain"] + assert index.options == ["Aurora (aurora-v1)", "Aurora (aurora-v2)"] + assert index.resolve("Aurora (aurora-v2)") == "aurora-v2" + assert index.label("aurora-v1") == "Aurora (aurora-v1)" -def test_effect_names_accept_catalog_payload() -> None: - catalog = {"effects": [{"id": "neon_rain", "name": "Neon Rain"}]} +def test_catalog_index_keeps_unique_display_name() -> None: + index = CatalogIndex.build([_effect("neon-rain", "Neon Rain")]) - assert effect_names(catalog) == ["Neon Rain"] + assert index.options == ["Neon Rain"] + assert index.resolve("Neon Rain") == "neon-rain" + assert first_id(index) == "neon-rain" -def test_effect_id_for_name_maps_home_assistant_choice_to_daemon_id() -> None: - catalog = [{"id": "neon_rain", "name": "Neon Rain"}] +def test_effect_metadata_projects_catalog_contract() -> None: + metadata = effect_metadata(_effect("neon-rain", "Neon Rain")) - assert effect_id_for_name(catalog, "Neon Rain") == "neon_rain" - - -def test_effect_id_for_name_preserves_unknown_choice() -> None: - assert effect_id_for_name([], "custom") == "custom" - - -def test_effect_name_for_id_maps_back_to_display_name() -> None: - catalog = [{"id": "neon_rain", "name": "Neon Rain"}] - - assert effect_name_for_id(catalog, "neon_rain") == "Neon Rain" - assert effect_name_for_id(catalog, "unknown") == "unknown" - - -def test_first_effect_id_returns_leading_catalog_id() -> None: - catalog = [{"id": "neon_rain", "name": "Neon Rain"}, {"id": "aurora", "name": "Aurora"}] - - assert first_effect_id(catalog) == "neon_rain" - - -def test_first_effect_id_handles_empty_catalog() -> None: - assert first_effect_id([]) is None - assert first_effect_id(None) is None - - -def test_renderable_zones_excludes_display_faces() -> None: - state = { - "zones": [ - {"id": "zone-1", "name": "Desk", "role": "primary"}, - {"id": "zone-2", "name": "Room", "role": "custom"}, - {"id": "zone-3", "name": "LCD", "role": "display"}, - ] - } - - zones = renderable_zones(state) - - assert [zone["id"] for zone in zones] == ["zone-1", "zone-2"] - - -def test_renderable_zones_tolerates_missing_state() -> None: - assert renderable_zones(None) == [] - assert renderable_zones({}) == [] - assert renderable_zones({"zones": "bogus"}) == [] - - -def _catalog() -> dict[str, list[dict[str, object]]]: - return { - "effects": [ - { - "id": "neon_rain", - "name": "Neon Rain", - "description": "Cascading neon", - "author": "Aurora Labs", - "category": "ambient", - "version": "1.2.0", - "audio_reactive": True, - "tags": ["cyberpunk", "rain"], - }, - {"id": "aurora", "name": "Aurora"}, - ] + assert metadata == { + "effect_description": "Cascading neon", + "effect_publisher": "Aurora Labs", + "effect_audio_reactive": True, + "effect_tags": ["cyberpunk", "rain"], + "effect_category": "ambient", + "effect_version": "1.2.0", } -def test_active_effect_entry_matches_by_id_then_name() -> None: - catalog = _catalog() - - by_id = active_effect_entry(catalog, "neon_rain", None) - by_name = active_effect_entry(catalog, None, "Aurora") - assert by_id is not None - assert by_id["name"] == "Neon Rain" - assert by_name is not None - assert by_name["id"] == "aurora" - assert active_effect_entry(catalog, "missing", "missing") is None - assert active_effect_entry([], "neon_rain", None) is None - - -def test_effect_metadata_reads_catalog_record() -> None: - entry = _catalog()["effects"][0] - - metadata = effect_metadata(entry, active_detail=None) - - assert metadata["description"] == "Cascading neon" - assert metadata["publisher"] == "Aurora Labs" - assert metadata["category"] == "ambient" - assert metadata["version"] == "1.2.0" - assert metadata["audio_reactive"] is True - assert metadata["tags"] == ["cyberpunk", "rain"] - - -def test_effect_metadata_prefers_live_audio_flag_and_tolerates_gaps() -> None: - # Live detail overrides the catalog flag; missing catalog stays safe. - assert ( - effect_metadata({"audio_reactive": False}, {"audio_reactive": True})["audio_reactive"] - is True - ) - empty = effect_metadata(None, None) - assert empty == { - "description": None, - "publisher": None, - "audio_reactive": False, - "tags": [], - "category": None, - "version": None, +def test_effect_metadata_has_stable_empty_projection() -> None: + assert effect_metadata(None) == { + "effect_description": None, + "effect_publisher": None, + "effect_audio_reactive": False, + "effect_tags": [], + "effect_category": None, + "effect_version": None, } -def test_effect_controls_payload_normalizes_descriptors() -> None: - active_detail = { - "controls": [ - { - "id": "speed", - "name": "Speed", - "kind": "number", - "control_type": "slider", - "min": 0, - "max": 100, - "step": 1, - "default_value": {"float": 50.0}, - }, - { - "id": "palette", - "name": "Palette", - "kind": "enum", - "options": [{"id": "sunset", "label": "Sunset"}, "Ocean"], - }, +def test_effect_controls_payload_uses_typed_sdk_controls() -> None: + active = ActiveEffect( + id="neon-rain", + name="Neon Rain", + state="running", + controls=[ + ControlDefinition( + id="speed", + label="Speed", + type="number", + default={"float": 50.0}, + min=0, + max=100, + step=1, + ), + ControlDefinition( + id="palette", + label="Palette", + type="select", + options=["Sunset", "Ocean"], + ), ], - "control_values": {"speed": {"float": 72.0}}, - } + control_values={"speed": {"float": 72.0}}, + ) - controls = effect_controls_payload(active_detail) + controls = effect_controls_payload(active) assert controls[0] == { "id": "speed", @@ -167,50 +85,20 @@ def test_effect_controls_payload_normalizes_descriptors() -> None: "step": 1, "value": 72.0, } - assert controls[1]["value"] is None + assert controls[1]["kind"] == "enum" assert controls[1]["options"] == ["Sunset", "Ocean"] -def test_effect_controls_payload_canonicalizes_kind_from_legacy_type() -> None: - # The real client normalizes daemon controls to a legacy `type` string and - # ships dropdown choices under `labels`; the payload must handle both. - active_detail = { - "controls": [ - {"id": "brightness", "label": "Brightness", "type": "number", "min": 0, "max": 100}, - {"id": "mirror", "label": "Mirror", "type": "boolean", "value": True}, - {"id": "tint", "label": "Tint", "type": "color", "value": {"color": [1.0, 0.0, 0.5]}}, - { - "id": "palette", - "label": "Palette", - "type": "select", - "labels": ["Sunset", "Ocean"], - "value": "Ocean", - }, - {"id": "caption", "label": "Caption", "type": "text", "value": "hi"}, - ], - } - - controls = effect_controls_payload(active_detail) - by_id = {control["id"]: control for control in controls} - - assert by_id["brightness"]["kind"] == "number" - assert by_id["mirror"]["kind"] == "boolean" - assert by_id["tint"]["kind"] == "color" - assert by_id["palette"]["kind"] == "enum" - assert by_id["palette"]["options"] == ["Sunset", "Ocean"] - # Controls with no faithful card widget are marked `other` (card skips them). - assert by_id["caption"]["kind"] == "other" - - -def test_effect_controls_payload_reads_generated_bound_aliases() -> None: - # The generated client model names bounds `min_`/`max_`. - control = {"id": "speed", "label": "Speed", "control_type": "slider", "min_": 5, "max_": 90} - payload = effect_controls_payload({"controls": [control]}) - assert payload[0]["kind"] == "number" - assert payload[0]["min"] == 5 - assert payload[0]["max"] == 90 - - -def test_effect_controls_payload_tolerates_missing_controls() -> None: - assert effect_controls_payload(None) == [] - assert effect_controls_payload({"controls": "bogus"}) == [] +def _effect(effect_id: str, name: str) -> EffectSummary: + return EffectSummary( + id=effect_id, + name=name, + description="Cascading neon", + author="Aurora Labs", + category="ambient", + source="builtin", + runnable=True, + version="1.2.0", + audio_reactive=True, + tags=["cyberpunk", "rain"], + ) diff --git a/tests/test_release.py b/tests/test_release.py new file mode 100644 index 0000000..e70705e --- /dev/null +++ b/tests/test_release.py @@ -0,0 +1,270 @@ +from __future__ import annotations + +import json +import os +import subprocess +from pathlib import Path + +import pytest +import scripts.release as release_module +from scripts.release import ( + Bump, + ReleaseMode, + ReleasePlanError, + ReleaseRequest, + Version, + _read_published_versions, + _run, + _write_github_outputs, + inspect_repository, + main, + plan_release, +) + +HEAD = "a" * 40 + + +def test_ci_installs_pinned_just_runner() -> None: + setup = Path(".github/actions/setup-hypercolor/action.yml").read_text(encoding="utf-8") + ci = Path(".github/workflows/ci.yml").read_text(encoding="utf-8") + install = "uv tool install rust-just==1.58.0" + + assert setup.count(install) == 1 + assert ci.count(install) == 1 + + +def test_composite_action_keeps_git_auth_header_on_one_line() -> None: + action = Path(".github/actions/setup-hypercolor/action.yml").read_text(encoding="utf-8") + assert "base64 | tr -d '\\n'" in action + + environment = {**os.environ, "HYPERCOLOR_TOKEN": "github_pat_" + "x" * 96} + encoded = subprocess.run( + [ + "/bin/bash", + "-c", + "printf 'x-access-token:%s' \"${HYPERCOLOR_TOKEN}\" | base64 | tr -d '\\n'", + ], + check=True, + capture_output=True, + env=environment, + text=True, + ).stdout + + assert encoded + assert "\n" not in encoded + + +def _request( + *, + project: str = "1.2.3", + explicit: str | None = None, + bump: Bump = Bump.PATCH, + tags: dict[str, str] | None = None, + published: set[str] | None = None, +) -> ReleaseRequest: + return ReleaseRequest( + project_version=Version.parse(project), + explicit_version=Version.parse(explicit) if explicit else None, + bump=bump, + head_commit=HEAD, + tag_commits={Version.parse(tag): commit for tag, commit in (tags or {}).items()}, + published_versions=frozenset(Version.parse(tag) for tag in published or set()), + ) + + +def _write_version_files(root: Path, version: str = "1.2.3") -> None: + (root / "pyproject.toml").write_text(f'[project]\nversion = "{version}"\n', encoding="utf-8") + manifest = root / "custom_components" / "hypercolor" / "manifest.json" + manifest.parent.mkdir(parents=True) + manifest.write_text(json.dumps({"version": version}), encoding="utf-8") + + +@pytest.mark.parametrize( + ("bump", "expected"), + [ + (Bump.PATCH, "1.2.4"), + (Bump.MINOR, "1.3.0"), + (Bump.MAJOR, "2.0.0"), + ], +) +def test_bumps_latest_tag(bump: Bump, expected: str) -> None: + plan = plan_release(_request(bump=bump, tags={"1.2.3": "b" * 40})) + + assert str(plan.version) == expected + assert plan.mode is ReleaseMode.CREATE + + +@pytest.mark.parametrize( + ("project", "tags", "expected"), + [ + ("0.1.0", {}, "0.1.0"), + ("1.3.0", {"1.2.3": "b" * 40}, "1.3.0"), + ], +) +def test_uses_project_version_for_first_or_prestamped_release( + project: str, + tags: dict[str, str], + expected: str, +) -> None: + plan = plan_release(_request(project=project, tags=tags)) + + assert str(plan.version) == expected + assert plan.mode is ReleaseMode.CREATE + + +def test_explicit_version_overrides_bump() -> None: + plan = plan_release(_request(explicit="v3.1.4", tags={"1.2.3": "b" * 40})) + + assert str(plan.version) == "3.1.4" + + +def test_missing_target_tag_creates_release_without_rewriting_version() -> None: + plan = plan_release(_request(project="1.2.4", explicit="1.2.4", tags={"1.2.3": HEAD})) + + assert plan.mode is ReleaseMode.CREATE + assert plan.tag_exists is False + + +def test_matching_unpublished_tag_resumes_release() -> None: + plan = plan_release(_request(explicit="1.2.3", tags={"1.2.3": HEAD})) + + assert plan.mode is ReleaseMode.RESUME + assert plan.tag_exists is True + + +@pytest.mark.parametrize( + ("release_request", "message"), + [ + ( + _request(explicit="1.2.3", tags={"1.2.3": "b" * 40}), + "does not match the current release state", + ), + ( + _request(explicit="1.2.3", tags={"1.2.3": HEAD}, published={"1.2.3"}), + "already exists", + ), + ( + _request(explicit="1.2.2", tags={"1.2.3": HEAD}), + "is not above the latest tag", + ), + ], +) +def test_rejects_unsafe_release_state(release_request: ReleaseRequest, message: str) -> None: + with pytest.raises(ReleasePlanError, match=message): + plan_release(release_request) + + +def test_writes_github_action_outputs(tmp_path: Path) -> None: + output = tmp_path / "github-output" + plan = plan_release(_request(explicit="1.2.3", tags={"1.2.3": HEAD})) + + _write_github_outputs(output, plan) + + assert output.read_text(encoding="utf-8").splitlines() == [ + "current=1.2.3", + "latest_tag=v1.2.3", + "mode=resume", + "tag=v1.2.3", + "tag_exists=true", + "version=1.2.3", + ] + + +def test_inspects_repository_state(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + _write_version_files(tmp_path) + + def fake_run(root: Path, *command: str) -> str: + assert root == tmp_path + match command: + case ("git", "rev-parse", "HEAD"): + return HEAD + case ("git", "tag", "--list"): + return "v1.2.2\n1.2.1\nnot-a-release" + case ("git", "rev-list", "-n", "1", "v1.2.2"): + return "b" * 40 + case ( + "gh", + "release", + "list", + "--limit", + "1000", + "--json", + "tagName", + ): + return json.dumps([{"tagName": "v1.2.2"}, {"tagName": "nightly"}]) + case _: + raise AssertionError(f"unexpected command: {command}") + + monkeypatch.setattr(release_module, "_run", fake_run) + + request = inspect_repository(tmp_path, explicit_version="1.2.4", bump=Bump.MINOR) + + assert request == ReleaseRequest( + project_version=Version.parse("1.2.3"), + explicit_version=Version.parse("1.2.4"), + bump=Bump.MINOR, + head_commit=HEAD, + tag_commits={Version.parse("1.2.2"): "b" * 40}, + published_versions=frozenset({Version.parse("1.2.2")}), + ) + + +@pytest.mark.parametrize(("release_count", "fails"), [(999, False), (1000, True)]) +def test_github_release_listing_must_be_complete( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + release_count: int, + fails: bool, +) -> None: + releases = json.dumps([{"tagName": "v1.2.3"}] * release_count) + monkeypatch.setattr(release_module, "_run", lambda *_args: releases) + + if fails: + with pytest.raises(ReleasePlanError, match="cannot prove release state"): + _read_published_versions(tmp_path) + else: + assert _read_published_versions(tmp_path) == frozenset({Version.parse("1.2.3")}) + + +def test_command_failure_is_fail_closed(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + def fail(*_args: object, **_kwargs: object) -> subprocess.CompletedProcess[str]: + raise subprocess.CalledProcessError(7, ["gh", "release"], stderr="denied") + + monkeypatch.setattr(subprocess, "run", fail) + + with pytest.raises(ReleasePlanError, match="gh release failed: denied"): + _run(tmp_path, "gh", "release") + + +def test_cli_writes_outputs( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + github_output = tmp_path / "github-output" + + def fake_inspection(root: Path, *, explicit_version: str, bump: Bump) -> ReleaseRequest: + assert root == Path.cwd() + assert explicit_version == "1.2.4" + assert bump is Bump.PATCH + return _request(explicit="1.2.4", tags={"1.2.3": "b" * 40}) + + monkeypatch.setattr(release_module, "inspect_repository", fake_inspection) + + result = main(["--version", "1.2.4", "--github-output", str(github_output)]) + + assert result == 0 + assert json.loads(capsys.readouterr().out)["mode"] == "create" + assert "tag=v1.2.4\n" in github_output.read_text(encoding="utf-8") + + +def test_cli_returns_two_for_unsafe_state( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + def fake_inspection(_root: Path, *, explicit_version: str, bump: Bump) -> ReleaseRequest: + assert explicit_version == "1.2.3" + assert bump is Bump.PATCH + return _request(explicit="1.2.3", tags={"1.2.3": "b" * 40}) + + monkeypatch.setattr(release_module, "inspect_repository", fake_inspection) + + assert main(["--version", "1.2.3"]) == 2 + assert "does not match the current release state" in capsys.readouterr().err diff --git a/tests/test_runtime_data.py b/tests/test_runtime_data.py index 1500c5e..bb37d4c 100644 --- a/tests/test_runtime_data.py +++ b/tests/test_runtime_data.py @@ -1,9 +1,20 @@ from __future__ import annotations -from custom_components.hypercolor.runtime_data import ConnectionState +from datetime import UTC, datetime, timedelta +from typing import Any, cast +import pytest -def test_connection_state_notifies_only_on_transitions() -> None: +from custom_components.hypercolor.api import ServerInfo +from custom_components.hypercolor.runtime_data import ( + ConnectionSource, + ConnectionState, + HypercolorRuntimeData, +) +from hypercolor import HypercolorNotFoundError + + +def test_connection_state_tracks_each_source_and_notifies_listeners() -> None: state = ConnectionState() notifications = 0 @@ -11,12 +22,163 @@ def listener() -> None: nonlocal notifications notifications += 1 - remove = state.async_add_listener(listener) - state.set_connected() - state.set_connected() - state.set_disconnected(ConnectionError("offline")) - state.set_disconnected(ConnectionError("offline")) - remove() - state.set_connected() + remove_listener = state.add_listener(listener) + state.set_connected(ConnectionSource.SNAPSHOT) + state.set_connected(ConnectionSource.SNAPSHOT) + state.set_connected(ConnectionSource.WEBSOCKET) + state.set_connected(ConnectionSource.WEBSOCKET) + state.set_disconnected(ConnectionSource.SNAPSHOT, RuntimeError("snapshot down")) + state.set_disconnected(ConnectionSource.SNAPSHOT, RuntimeError("snapshot down")) + + assert state.connected is True + assert state.last_connected_at is not None + assert state.last_disconnected_at is not None + assert state.last_error == "snapshot down" + assert notifications == 3 + assert state.snapshot()["sources"]["snapshot"]["connected"] is False + + remove_listener() + state.set_disconnected(ConnectionSource.WEBSOCKET, RuntimeError("socket down")) + assert notifications == 3 + assert state.connected is False + assert state.last_error == "socket down" + + +def test_connection_availability_uses_stable_source_outage_clocks() -> None: + state = ConnectionState() + assert state.is_connected(grace_s=5) is False + assert state.is_available(unavailable_after_s=5) is False + + state.set_disconnected(ConnectionSource.SNAPSHOT) + assert state.is_connected(grace_s=5) is False + assert state.is_source_connected(ConnectionSource.SNAPSHOT, grace_s=5) is False + + old = datetime.now(UTC) - timedelta(seconds=10) + state.set_connected(ConnectionSource.SNAPSHOT) + snapshot = state.sources[ConnectionSource.SNAPSHOT] + snapshot.last_connected_at = old + state.set_disconnected(ConnectionSource.SNAPSHOT) + + assert state.is_connected(grace_s=5) is True + assert state.is_available(unavailable_after_s=5) is True + + disconnected_at = snapshot.last_disconnected_at + state.set_disconnected(ConnectionSource.SNAPSHOT, RuntimeError("different error")) + assert snapshot.last_disconnected_at == disconnected_at + unavailable_in = state.source_unavailable_in(ConnectionSource.SNAPSHOT, 5) + assert unavailable_in is not None + assert 0 < unavailable_in <= 5 + + snapshot.last_disconnected_at = old + + assert state.is_connected(grace_s=5) is False + assert state.is_available(unavailable_after_s=5) is False + + state.set_connected(ConnectionSource.SNAPSHOT) + assert state.is_available(unavailable_after_s=0) is True + + +def test_source_connection_does_not_hide_websocket_loss_behind_rest_health() -> None: + state = ConnectionState() + state.set_connected(ConnectionSource.SNAPSHOT) + + assert state.is_source_connected(ConnectionSource.WEBSOCKET, grace_s=5) is False + + state.set_connected(ConnectionSource.WEBSOCKET) + state.set_disconnected(ConnectionSource.WEBSOCKET) + + assert state.is_connected() is True + assert state.is_source_connected(ConnectionSource.WEBSOCKET, grace_s=5) is True + assert state.is_available(unavailable_after_s=5) is True + + old = datetime.now(UTC) - timedelta(seconds=10) + state.sources[ConnectionSource.WEBSOCKET].last_disconnected_at = old + assert state.is_source_connected(ConnectionSource.WEBSOCKET, grace_s=5) is False + assert state.is_available(unavailable_after_s=5) is False + + +async def test_mutation_gateway_refreshes_successful_results() -> None: + coordinator = _Coordinator() + runtime = _runtime(coordinator) + + async def operation() -> str: + return "applied" + + assert await runtime.async_mutate(operation) == "applied" + assert coordinator.refreshes == 1 + + +async def test_mutation_gateway_preserves_operation_error_when_refresh_fails() -> None: + coordinator = _Coordinator(refresh_error=RuntimeError("refresh failed")) + runtime = _runtime(coordinator) + + async def operation() -> None: + raise ValueError("mutation failed") + + with pytest.raises(ValueError, match="mutation failed"): + await runtime.async_mutate(operation) + assert coordinator.refreshes == 1 + + +@pytest.mark.parametrize( + ("error", "raises"), + [ + (None, False), + (HypercolorNotFoundError("No effect is currently active"), False), + (HypercolorNotFoundError("Stop endpoint is unavailable"), True), + ], +) +async def test_stop_effect_normalizes_only_the_idle_response( + error: Exception | None, + raises: bool, +) -> None: + coordinator = _Coordinator() + client = _StopClient(error) + runtime = _runtime(coordinator, client=client) + + if raises: + with pytest.raises(HypercolorNotFoundError, match="Stop endpoint is unavailable"): + await runtime.async_stop_effect() + else: + await runtime.async_stop_effect() + + assert client.stop_calls == 1 + assert coordinator.refreshes == 1 + + +class _Coordinator: + def __init__(self, *, refresh_error: Exception | None = None) -> None: + self.data: Any = None + self.last_update_success = True + self.refresh_error = refresh_error + self.refreshes = 0 + + async def async_refresh(self) -> None: + self.refreshes += 1 + if self.refresh_error is not None: + raise self.refresh_error + + +class _StopClient: + def __init__(self, error: Exception | None) -> None: + self.error = error + self.stop_calls = 0 + + async def stop_effect(self) -> None: + self.stop_calls += 1 + if self.error is not None: + raise self.error + - assert notifications == 2 +def _runtime(coordinator: _Coordinator, *, client: object | None = None) -> HypercolorRuntimeData: + return HypercolorRuntimeData( + client=cast(Any, client or object()), + server=ServerInfo( + instance_id="srv-1", + instance_name="Hyperia", + version="0.3.2", + auth_required=True, + device_count=3, + ), + coordinator=cast(Any, coordinator), + ) diff --git a/tests/test_select.py b/tests/test_select.py deleted file mode 100644 index f1e7075..0000000 --- a/tests/test_select.py +++ /dev/null @@ -1,52 +0,0 @@ -from __future__ import annotations - -from types import SimpleNamespace - -from custom_components.hypercolor.select import HypercolorPresetSelect, _preset_option_map - - -def test_preset_options_keep_one_label_for_unique_names() -> None: - bundled = SimpleNamespace( - id="preset-bundled", - name="Soft", - effect_id="aurora", - origin="bundled", - editable=False, - ) - - assert _preset_option_map([bundled]) == {"Soft": bundled} - - -def test_preset_options_disambiguate_bundled_and_saved_names() -> None: - bundled = SimpleNamespace(id="preset-bundled", name="Soft", origin="bundled") - saved = SimpleNamespace(id="preset-saved", name="Soft", origin="saved") - - assert _preset_option_map([bundled, saved]) == { - "Soft (Built-in)": bundled, - "Soft (Saved)": saved, - } - - -def test_preset_options_hide_stack_from_stale_effect() -> None: - preset = SimpleNamespace(id="preset-bundled", name="Soft", origin="bundled") - entity = object.__new__(HypercolorPresetSelect) - entity.coordinator = SimpleNamespace(data={"preset_effect_id": "aurora", "presets": [preset]}) - entity._state = SimpleNamespace(data={"active_effect_id": "rainbow"}) - - assert entity.options == [] - - -def test_modified_preset_stays_selected_and_reports_derivation() -> None: - preset = SimpleNamespace(id="preset-soft", name="Soft", effect_id="aurora") - entity = object.__new__(HypercolorPresetSelect) - entity.coordinator = SimpleNamespace(data={"preset_effect_id": "aurora", "presets": [preset]}) - entity._state = SimpleNamespace( - data={ - "active_effect_id": "aurora", - "active_preset": "preset-soft", - "active_preset_modified": True, - } - ) - - assert entity.current_option == "Soft" - assert entity.extra_state_attributes == {"active_preset_modified": True} diff --git a/tests/test_sensor.py b/tests/test_sensor.py index 328f56b..00205b4 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -1,30 +1,41 @@ from __future__ import annotations from types import SimpleNamespace -from typing import Any +from typing import Any, cast -from custom_components.hypercolor.sensor import HypercolorFpsSensor, HypercolorRenderTimeSensor +from custom_components.hypercolor import sensor -def test_metrics_sensors_read_nested_daemon_payload() -> None: - coordinator = SimpleNamespace( - data={ - "fps": {"actual": 58.75, "target": 60}, - "frame_time": {"avg_ms": 4.25, "p95_ms": 7.5}, - }, - last_update_success=True, +async def test_metrics_entities_follow_channel_option(monkeypatch) -> None: + monkeypatch.setattr(sensor, "HypercolorActiveEffectSensor", lambda entry: "active") + monkeypatch.setattr(sensor, "HypercolorFpsSensor", lambda entry: "fps") + monkeypatch.setattr(sensor, "HypercolorRenderTimeSensor", lambda entry: "render_time") + monkeypatch.setattr(sensor, "HypercolorAudioEnergySensor", lambda entry: "audio") + entities: list[str] = [] + + await sensor.async_setup_entry( + cast(Any, None), + cast(Any, SimpleNamespace(options={})), + cast(Any, entities.extend), ) - entry: Any = SimpleNamespace( - data={"host": "hyperia", "port": 9420}, - runtime_data=SimpleNamespace( - server=SimpleNamespace( - instance_id="srv-1", - instance_name="Hyperia", - version="0.3.2", - ), - coordinators={"metrics": coordinator}, - ), + assert entities == ["active"] + + entities.clear() + await sensor.async_setup_entry( + cast(Any, None), + cast(Any, SimpleNamespace(options={"channels.metrics": True})), + cast(Any, entities.extend), ) + assert entities == ["active", "fps", "render_time"] + + +def test_nested_metrics_match_websocket_contract() -> None: + metrics = { + "fps": {"actual": 59.8}, + "frame_time": {"avg_ms": 4.2}, + } - assert HypercolorFpsSensor(entry).native_value == 58.75 - assert HypercolorRenderTimeSensor(entry).native_value == 4.25 + assert sensor._nested_number(metrics, "fps", "actual") == 59.8 + assert sensor._nested_number(metrics, "frame_time", "avg_ms") == 4.2 + assert sensor._nested_number(metrics, "fps", "missing") is None + assert sensor._nested_number({"fps": "unknown"}, "fps", "actual") is None diff --git a/tests/test_services.py b/tests/test_services.py index 7808686..61ce6ae 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -1,47 +1,37 @@ from __future__ import annotations +from collections.abc import Awaitable, Callable from types import SimpleNamespace -from typing import Any +from typing import Any, cast import pytest import voluptuous as vol from homeassistant.const import CONF_NAME from homeassistant.core import Context, HomeAssistant from homeassistant.exceptions import HomeAssistantError, Unauthorized -from homeassistant.helpers import config_validation as cv -from pytest_homeassistant_custom_component.common import MockUser +from pytest_homeassistant_custom_component.common import MockConfigEntry, MockUser from custom_components.hypercolor import services as services_module from custom_components.hypercolor.const import DOMAIN from custom_components.hypercolor.services import ( CONF_CONFIG_ENTRY_ID, SERVICE_APPLY_EFFECT, - _apply_effect, - _apply_preset, - _list_presets, - _list_zones, - _save_preset, - _schema, - _set_color, - _set_unassigned_behavior, - _set_zone, + SERVICE_APPLY_PRESET, + SERVICE_LIST_PRESETS, + SERVICE_SAVE_PRESET, + SERVICE_SET_COLOR, + SERVICE_SET_ZONE, _upload_effect, async_setup_services, ) - - -def test_service_schema_requires_mutation_fields() -> None: - schema = _schema({vol.Required("effect_id"): cv.string}) - - with pytest.raises(vol.MultipleInvalid, match="effect_id"): - schema({CONF_CONFIG_ENTRY_ID: "entry-1"}) +from hypercolor.models import EffectPreset, EffectPresetOrigin, Preset async def test_registered_services_reject_non_admin_users( hass: HomeAssistant, hass_read_only_user: MockUser, ) -> None: - async_setup_services(hass) + _entry(hass, _Runtime()) with pytest.raises(Unauthorized): await hass.services.async_call( @@ -53,189 +43,281 @@ async def test_registered_services_reject_non_admin_users( ) -async def test_apply_effect_can_route_to_preset() -> None: - client = _FakeClient() - call = _call(client, {"effect_id": "aurora", "preset_id": "preset-1"}) - - await _apply_effect(call) - - assert client.calls == [ - ("apply_effect_preset", ("aurora", "preset-1"), {"render_group": None}) - ] - - -async def test_apply_effect_rejects_unscoped_preset() -> None: - client = _FakeClient() - call = _call(client, {"preset_id": "preset-1"}) - - with pytest.raises(HomeAssistantError, match="effect_id is required"): - await _apply_effect(call) - - assert client.calls == [] - +async def test_registered_apply_effect_routes_and_refreshes(hass: HomeAssistant) -> None: + runtime = _Runtime() + entry = _entry(hass, runtime) -async def test_apply_preset_uses_effect_scoped_stack() -> None: - client = _FakeClient() - call = _call(client, {"effect_id": "aurora", "preset_id": "preset-1"}) - - await _apply_preset(call) - - assert client.calls == [("apply_effect_preset", ("aurora", "preset-1"), {})] - - -async def test_apply_effect_targets_zone() -> None: - client = _FakeClient() - call = _call(client, {"effect_id": "aurora", "zone_id": "zone-1"}) - - await _apply_effect(call) + await hass.services.async_call( + DOMAIN, + SERVICE_APPLY_EFFECT, + { + CONF_CONFIG_ENTRY_ID: entry.entry_id, + "effect_id": "aurora", + "zone_id": "zone-1", + }, + blocking=True, + ) - assert client.calls == [ + assert runtime.client.calls == [ ( "apply_effect", ("aurora",), - {"controls": None, "transition": None, "render_group": "zone-1"}, + { + "controls": None, + "transition": None, + "preset_id": None, + "render_group": "zone-1", + }, ) ] + assert runtime.refreshes == 1 + + +async def test_registered_apply_effect_routes_to_preset(hass: HomeAssistant) -> None: + runtime = _Runtime() + entry = _entry(hass, runtime) + + await hass.services.async_call( + DOMAIN, + SERVICE_APPLY_EFFECT, + { + CONF_CONFIG_ENTRY_ID: entry.entry_id, + "effect_id": "aurora", + "preset_id": "soft", + }, + blocking=True, + ) - -async def test_set_zone_scales_brightness_and_resolves_active_scene() -> None: - client = _FakeClient() - call = _call(client, {"zone_id": "zone-1", "brightness": 50, "enabled": True}) - - await _set_zone(call) - - assert client.calls == [ - ("get_active_scene", (), {}), - ("update_zone", ("scene-active", "zone-1"), {"brightness": 0.5, "enabled": True}), + assert runtime.client.calls == [ + ( + "apply_effect", + ("aurora",), + { + "controls": None, + "transition": None, + "preset_id": "soft", + "render_group": None, + }, + ) ] + assert runtime.refreshes == 1 -async def test_set_unassigned_behavior_builds_fallback_payload() -> None: - client = _FakeClient() - call = _call( - client, - {"behavior": "fallback", "fallback_zone_id": "zone-2", "scene_id": "scene-9"}, +async def test_registered_apply_preset_uses_effect_scoped_route( + hass: HomeAssistant, +) -> None: + runtime = _Runtime() + entry = _entry(hass, runtime) + + await hass.services.async_call( + DOMAIN, + SERVICE_APPLY_PRESET, + { + CONF_CONFIG_ENTRY_ID: entry.entry_id, + "effect_id": "aurora", + "preset_id": "soft", + }, + blocking=True, ) - await _set_unassigned_behavior(call) - - assert client.calls == [("set_unassigned_behavior", ("scene-9", {"fallback": "zone-2"}), {})] + assert runtime.client.calls == [("apply_effect_preset", ("aurora", "soft"), {})] + assert runtime.refreshes == 1 -async def test_list_zones_returns_jsonable_payload() -> None: - client = _FakeClient() - call = _call(client, {"scene_id": "scene-9"}) +async def test_registered_set_zone_uses_snapshot_scene(hass: HomeAssistant) -> None: + runtime = _Runtime() + entry = _entry(hass, runtime) - result = await _list_zones(call) + await hass.services.async_call( + DOMAIN, + SERVICE_SET_ZONE, + { + CONF_CONFIG_ENTRY_ID: entry.entry_id, + "zone_id": "zone-1", + "brightness": 50, + "enabled": True, + }, + blocking=True, + ) - assert result == { - "scene_id": "scene-9", - "groups_revision": 4, - "zones": [{"id": "zone-1", "name": "Desk", "role": "primary"}], - } + assert runtime.client.calls == [ + ( + "update_zone", + ("scene-active", "zone-1"), + {"name": None, "brightness": 0.5, "enabled": True, "make_primary": None}, + ) + ] + assert runtime.refreshes == 1 -async def test_set_color_applies_solid_color_effect() -> None: - client = _FakeClient() - call = _call(client, {"r": 128, "g": 255, "b": 0}) +async def test_registered_set_color_builds_hex_control(hass: HomeAssistant) -> None: + runtime = _Runtime() + entry = _entry(hass, runtime) - await _set_color(call) + await hass.services.async_call( + DOMAIN, + SERVICE_SET_COLOR, + {CONF_CONFIG_ENTRY_ID: entry.entry_id, "r": 128, "g": 255, "b": 0}, + blocking=True, + ) - assert client.calls == [ - ( - "apply_effect", - ("solid_color",), - {"controls": {"color": "#80ff00"}}, - ) + assert runtime.client.calls == [ + ("apply_effect", ("solid_color",), {"controls": {"color": "#80ff00"}}) ] -async def test_save_preset_uses_active_effect_when_not_supplied() -> None: - client = _FakeClient() - call = _call(client, {CONF_NAME: "Soft", "controls": {"speed": 40}}) - - result = await _save_preset(call) +async def test_registered_save_preset_uses_active_effect(hass: HomeAssistant) -> None: + runtime = _Runtime() + entry = _entry(hass, runtime) + + response = await hass.services.async_call( + DOMAIN, + SERVICE_SAVE_PRESET, + { + CONF_CONFIG_ENTRY_ID: entry.entry_id, + CONF_NAME: "Soft", + "controls": {"speed": 40}, + }, + blocking=True, + return_response=True, + ) - assert client.calls == [ + assert runtime.client.calls == [ ( "save_preset", ("Soft", "aurora"), {"description": None, "controls": {"speed": 40}, "tags": None}, ) ] - assert result == {"preset": {"id": "preset-1", "effect_id": "aurora"}} + assert response == { + "preset": { + "id": "preset-1", + "name": "Soft", + "description": None, + "effect_id": "aurora", + "controls": {}, + "tags": [], + "created_at_ms": None, + "updated_at_ms": None, + } + } -async def test_list_presets_filters_by_effect_id() -> None: - client = _FakeClient() - call = _call(client, {"effect_id": "aurora"}) +async def test_registered_list_presets_filters_without_mutating(hass: HomeAssistant) -> None: + runtime = _Runtime() + entry = _entry(hass, runtime) - result = await _list_presets(call) + response = await hass.services.async_call( + DOMAIN, + SERVICE_LIST_PRESETS, + {CONF_CONFIG_ENTRY_ID: entry.entry_id, "effect_id": "aurora"}, + blocking=True, + return_response=True, + ) - assert result == { - "presets": [ - { - "id": "preset-1", - "effect_id": "aurora", - "origin": "bundled", - "editable": False, - }, - ] - } - assert client.calls == [("get_effect_presets", ("aurora",), {})] + response_data = cast(dict[str, Any], response) + presets = cast(list[dict[str, Any]], response_data["presets"]) + assert [preset["id"] for preset in presets] == ["preset-1"] + assert presets[0]["origin"] == "bundled" + assert runtime.client.calls == [("get_effect_presets", ("aurora",), {})] + assert runtime.refreshes == 0 -async def test_list_presets_defaults_to_active_effect() -> None: - client = _FakeClient() - call = _call(client, {}) +async def test_registered_list_presets_defaults_to_active_effect( + hass: HomeAssistant, +) -> None: + runtime = _Runtime() + entry = _entry(hass, runtime) + + response = await hass.services.async_call( + DOMAIN, + SERVICE_LIST_PRESETS, + {CONF_CONFIG_ENTRY_ID: entry.entry_id}, + blocking=True, + return_response=True, + ) - await _list_presets(call) + response_data = cast(dict[str, Any], response) + presets = cast(list[dict[str, Any]], response_data["presets"]) + assert [preset["id"] for preset in presets] == ["preset-1"] + assert runtime.client.calls == [("get_effect_presets", ("aurora",), {})] - assert client.calls == [("get_effect_presets", ("aurora",), {})] +@pytest.mark.parametrize("service_name", [SERVICE_APPLY_EFFECT, SERVICE_APPLY_PRESET]) +def test_effect_scoped_service_schema_requires_effect_id( + hass: HomeAssistant, + service_name: str, +) -> None: + _entry(hass, _Runtime()) + service = hass.services.async_services()[DOMAIN][service_name] + assert service.schema is not None -async def test_upload_effect_accepts_inline_html() -> None: - client = _FakeClient() - call = _call(client, {"html": "", "file_name": "neon.html"}) + with pytest.raises(vol.MultipleInvalid, match="effect_id"): + service.schema( + { + CONF_CONFIG_ENTRY_ID: "entry-1", + "preset_id": "soft", + } + ) - result = await _upload_effect(call) - assert client.calls == [ - ("upload_effect", ("neon.html", ""), {}), - ] - assert result == {"effect": {"id": "user:neon"}} +def test_apply_effect_schema_rejects_fake_entity_target(hass: HomeAssistant) -> None: + _entry(hass, _Runtime()) + service = hass.services.async_services()[DOMAIN][SERVICE_APPLY_EFFECT] + assert service.schema is not None + with pytest.raises(vol.MultipleInvalid, match="entity_id"): + service.schema( + { + CONF_CONFIG_ENTRY_ID: "entry-1", + "effect_id": "aurora", + "entity_id": "light.hypercolor", + } + ) -async def test_upload_effect_rejects_path_outside_allowed_roots(tmp_path: Any) -> None: + +async def test_upload_effect_rejects_path_outside_allowed_roots( + hass: HomeAssistant, + tmp_path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + runtime = _Runtime() + entry = _entry(hass, runtime) path = tmp_path / "secret.html" path.write_text("") - call = _call(_FakeClient(), {"path": str(path)}) - call.hass.config = SimpleNamespace(is_allowed_path=lambda _: False) + monkeypatch.setattr(hass.config, "is_allowed_path", lambda _: False) with pytest.raises(HomeAssistantError, match="outside Home Assistant's allowed paths"): - await _upload_effect(call) + await _upload_effect(_call(hass, entry, {"path": str(path)})) -async def test_upload_effect_rejects_oversized_file_before_read(tmp_path: Any) -> None: +async def test_upload_effect_rejects_oversized_file_before_read( + hass: HomeAssistant, + tmp_path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + runtime = _Runtime() + entry = _entry(hass, runtime) path = tmp_path / "huge.html" path.write_bytes(b"x" * (1024 * 1024 + 1)) - call = _call(_FakeClient(), {"path": str(path)}) - call.hass.config = SimpleNamespace(is_allowed_path=lambda _: True) + monkeypatch.setattr(hass.config, "is_allowed_path", lambda _: True) with pytest.raises(HomeAssistantError, match="exceeds the 1 MiB"): - await _upload_effect(call) + await _upload_effect(_call(hass, entry, {"path": str(path)})) async def test_upload_effect_rejects_path_replacement_during_open( - tmp_path: Any, + hass: HomeAssistant, + tmp_path, monkeypatch: pytest.MonkeyPatch, ) -> None: + runtime = _Runtime() + entry = _entry(hass, runtime) path = tmp_path / "effect.html" outside = tmp_path / "secret.html" path.write_text("safe") outside.write_text("secret") - call = _call(_FakeClient(), {"path": str(path)}) - call.hass.config = SimpleNamespace(is_allowed_path=lambda _: True) + monkeypatch.setattr(hass.config, "is_allowed_path", lambda _: True) real_open = services_module.os.open def replace_then_open(file_path: Any, flags: int) -> int: @@ -246,7 +328,28 @@ def replace_then_open(file_path: Any, flags: int) -> int: monkeypatch.setattr(services_module.os, "open", replace_then_open) with pytest.raises(HomeAssistantError, match=r"Unable to read effect file|changed while"): - await _upload_effect(call) + await _upload_effect(_call(hass, entry, {"path": str(path)})) + + +class _Runtime: + def __init__(self) -> None: + self.client = _FakeClient() + self.refreshes = 0 + self.snapshot = SimpleNamespace( + state=SimpleNamespace( + active_effect_id="aurora", + active_scene=SimpleNamespace(id="scene-active"), + ) + ) + + async def async_mutate[ResultT]( + self, + operation: Callable[[], Awaitable[ResultT]], + ) -> ResultT: + try: + return await operation() + finally: + self.refreshes += 1 class _FakeClient: @@ -259,65 +362,45 @@ async def apply_effect(self, *args: Any, **kwargs: Any) -> None: async def apply_effect_preset(self, *args: Any, **kwargs: Any) -> None: self.calls.append(("apply_effect_preset", args, kwargs)) - async def save_preset(self, *args: Any, **kwargs: Any) -> dict[str, str]: + async def update_zone(self, *args: Any, **kwargs: Any) -> None: + self.calls.append(("update_zone", args, kwargs)) + + async def save_preset(self, *args: Any, **kwargs: Any) -> Preset: self.calls.append(("save_preset", args, kwargs)) - return {"id": "preset-1", "effect_id": "aurora"} + return Preset(id="preset-1", name="Soft", effect_id="aurora") - async def get_effect_presets(self, effect_id: str) -> list[dict[str, Any]]: + async def get_effect_presets(self, effect_id: str) -> list[EffectPreset]: self.calls.append(("get_effect_presets", (effect_id,), {})) return [ - { - "id": "preset-1", - "effect_id": effect_id, - "origin": "bundled", - "editable": False, - } + EffectPreset( + id="preset-1", + name="Soft", + effect_id=effect_id, + origin=EffectPresetOrigin.BUNDLED, + editable=False, + ) ] async def upload_effect(self, *args: Any, **kwargs: Any) -> dict[str, str]: self.calls.append(("upload_effect", args, kwargs)) return {"id": "user:neon"} - async def get_active_scene(self) -> Any: - self.calls.append(("get_active_scene", (), {})) - return SimpleNamespace(id="scene-active") - - async def update_zone(self, *args: Any, **kwargs: Any) -> None: - self.calls.append(("update_zone", args, kwargs)) - - async def set_unassigned_behavior(self, *args: Any, **kwargs: Any) -> None: - self.calls.append(("set_unassigned_behavior", args, kwargs)) - async def get_zones(self, scene_id: str) -> Any: - return SimpleNamespace( - groups_revision=4, - items=[{"id": "zone-1", "name": "Desk", "role": "primary"}], - ) - - -def _call(client: _FakeClient, data: dict[str, Any]) -> Any: - entry = SimpleNamespace( +def _entry(hass: HomeAssistant, runtime: _Runtime) -> MockConfigEntry: + entry = MockConfigEntry( domain=DOMAIN, - entry_id="entry-1", title="Hyperia", - runtime_data=SimpleNamespace( - client=client, - coordinators={ - "state": SimpleNamespace( - data={"active_effect": "Aurora", "active_effect_id": "aurora"} - ) - }, - ), - ) - hass = SimpleNamespace( - config_entries=SimpleNamespace(async_get_entry=lambda entry_id: entry), - async_add_executor_job=_run_executor_job, + unique_id="srv-1", + entry_id="entry-1", ) + entry.runtime_data = runtime + entry.add_to_hass(hass) + async_setup_services(hass) + return entry + + +def _call(hass: HomeAssistant, entry: MockConfigEntry, data: dict[str, Any]) -> Any: return SimpleNamespace( hass=hass, - data={CONF_CONFIG_ENTRY_ID: "entry-1", **data}, + data={CONF_CONFIG_ENTRY_ID: entry.entry_id, **data}, ) - - -async def _run_executor_job(func: Any, *args: Any) -> Any: - return func(*args)