diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..5e916e73 --- /dev/null +++ b/.env.example @@ -0,0 +1,10 @@ +# Copy this file to .env and adjust paths for your local setup. + +# Path to span-panel-api repo (for editable pip install) +export SPAN_PANEL_API_DIR=../span-panel-api + +# Path to span-card frontend repo (for build-frontend.sh) +export SPAN_CARD_DIR=../cards/span-card + +# Path to HA config directory (created automatically if missing) +export HA_CONFIG_DIR=./ha-config diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 9aff75db..04057bc9 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,6 +1,6 @@ version: 2 updates: - # Python dependencies managed by Poetry + # Python dependencies managed by uv - package-ecosystem: "pip" directory: "/" schedule: @@ -8,6 +8,39 @@ updates: day: "monday" time: "09:00" open-pull-requests-limit: 1 + allow: + - dependency-type: "all" + groups: + # Home Assistant core, stubs, and test component must upgrade together. + # A version mismatch between any of these causes resolution failures. + ha-core: + patterns: + - "homeassistant" + - "homeassistant-stubs" + - "pytest-homeassistant-custom-component" + # Group remaining development dependencies together + dev-dependencies: + patterns: + - "pytest*" + - "mypy" + - "ruff" + - "bandit" + - "prek" + - "isort" + - "pylint" + - "radon" + - "prettier" + - "python-direnv" + - "*-cov" + - "types-*" + - "voluptuous-stubs" + - "pyright" + exclude-patterns: + - "homeassistant" + - "homeassistant-stubs" + - "pytest-homeassistant-custom-component" + reviewers: + - "cayossarian" assignees: - "cayossarian" commit-message: @@ -17,15 +50,9 @@ updates: - "dependencies" - "python" ignore: - # Ignore major version updates for Home Assistant to prevent breaking changes - - dependency-name: "homeassistant" - update-types: ["version-update:semver-major"] # Ignore major version updates for span-panel-api since it's a local dependency - dependency-name: "span-panel-api" update-types: ["version-update:semver-major"] - # Ignore pytest-homeassistant-custom-component major updates to maintain compatibility - - dependency-name: "pytest-homeassistant-custom-component" - update-types: ["version-update:semver-major"] # GitHub Actions dependencies - package-ecosystem: "github-actions" @@ -35,6 +62,8 @@ updates: day: "monday" time: "09:00" open-pull-requests-limit: 1 + reviewers: + - "cayossarian" assignees: - "cayossarian" commit-message: diff --git a/.github/instructions/*.instructions.md b/.github/instructions/*.instructions.md new file mode 100644 index 00000000..bb017a10 --- /dev/null +++ b/.github/instructions/*.instructions.md @@ -0,0 +1,5 @@ +# Copilot Review Instructions + +- Before reviewing a PR examine any previous comments on the PR and the resolutions. +- Use those previous comments as the basis for the new review so that resolutions that were previously resolved and where code changes have not diverged are not + repeated unnecessarily. diff --git a/.github/workflows/ci-simulation-example.yml b/.github/workflows/ci-simulation-example.yml deleted file mode 100644 index 1e41d264..00000000 --- a/.github/workflows/ci-simulation-example.yml +++ /dev/null @@ -1,58 +0,0 @@ -name: CI with Simulation Tests - -on: - workflow_call: - -jobs: - lint-and-test: - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v6 - - - name: Set up Python - uses: actions/setup-python@v6 - with: - python-version: "3.13" - - - name: Install Poetry - uses: snok/install-poetry@v1 - - - name: Install dependencies - run: | - # Replace path dependencies with PyPI versions for CI - sed -i 's/span-panel-api = {path = "..\/span-panel-api", develop = true}/span-panel-api = "^1.1.5"/' pyproject.toml - sed -i 's/ha-synthetic-sensors = {path = "..\/ha-synthetic-sensors", develop = true}/ha-synthetic-sensors = "^1.1.1"/' pyproject.toml - # Regenerate lock file with the modified dependencies - poetry lock - poetry install --with dev - # Install bandit with TOML support - poetry run pip install 'bandit[toml]' - - - name: Format check with ruff - run: poetry run ruff format --check custom_components/span_panel - - - name: Lint with ruff - run: poetry run ruff check custom_components/span_panel - - - name: Type check with mypy - run: poetry run mypy custom_components/span_panel - - - name: Security check with bandit - run: poetry run bandit -c pyproject.toml -r custom_components/span_panel - - - name: Check poetry configuration - run: poetry check - - - name: Run pre-commit hooks (for extra validation) - run: poetry run pre-commit run --all-files --show-diff-on-failure - - # Regular tests - simulation tests are automatically skipped - - name: Run tests with coverage - run: poetry run pytest tests/ --cov=custom_components/span_panel --cov-report=xml --cov-report=term-missing - - # Optional: Run simulation tests separately if desired - - name: Run simulation tests - env: - SPAN_USE_REAL_SIMULATION: 1 - run: poetry run pytest tests/test_solar_configuration_with_simulator.py -v diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0da2a6c1..ebecee83 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,51 +6,53 @@ on: jobs: lint-and-test: runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.14"] steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@v7 - - name: Set up Python + - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v6 with: - python-version: "3.13" + python-version: ${{ matrix.python-version }} + allow-prereleases: true - - name: Install Poetry - uses: snok/install-poetry@v1 + - name: Install uv + uses: astral-sh/setup-uv@v7 + + - name: Install prek + run: curl --proto '=https' --tlsv1.2 -LsSf https://github.com/j178/prek/releases/latest/download/prek-installer.sh | sh - name: Install dependencies run: | - # Replace path dependencies with PyPI versions for CI - sed -i 's/span-panel-api = {path = "..\/span-panel-api", develop = true}/span-panel-api = "^1.1.13"/' pyproject.toml - sed -i 's/ha-synthetic-sensors = {path = "..\/ha-synthetic-sensors", develop = true}/ha-synthetic-sensors = "^1.1.13"/' pyproject.toml - # Regenerate lock file with the modified dependencies - poetry lock - poetry install --with dev - # Install bandit with TOML support - poetry run pip install 'bandit[toml]' + # Remove local path source overrides so uv resolves from PyPI + sed -i '/^\[tool\.uv\.sources\]/,/^$/d' pyproject.toml + uv lock + uv sync - name: Format check with ruff - run: poetry run ruff format --check custom_components/span_panel + run: uv run ruff format --check custom_components/span_panel - name: Lint with ruff - run: poetry run ruff check custom_components/span_panel + run: uv run ruff check custom_components/span_panel - name: Type check with mypy - run: poetry run mypy custom_components/span_panel + run: uv run mypy custom_components/span_panel - name: Security check with bandit - run: poetry run bandit -c pyproject.toml -r custom_components/span_panel - - - name: Check poetry configuration - run: poetry check + run: uv run bandit -c pyproject.toml -r custom_components/span_panel - - name: Run pre-commit hooks (for extra validation) - run: poetry run pre-commit run --all-files --show-diff-on-failure + - name: Run prek hooks (for extra validation) + run: prek run --all-files --show-diff-on-failure + env: + SKIP: "" - name: Run tests with coverage - run: poetry run pytest tests/ --cov=custom_components/span_panel --cov-report=xml --cov-report=term-missing + run: uv run pytest tests/ --cov=custom_components/span_panel --cov-report=xml --cov-report=term-missing - name: Upload coverage reports to Codecov - uses: codecov/codecov-action@v5 + uses: codecov/codecov-action@v7 with: slug: SpanPanel/span files: ./coverage.xml diff --git a/.github/workflows/dependabot-auto-approve.yml b/.github/workflows/dependabot-auto-approve.yml new file mode 100644 index 00000000..2a56ecf9 --- /dev/null +++ b/.github/workflows/dependabot-auto-approve.yml @@ -0,0 +1,32 @@ +name: Dependabot Auto-Approve + +on: + pull_request_target: + types: [opened, synchronize, reopened] + +permissions: + contents: read + pull-requests: write + +jobs: + auto-approve: + runs-on: ubuntu-latest + if: github.actor == 'dependabot[bot]' + steps: + - name: Dependabot metadata + id: metadata + uses: dependabot/fetch-metadata@v3.1.0 + with: + github-token: "${{ secrets.GITHUB_TOKEN }}" + + - name: Auto-approve patch and minor updates + if: ${{ steps.metadata.outputs.update-type == 'version-update:semver-patch' || steps.metadata.outputs.update-type == 'version-update:semver-minor' }} + uses: hmarr/auto-approve-action@v4 + with: + review-message: "Auto-approved ${{ steps.metadata.outputs.update-type }} update for ${{ steps.metadata.outputs.dependency-names }}" + + - name: Auto-approve GitHub Actions updates + if: ${{ steps.metadata.outputs.package-ecosystem == 'github_actions' }} + uses: hmarr/auto-approve-action@v4 + with: + review-message: "Auto-approved GitHub Actions update for ${{ steps.metadata.outputs.dependency-names }}" diff --git a/.github/workflows/depdendabot_auto_approve_and_merge b/.github/workflows/dependabot-auto-merge.yml similarity index 97% rename from .github/workflows/depdendabot_auto_approve_and_merge rename to .github/workflows/dependabot-auto-merge.yml index 42c13551..febf8f6e 100644 --- a/.github/workflows/depdendabot_auto_approve_and_merge +++ b/.github/workflows/dependabot-auto-merge.yml @@ -15,7 +15,7 @@ jobs: steps: - name: Dependabot metadata id: metadata - uses: dependabot/fetch-metadata@v2.4.0 + uses: dependabot/fetch-metadata@v3.1.0 with: github-token: "${{ secrets.GITHUB_TOKEN }}" diff --git a/.github/workflows/hacs.yml b/.github/workflows/hacs.yml index 373e7af7..85bffa86 100644 --- a/.github/workflows/hacs.yml +++ b/.github/workflows/hacs.yml @@ -21,7 +21,7 @@ jobs: name: Hassfest runs-on: "ubuntu-latest" steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - uses: "home-assistant/actions/hassfest@master" hacs: diff --git a/.gitignore b/.gitignore index dc0e7eba..c4cad8a5 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ xnotes/* !.vscode/tasks.json .git/ .direnv/ +docs/ env/ .envrc .env @@ -22,7 +23,16 @@ env/ # Coverage files .coverage .coverage.* +coverage_output.log .local_coverage_data htmlcov/ .pytest_cache/ docs/agile-readme.md +.github/copilot-instructions.md +.claude/ +.superpowers/ +docs/superpowers/ +.mcp.json + +# Docker dev environment +ha-config/ diff --git a/.markdownlint-cli2.jsonc b/.markdownlint-cli2.jsonc index 79d70b9d..71009aa0 100644 --- a/.markdownlint-cli2.jsonc +++ b/.markdownlint-cli2.jsonc @@ -14,7 +14,8 @@ "dist/**", "build/**", ".cursor/**", - "tests/**" + "tests/**", + "docs/superpowers/**" ], "config": { "default": true, diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml deleted file mode 100644 index 5a01a898..00000000 --- a/.pre-commit-config.yaml +++ /dev/null @@ -1,140 +0,0 @@ -repos: - # Pre-commit hooks for essential file checks only - - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v5.0.0 - hooks: - - id: check-yaml - exclude: '\..*_cache/.*|dist/.*|venv/.*' - - id: check-toml - exclude: '\..*_cache/.*|dist/.*|venv/.*' - - id: check-json - exclude: '\..*_cache/.*|dist/.*|venv/.*' - - id: check-added-large-files - - id: check-merge-conflict - exclude: '\..*_cache/.*|dist/.*|venv/.*' - - id: debug-statements - exclude: '^tests/.*|\..*_cache/.*|dist/.*|venv/.*' - - id: trailing-whitespace - exclude: '\..*_cache/.*|dist/.*|venv/.*' - - id: end-of-file-fixer - exclude: '\..*_cache/.*|dist/.*|venv/.*' - - id: mixed-line-ending - args: ['--fix=lf'] - exclude: '\..*_cache/.*|dist/.*|venv/.*' - - # Pylint check for import-outside-toplevel (run before ruff) - - repo: local - hooks: - - id: pylint-import-check - name: pylint import-outside-toplevel check - entry: poetry run pylint - language: system - args: ['--disable=all', '--enable=import-outside-toplevel', '--score=no', 'custom_components/span_panel/'] - pass_filenames: false - files: \.py$ - exclude: '^tests/.*|^scripts/.*' - - # Ruff for linting, import sorting, and primary formatting - - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.11.13 - hooks: - - id: ruff-format - exclude: '^tests/.*|scripts/.*|\..*_cache/.*|dist/.*|venv/.*' - - id: ruff-check - args: ['--fix'] - exclude: '^tests/.*|scripts/.*|\..*_cache/.*|dist/.*|venv/.*' - - # MyPy for type checking - - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.16.0 - hooks: - - id: mypy - additional_dependencies: - - httpx - - pydantic - - typing-extensions - - pytest - - homeassistant-stubs - - types-PyYAML - - types-aiofiles - args: ['--config-file=pyproject.toml'] - exclude: '^tests/.*|^scripts/.*|docs/.*|\..*_cache/.*|dist/.*|venv/.*' - - # Markdownlint for markdown files - - repo: https://github.com/DavidAnson/markdownlint-cli2 - rev: v0.18.1 - hooks: - - id: markdownlint-cli2 - args: ['--config', '.markdownlint-cli2.jsonc'] - exclude: '\..*_cache/.*|dist/.*|venv/.*|\.venv/.*|node_modules/.*|htmlcov/.*|tests/.*' - - # Markdown formatting using shared script (local only) - - repo: local - hooks: - - id: markdown-format - name: Format markdown files - entry: scripts/fix-markdown.sh - language: system - args: [.] - types: [markdown] - pass_filenames: false - always_run: true - stages: [manual] - - # Check for common security issues - - repo: https://github.com/PyCQA/bandit - rev: 1.8.3 - hooks: - - id: bandit - args: ['-c', 'pyproject.toml'] - additional_dependencies: ['bandit[toml]'] - exclude: '^tests/.*|^scripts/.*|\..*_cache/.*|dist/.*|venv/.*' - - # Poetry check for pyproject.toml validation - - repo: https://github.com/python-poetry/poetry - rev: 2.1.3 - hooks: - - id: poetry-check - - id: poetry-lock - - # Radon for code metrics and maintainability (local) - - repo: local - hooks: - - id: radon-complexity - name: radon complexity check - entry: poetry run radon - language: system - args: ['cc', '--min=B', '--show-complexity', 'custom_components/span_panel/'] - pass_filenames: false - files: \.py$ - - id: radon-maintainability - name: radon maintainability index - entry: poetry run radon - language: system - args: ['mi', '--min=B', '--show', 'custom_components/span_panel/'] - pass_filenames: false - files: \.py$ - - # Coverage check with pytest output and coverage report - - repo: local - hooks: - - id: pytest-cov-summary - name: coverage summary - entry: bash - language: system - args: ['-c', 'echo "Running tests with coverage..."; poetry run pytest tests/ --cov=custom_components/span_panel --cov-config=pyproject.toml --cov-report=term-missing:skip-covered -v; exit_code=$?; echo; if [ $exit_code -eq 0 ]; then echo "✅ Tests passed with coverage report above"; else echo "❌ Tests failed"; fi; exit $exit_code'] - pass_filenames: false - always_run: true - verbose: true - - # Sync dependencies from manifest.json to CI workflow (run last) - - repo: local - hooks: - - id: sync-dependencies - name: sync dependency versions between manifest.json and CI workflow - entry: python scripts/sync-dependencies.py - language: system - pass_filenames: false - always_run: true - stages: [pre-commit] - verbose: true diff --git a/.python-version b/.python-version index 3e388a4a..da717732 100644 --- a/.python-version +++ b/.python-version @@ -1 +1 @@ -3.13.2 +3.14.3 diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 6e342fa1..4b2df79c 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -6,8 +6,8 @@ "type": "shell", "command": "${workspaceFolder}/setup-hooks.sh", "presentation": { - "echo": true, - "reveal": "always", + "echo": false, + "reveal": "silent", "focus": false, "panel": "dedicated", "showReuseMessage": false, diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..18441d56 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,99 @@ +# Agent Instructions for SPAN Panel Integration + +## Permitted Operations + +The following operations are benign and should be executed without confirmation. Do not prompt the user for permission to: + +- Run tests (`pytest`, `python -m pytest`) +- Run linters and formatters (`ruff`, `mypy`, `markdownlint-cli2`, `prettier`) +- Compile or syntax-check Python (`python -c`, `python -m py_compile`) +- Activate or use the virtual environment (`.venv/bin/python`, `source .venv/bin/activate`) +- Run pre-commit hooks (`git commit` triggers hooks automatically) +- Run project scripts (`./scripts/fix-markdown.sh`) +- Standard git operations (`git status`, `git diff`, `git log`, `git add`, `git commit`) +- Package management (`poetry`, `uv`, `pip install`) +- File inspection (`ls`, `find`, `wc`, `head`, `tail`, `diff`, `cat`) + +### Frontend (`custom_components/span_panel/frontend`) + +The JS frontend lives in a separate `span-card` repository (there is no git submodule). Build artifacts are copied into +`custom_components/span_panel/frontend/dist/` via `scripts/build-frontend.sh`. The following operations are permitted without confirmation: + +- Build and copy the frontend (`./scripts/build-frontend.sh`) +- File inspection within `dist/` and config files + +## Frontend Build Workflow + +After making any changes to the span-card frontend source (`src/`), you MUST rebuild and copy the dist files into the integration before considering the work +complete. Run the build script from the integration repo: + +```bash +./scripts/build-frontend.sh /path/to/span-card +# or, if SPAN_CARD_DIR is set in the environment: +./scripts/build-frontend.sh +``` + +This builds the card (`npm run build`) and copies `span-panel-card.js` and `span-panel.js` into `custom_components/span_panel/frontend/dist/`. The updated dist +files must be staged and committed with the rest of the change. Skipping this step means the frontend changes will not be visible in Home Assistant. + +## Translation Workflow + +`strings.json` is the single source of truth for all translatable strings. When adding or updating any user-facing text (config flow steps, service names, +service field descriptions, error messages, etc.), always edit `strings.json` first. The pre-commit hook runs `scripts/sync_translations.py`, which copies +`strings.json` to `translations/en.json` and validates that all other language files have no missing or orphaned keys. Never edit `translations/en.json` +directly — it is generated automatically. + +## Documentation Artifacts + +When using superpowers, planning tools, spec generators, or other tools that produce design documents, plans, specs, or analysis artifacts, place, read, edit +those files in the `SpanPanel_Docs/span/docs/` folder in the workspace — not in this repository. Use the appropriate subfolder: + +- `SpanPanel_Docs/span/docs/superpowers/plans` — superpowers artifacts +- `SpanPanel_Docs/span/docs/dev/` — developer plans, specs, and design documents +- Do NOT create documents in the `span/docs` folders or subfolders + +Do not commit documentation artifacts to the `span` integration repo itself. + +## Service Registration Requirements + +All services registered by this integration MUST conform to the following requirements. These align with Home Assistant core's service architecture and ensure +consistent behavior, proper UI rendering, and correct error handling. + +### 1. Voluptuous Schema Validation + +Every service MUST have a `vol.Schema` passed to `hass.services.async_register`. Use `vol.Required` for mandatory fields and `vol.Optional` for optional ones. +Apply range/type validators (`vol.All`, `vol.Range`, `vol.Coerce`, `vol.In`) to constrain inputs at the schema level — do not defer validation to the handler. + +### 2. services.yaml Field Definitions + +Every service MUST have a corresponding entry in `services.yaml`. Each field MUST include a `selector` that matches its voluptuous type so the HA UI can render +the correct input widget. Required fields MUST be marked with `required: true`. Parameterless services still need an entry (the service name with no body). + +### 3. strings.json Translations + +Every service MUST have translations in `strings.json` under the `"services"` key: + +- `services..name` — human-readable service name +- `services..description` — what the service does +- `services..fields..name` — field label +- `services..fields..description` — field help text + +### 4. Error Handling with ServiceValidationError + +Service handlers MUST raise `ServiceValidationError` (not generic exceptions) for user-facing errors such as missing config entries or disabled features. Always +include `translation_domain=DOMAIN` and a `translation_key` so HA can localize the error message. + +### 5. Response Declaration + +Services that return data MUST declare `supports_response=SupportsResponse.ONLY` (or `SupportsResponse.OPTIONAL` if the service can also be used as a fire-and- +forget action). Services that perform actions without returning data omit `supports_response`. + +### 6. Runtime Data Guards + +Handlers that access `entry.runtime_data` MUST verify the entry is loaded and its runtime data is the expected type before accessing coordinator or other +runtime objects. Use `hasattr` and `isinstance` checks — never assume runtime data is present. + +### 7. Domain-Level Registration + +Services are registered once per HA instance in `async_setup` (not per config entry). Handlers that need entry-specific data iterate +`hass.config_entries.async_loaded_entries(DOMAIN)` to find the relevant entry. diff --git a/CHANGELOG.md b/CHANGELOG.md index b5e96570..a84ef89a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,16 +2,293 @@ All notable changes to this project will be documented in this file. -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to -[Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.0.8] - 5/2026 + +### Fixed + +- **Integration reconnects automatically after a panel firmware upgrade** — previously, if the panel renewed its security certificate (for example during a + firmware update), the integration could get stuck offline and require a manual reload from the Devices & Services page. It now recovers on its own. +- **Non-default panel ports now connect correctly** — panels configured to use a port other than the default are reached on the right port at startup. + +## [2.0.7] - 5/2026 + +### Fixed + +- **Door sensor no longer shows "Unavailable" when the panel reports an unknown door state** — The underlying firmware reports the door state as `UNKNOWN` + rather than `OPEN` or `CLOSED` on boot. The door sensor now correctly shows "Unknown" state instead of becoming unavailable. The same fix applies to the + grid-islandable and BESS connected binary sensors. + +- **Energy statistics no longer spike when the panel reconnects quickly after an integration reload** — If the panel came back online within ~1 second of a + reload (e.g. a brief network blip or panel restart), the dip compensation offset could fail to apply before the first coordinator update fired, causing HA + statistics to record the raw panel counter as a fresh counter-reset value and permanently inflate cumulative energy totals. The offset is now restored before + the coordinator listener is registered. + +- **Favorites view no longer goes blank** after returning to Home Assistant from a backgrounded browser tab. +- **Circuit names display fully on narrow displays** — the row folds to a second line when the name would otherwise truncate. +- **Favoriting an EVSE now shows it as a device card** instead of a circuit row, matching the By Panel view. + +### Changed + +- **Dashboard now ships its own frontend components** so it no longer breaks when Home Assistant migrates its internal UI library (per + [Frontend Component Updates 2026.4](https://developers.home-assistant.io/blog/2026/03/25/frontend-component-updates-2026.4)). No visual change; bundle grows + ~500 KB. + +## [2.0.6] - 4/2026 + +**Important** 2.0.x cautions still apply — read those carefully if not already on 2.0.x BEFORE proceeding: + +- Requires firmware `spanos2/r202603/05` or later (v2 eBus MQTT) +- You _must_ already be on v1.3.x or later of the SpanPanel/span integration if upgrading + +### Added + +- **By Activity and By Area views** — Two new circuit views available in both the integration panel and the Lovelace card (span-card 0.9.2): + - By Activity: circuits sorted by power consumption with expandable graphs and search filtering + - By Area: circuits grouped by Home Assistant area with live area registry updates + - Shared tab bar across panel and card with configurable text/icon style +- **Cross-panel Favorites view** (span-card 0.9.4) — A synthetic "Favorites" entry in the dashboard panel dropdown aggregates favorited circuits and sub-devices + (BESS, EVSE) across every configured SPAN panel into a single workspace. Heart toggles in the Graph Settings and per-circuit / per-sub-device side panels + persist favorites and the view to the integration storage so the Favorites view is reconstituted on restart. See the Favorites explanation in the frontend + dashboard link via the README.md. + +### Fixed + +- **Dashboard goes blank after idle** — Panel and card migrated to LitElement and refresh after losing focus (span-card 0.9.1) +- **Dashboard graph fidelity** — Circuit charts now use step interpolation instead of linear, eliminating misleading diagonal ramps between data points. + Continuous signals (PV solar output, BESS SoC/SoE) retain linear interpolation to faithfully represent their gradual behavior. +- **Panel status showing "Connected" while the panel is offline** — the panel status sensor now reflects the true connection state and updates within a second + of the panel going offline or coming back online (including the bump to span-panel-api v2.6.2) + +## [2.0.5] - 4/2026 + +**Important** 2.0.x cautions still apply — read those carefully if not already on 2.0.x BEFORE proceeding: + +- Requires firmware `spanos2/r202603/05` or later (v2 eBus MQTT) +- You _must_ already be on v1.3.x or later of the SpanPanel/span integration if upgrading + +### Added + +- **Current monitoring and dashboard** — Real-time monitoring of circuit and mains current draw, managed from a new sidebar panel with Panel, Monitoring, and + Settings tabs. + - Configurable spike and continuous overload thresholds (percentage of breaker rating, window duration, cooldown) + - Per-circuit and per-mains-leg threshold overrides with reset-to-global + - Notification targets and device trackers + - Persistent HA notifications and event bus alerts + - Customizable notification title and message templates with placeholder substitution + - Breaker grid view with live utilization indicators, shedding icons, and per-circuit side panel + +- **Frontend i18n** — Dashboard panel and card editor translated into English, Spanish, French, Japanese, and Portuguese. + +- **Local brand images** — Icon and logo assets are now shipped inside the integration (`brand/` directory) instead of relying on the Home Assistant brands CDN. + Requires Home Assistant 2026.3 or later. + +### Changed + +- **Services use entity IDs** — Monitoring services accept entity IDs instead of internal circuit UUIDs, matching HA conventions. +- **`span-panel-api` updated to 2.5.1** — Improved HTTP connection handling and performance. +- **`span-card`** no longer needs to be loaded through a custom HACS repository; it is loaded by the integration and can be embedded into dashboards. If using + the `span-card` separately from the built-in dashboard, remove the custom resource. + +### Fixed + +- **Circuit switch toggle bounce** — Toggling a breaker switch no longer bounces (changes → reverts → settles). + +- **Breaker rating and nameplate capacity sensors** — Corrected device classes on breaker ratings (main and per-circuit) and BESS/PV nameplate capacity sensors. + These are static configuration values that rarely change, so they are now disabled by default in new installs to reduce recorder writes. The data is still + available via the panel topology service; enable the sensors from entity settings if you need them in dashboards or automations. + +## [2.0.4] - 3/2026 + +**Important** 2.0.1 cautions still apply — read those carefully if not already on 2.0.1 BEFORE proceeding: + +- Requires firmware `spanos2/r202603/05` or later (v2 eBus MQTT) +- You _must_ already be on v1.3.x or later of the SpanPanel/span integration if upgrading + +### Added + +- **Grid Power sensor** — New `Grid Power`. Previously only `Current Power` (upstream lugs measurement) was available; the new sensor surfaces the panel's own + grid power accounting alongside Battery Power, PV Power, and Site Power. Without BESS `Grid Power` is the same as `Current Power`. Note that if your panel has + an integrated BESS and the BESS loses communication with the panel the Grid Power sensor is not accurate. In such a case HA would need a current clamp + upstream of the BESS to accurately reflect whether the Grid is up. +- **FQDN registration support** — Config flow detects FQDN-based connections and registers the domain with the panel for TLS certificate SAN inclusion. Blocked + by an upstream API permission issue ([SPAN-API-Client-Docs#10](https://github.com/spanio/SPAN-API-Client-Docs/issues/10)); the integration falls back to + IP-based connections until resolved. + +### Changed + +- **Simulation moved to dedicated add-on** — Panel cloning and simulation are no longer part of the integration's options flow. A new `export_circuit_manifest` + service provides panel parameters to the standalone [SPAN Panel Simulator](https://github.com/SpanPanel/simulator) add-on, which now supports upgrade + modelling (evaluate firmware or integration upgrades in a sandbox before applying them to your real panel) and panel clone (replicate your panel's circuit + layout for testing). + +### Fixed + +- **MQTT broker connection** — The eBus broker connection now uses the panel host from zeroconf discovery or user configuration instead of the panel-advertised + `.local` address, which may not resolve in all HA environments (#193). + +- **PV nameplate capacity unit** — Corrected the PV nameplate capacity sensor unit to watts. + +- **Recorder database growth** — Energy sensors still expose grace-period and dip-compensation diagnostics, plus circuit `tabs` and `voltage`, on the entity, + but those attributes are no longer written to the recorder, which greatly reduces churn in the `state_attributes` table (#197). + +## [2.0.3] - 3/2026 + +**Important** 2.0.1 cautions still apply — read those carefully if not already on 2.0.1 BEFORE proceeding: + +- Requires firmware `spanos2/r202603/05` or later (v2 eBus MQTT) +- You _must_ already be on v1.3.x or later of the SpanPanel/span integration if upgrading + +### Fixed + +- **Force dependency re-resolution** — Version bump to ensure HACS re-installs `span-panel-api` for users who had the earlier 2.0.2 release. Users upgrading HA + without re-downloading the integration could be left with a stale library missing required imports. (#191) + +## [2.0.2] - 3/2026 + +**Important** 2.0.1 cautions still apply — read those carefully if not already on 2.0.1 BEFORE proceeding: + +- Requires firmware `spanos2/r202603/05` or later (v2 eBus MQTT) +- You _must_ already be on v1.3.x or later of the SpanPanel/span integration if upgrading + +### Fixed + +- **Panel size always available** — `panel_size` is now sourced from the Homie schema by the underlying `span-panel-api` Previously some users could see fewer + unmapped sensors when trailing breaker positions were empty. Topology service reflects panel size. +- **Battery power sign inverted** — Battery power sensor now uses the correct sign convention. Previously, charging was reported as positive and discharging as + negative, which caused HA energy cards to show the battery discharging when it was actually charging. The panel reports power from its own perspective; the + sensor now negates the value to match HA conventions (positive = discharging), consistent with how PV power is already handled. (#184) +- **Idle circuits showing -0W** — Power sensors that negate values (PV circuits, battery, PV power) could produce IEEE 754 negative zero (`-0.0`) when the + circuit was idle, causing HA to display `-0W` instead of `0W`. All negation sites now normalize zero to positive. (#185) +- **Net energy inconsistent with dip-compensated consumed/produced** — When energy dip compensation was enabled, consumed and produced sensors applied an offset + but net energy computed from raw snapshot values, causing a visible mismatch. Net energy now reads dip offsets from its sibling sensors so the displayed value + always equals compensated consumed minus compensated produced. + +## [2.0.1] - 3/2026 + +⚠️ **STOP — If your SPAN panel is not on firmware `spanos2/r202603/05` or later, do not upgrade. Ensure you are on v1.3.0 or later BEFORE upgrading to 2.0. This +upgrade migrates to the SPAN official eBus API. Make a backup first.** ⚠️ + +### Breaking Changes + +- Requires firmware `spanos2/r202603/05` or later (v2 eBus MQTT) +- You _must_ already be on v1.3.0 or later of the SpanPanel/span integration if upgrading +- After upgrading, you must re-authenticate using your **panel passphrase** (found in the SPAN mobile app under On-premise settings) or **proof of proximity** + (open and close the panel door 3 times). See the [README](README.md) for details. +- If you were running a beta or RC, ensure you reload the integration after upgrade +- `Cellular` binary sensor removed — replaced by `Vendor Cloud` sensor +- `DSM Grid State` deprecated — still available, but users should rely on `DSM State` as `DSM Grid State` may be removed in a future version since it is an + alias for `DSM State` +- **Sensor state values are now lowercase** — The following sensors now report lowercase state values with translated display names. Automations or scripts that + compare against the old uppercase values must be updated: + - `DSM State`: `DSM_ON_GRID` → `dsm_on_grid`, `DSM_OFF_GRID` → `dsm_off_grid` + - `DSM Grid State`: same as DSM State (deprecated alias) + - `Current Run Config`: `PANEL_ON_GRID` → `panel_on_grid`, `PANEL_OFF_GRID` → `panel_off_grid` + - `Main Relay State`: `CLOSED` → `closed`, `OPEN` → `open` + + The UI displays localized names (e.g., `dsm_on_grid` displays as "On Grid"). Automations use the lowercase values shown above. This change was made to support + translations in enumerations. + +### New Features + +- **EVSE (SPAN Drive) Support**: Each commissioned EV charger appears as a sub-device (e.g., "Main House SPAN Drive (Garage)") +- **BESS sub-device**: Battery entities live on a dedicated BESS sub-device +- **Energy Dip Compensation**: Automatically compensates when the panel reports lower energy readings for `TOTAL_INCREASING` sensors, maintaining a cumulative + offset to prevent negative spikes in the energy dashboard. Enabled by default for new installs; existing installs can enable via General Options. Includes + diagnostic attributes (`energy_offset`, `last_dip_delta`) and persistent notifications. +- Real-time MQTT push via eBus broker — no more polling intervals +- **Grid Forming Entity (GFE) sensor** — shows the panel's current grid-forming power source (GRID, BATTERY, PV, GENERATOR, NONE, UNKNOWN). Identifies which + source provides the frequency and voltage reference. +- **GFE Override button** — publishes a temporary `GRID` override when the battery system (BESS) loses communication and the GFE value becomes stale. The BESS + automatically reclaims control when communication is restored. See [Grid Forming Entity](README.md#grid-forming-entity) for details +- `Site Power` sensor (grid + PV + battery from power-flows node) +- **Panel diagnostic sensors**: L1/L2 Voltage, Upstream/Downstream L1/L2 Current, Main Breaker Rating — promoted from attributes to dedicated diagnostic + entities +- **Circuit Current and Breaker Rating sensors**: promoted from circuit power sensor attributes to dedicated per-circuit entities (conditionally created when + the panel reports the data) +- **PV metadata sensors**: PV Vendor, PV Product, Nameplate Capacity — on the main panel device (conditionally created when PV is commissioned) +- **Grid Islandable binary sensor**: indicates whether the panel can island from the grid (conditionally created) +- `PV Power` sensor with inverter metadata attributes (vendor, product, nameplate capacity) +- **Reconfigure flow** — update the panel host/IP address without removing and re-adding the integration. +- Circuit Shed Priority select now works — controls off-grid shedding (NEVER / SOC_THRESHOLD / OFF_GRID) +- Panel size and Wi-Fi SSID as software version attributes + +### Removed + +- Post-install entity naming pattern switching — the naming pattern is now set once during initial setup. The `EntityIdMigrationManager` and all associated + migration machinery have been removed +- `cleanup_energy_spikes` and `undo_stats_adjustments` services — energy dip compensation handles counter dips automatically. For existing historical spikes, + use Developer Tools > Statistics to adjust individual entries + +### Developer / Card Support + +- **WebSocket Topology API**: New `span_panel/panel_topology` WebSocket command that returns the full physical layout of a panel in a single call — circuits + with breaker slot positions, entity IDs grouped by role, and sub-devices (BESS, EVSE) with their entities. See [WebSocket API Reference](websocket-api.md) for + schema and examples + +### Improvements + +- `DSM State` — multi-signal heuristic deriving grid connectivity from battery grid-state, dominant power source, upstream lugs power, and power-flows grid +- `Current Run Config` — full tri-state derivation (PANEL_ON_GRID / PANEL_OFF_GRID / PANEL_BACKUP) +- Configurable snapshot update interval (0–15s, default 1s) reduces CPU on low-power hardware + +## [1.3.1] - 2026-01-19 + +### 🐛 Bug Fixes + +- **Fix reload loop when circuit name is None (#162)**: Fixed infinite reload loop that caused entity flickering when the SPAN panel API returns None for + circuit names. Uses sentinel value to distinguish between "never synced" and "circuit name is None" states. When circuit name is None, entity name is set to + None allowing HA to use default naming behavior. Thanks to @NickBorgers for reporting and correctly analyzing a solution. @cayossarian. + +- **Fix spike cleanup service not finding legacy sensor names (#160)**: The `cleanup_energy_spikes` service now correctly finds sensors regardless of naming + pattern (friendly names, circuit numbers, or legacy names without `span_panel_` prefix). Also adds optional `main_meter_entity_id` parameter allowing users to + manually specify the spike detection sensor when auto-detection of main meter fails or that sensor has been renamed. Thanks to @mepoland for reporting. + @cayossarian. + +### 🔧 Improvements + +- **Respect user-customized entity names**: When a user has customized an entity's friendly name in Home Assistant, the integration skips name sync for that + entity. @cayossarian + +## [1.3.0] - 2025-12-31 + +### 🔄 Changed + +- **Bump span-panel-api to v1.1.14**: Recognize panel Keep-Alive at 5 sec, handle httpx.RemoteProtocolError defensively. Thanks to + @NickBorgersOnLowSecurityNode. + +## [1.2.9] - 2025-12-25 + +### ✨ New Features + +- **Energy Spike Cleanup Service**: New `span_panel.cleanup_energy_spikes` service to detect and remove negative energy spikes from Home Assistant statistics + caused by panel firmware updates. Includes dry-run mode for safe preview before deletion. +- **Firmware Reset Detection (Beta)**: Monitors the main meter energy sensor for errant decreases (negative energy deltas over time). Sends a persistent + notification when detected, guiding users to adjust statistics if desired. + +### 🔄 Changed + +- **Removed Decreasing Energy Protection**: Reverted the TOTAL_INCREASING validation that was ignoring decreasing energy values that were thought to occur + during a limited number of updates but turned out to be permanent under-reporting of SPAN cloud data that manifested during firmware updates. The bug is on + the SPAN side and can result in spikes in energy dashboards after firmware updates. See the Trouble-Shooting section of the README.md for more information. + +### 📝 Notes + +- A future release may implement local energy calculation from power values to eliminate both the freezing issue and negative spikes. + +## [1.2.8] - 2025-12-10 + +### 🔧 Technical Improvements + +- **Fix total increasing sensors** against receiving data that is less than previously reported +- **Fix feedthrough sensor types** now set to TOTAL instead of TOTAL_INCREASING ## [1.2.7] - 2025-11-29 ### 🔧 Technical Improvements - **Offline Listener Fix**: Fixed simulation listener to prevent being called when not in simulation mode -- **Grace Period Restoration**: Fixed grace period algorithm to properly restore previous good values from Home Assistant statistics on - restart, ensuring energy sensors report accurately after system restarts +- **Grace Period Restoration**: Fixed grace period algorithm to properly restore previous good values from Home Assistant statistics on restart, ensuring energy + sensors report accurately after system restarts - **CI/CD Dependencies**: Updated GitHub Actions checkout action to version 6 ## [1.2.6] - 2025-09-XX @@ -24,7 +301,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - **Circuit Naming Logic**: Fixed logic for circuit-naming patterns to ensure proper entity ID generation and panel prefixes (fresh installs only) - **Entity ID naming Choices**: Restored the ability to change entity ID naming patterns in live panels (circuit tab-based sensors only, not panel) - **Panel Friendly Name Sync**: Fixed regression in panel circuit name synchronization. A new install will sync all friendly names once on the first refresh and - anytime a user changes a name in the SPAN App changes the name in the mobile/SPAN App. + anytime a user changes a name in the SPAN mobile app. - **API Optimization**: Removed unnecessary signal updates to improve performance and reduce overhead - **API Dependencies**: Updated span-panel-api OpenAPI package to version 1.1.13 to remove the cache - **Resolve Cache Config Entry Defect**: Fixed an issue where a 1.2.5 config entry could attempt to set up a cache window in the underlying OpenAPI library that @@ -34,8 +311,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### 🔧 Technical Improvements -- **Circuit Based Naming**: Circuit based entity_id naming was not using both tabs in the name. Existing entity_id's are unchnaged except fresh installs. -- **Switches and Selects Naming**: were creating proper ID's but not looking up migration names in 1.2.4 +- **Circuit Based Naming**: Circuit based entity_id naming was not using both tabs in the name. Existing entity IDs are unchanged except for fresh installs. +- **Switches and Selects Naming**: were creating proper IDs but not looking up migration names in 1.2.4 ## [1.2.4] - 2025-09-XX @@ -44,10 +321,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - **Performance**: Revert to native sensors (non-synthetic) to avoid calculation engine for simple math. Features like net energy, OpenAPI, simulation are still present. We may reintroduce the synthetic engine later in a modified form to allow users to add attributes, etc. - **Fix sensor circuit-based naming**: For new installations with circuit naming provide consistent behavior where all circuits, other than panel have circuit - names related to the tab (120V) or tabs (240V). We do not modify entity ID's so if an installation had faulty names from a previous release those must be + names related to the tab (120V) or tabs (240V). We do not modify entity IDs, so if an installation had faulty names from a previous release those must be renamed manually - **Fix Faulty Legacy Single Panel Config**: Provided a repair for a pre-1.0.4 upgraded release where the config entry was missing the device unique ID (serial - number) causing the new migration for normalized unique keys to fail. This repair only works for single panel installs because we derive the serial number + number), causing the new migration for normalized unique keys to fail. This repair only works for single panel installs because we derive the serial number from the entities and if more than one serial number is found we cannot determine which config the serial number would match. - **Fixed Unmapped Tab Behavior for Offline Panel**: Unmapped tab sensors reported erroneous values when the panel was offline @@ -66,8 +343,8 @@ we've implemented migration logic to preserve your existing entities and automat known good value for a grace period - **Voltage and Amperage Attributes**: Added attributes for voltage and amperage to each power sensor for threshold automations - **Panel Tabs Attributes**: Added attribute to each sensor to see the specific panel tabs (spaces) associated with sensor -- **Unmapped Tab Sensors**: Added hidden circuits for tabs that are no part of a circuit reported directly by the panel. The user may make these tabs sensors - visisble. +- **Unmapped Tab Sensors**: Added hidden circuits for tabs that are not part of a circuit reported directly by the panel. The user may make these tab sensors + visible. - **Panel Offline Sensor**: Added a sensor that indicates whether the panel is offline (cannot return data to the integration) - **State Visibility**: Attributes show you the formula used in the sensor calculation for grace periods and net energy - **Net Energy Sensors**: New net energy sensors calculate `consumed energy - produced energy` for circuits, panels, and tab-based solar installations, @@ -129,7 +406,7 @@ we've implemented migration logic to preserve your existing entities and automat ### 🔄 HACS Upgrade Process -This integration should handle migrating your entities seamlessly. Any entity ID's or names should be retained. We do migrate all the unique keys by properly +This integration should handle migrating your entities seamlessly. Any entity IDs or names should be retained. We do migrate all the unique keys by properly renaming these in the entity registry so the user should not see any difference. - **Backup Instructions**: Check backup requirements before upgrade diff --git a/README.md b/README.md index d3182ae7..de316de8 100644 --- a/README.md +++ b/README.md @@ -8,278 +8,465 @@ monitoring and control of your home's electrical system. [![GitHub Activity](https://img.shields.io/github/commit-activity/y/SpanPanel/span.svg?style=flat-square)](https://github.com/SpanPanel/span/commits) [![License](https://img.shields.io/github/license/SpanPanel/span.svg?style=flat-square)](LICENSE) -[![Python](https://img.shields.io/badge/python-3.13.2-blue.svg)](https://www.python.org/downloads/release/python-3132/) +[![Python](https://img.shields.io/badge/python-3.14.2-blue.svg)](https://www.python.org/downloads/) [![Ruff](https://img.shields.io/badge/code%20style-ruff-000000.svg)](https://github.com/astral-sh/ruff) [![Mypy](https://img.shields.io/badge/mypy-checked-blue)](http://mypy-lang.org/) [![prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg)](https://github.com/prettier/prettier) -[![pre-commit](https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit&logoColor=white)](https://github.com/pre-commit/pre-commit) +[![prek](https://img.shields.io/badge/prek-enabled-brightgreen)](https://github.com/j178/prek) -This integration relies on the OpenAPI interface contract sourced from the SPAN Panel. The integration may break if SPAN changes the API in an incompatible way. +The software is provided as-is with no warranty or guarantee of performance or suitability to your particular setting. -We cannot provide technical support for either SPAN or your home's electrical system. The software is provided as-is with no warranty or guarantee of -performance or suitability to your particular setting. +**IMPORTANT:** This integration controls real electrical equipment. Circuit switches open and close physical relays. The GFE override button changes how the +panel manages load shedding during power outages. These actions carry the same consequences as operating the panel manually — because they are. Automations can +execute these actions without user presence; design them with the same care you would apply to any unattended electrical control. This integration is not a +safety device and must not be relied upon for life-safety applications. Use this software at your own risk. If you cannot accept that risk, do not use this +software. See [LICENSE](LICENSE) for the full warranty disclaimer. -This integration provides the user with sensors and controls that are useful in understanding an installation's power consumption, energy usage, and the ability -to control user-manageable panel circuits. +The SPAN Client documentation has warnings regarding the use of the API (the API used by this integration) which should be heeded just as if you were using that +API directly: -## What's New +> An API client that attempts to implement its own load-shedding decisions, grid-state detection, or other critical automation is operating outside the scope of +> what SPAN API was designed and engineered for. Such use is entirely at the client developer's and homeowner's own risk and may void the SPAN Panel Limited +> Warranty. See the SPAN API Scope & Responsibility Model in the [SPAN API documentation](https://github.com/spanio/SPAN-API-Client-Docs). -### Major Upgrade +This integration provides sensors and controls for understanding an installation's power consumption, energy usage, and controlling user-manageable panel +circuits. -**Before upgrading to version 1.2.x, please backup your Home Assistant configuration and database.** This version introduces significant architectural changes. -While we've implemented migration logic to preserve your existing entities and automations, it's always recommended to have a backup before major upgrades. +The integration includes a built-in dashboard accessible from the Home Assistant sidebar, providing real-time circuit-level power visualization, current +monitoring with configurable alerts, and circuit settings for relays and load shedding. See [Frontend Dashboard](frontend.md) for details. You can optionally +use the [span-card](https://github.com/SpanPanel/span-card) Lovelace card for visualization and switch control. -**OpenAPI Support**: The integration now uses the OpenAPI specification provided by the SPAN panel. This change provides a reliable foundation for future -interface changes but some users have reported that newer panels might have closed off the interface (see trouble shooting). If and when SPAN provides -additional support we may adapt. +The [SPAN Panel Simulator](https://github.com/SpanPanel/simulator) HA App lets you clone your panel's circuit layout for testing, or model an upgrade to +evaluate firmware or integration changes in a sandbox before applying them to your real panel. -**New Features**: This version introduces net energy calculations, simulation support, configurable timeouts, SSL support, circuit name sync, and flexible -entity naming patterns. See the [CHANGELOG.md](CHANGELOG.md) for detailed information about all new features and improvements. +This integration communicates with the SPAN Panel over your local network using SPAN's official +[Electrification Bus (eBus)](https://github.com/spanio/SPAN-API-Client-Docs) framework — an open, multi-vendor integration standard for home energy +infrastructure. eBus uses the [Homie Convention](https://homieiot.github.io/) for MQTT topics and messages, with the panel's built-in MQTT broker delivering +real-time state updates without polling. -### HACS Upgrade Process +## 1.1.x Integration Sunset (v1) -When upgrading through HACS, you'll see a notification about the new version. Before clicking "Update": +Users MUST upgrade by the end of 2026 to avoid disruption. Upgrade to the latest 1.1.x version BEFORE upgrading to 2.0.x. -1. **Create a backup** of your Home Assistant configuration and database -2. **Review the changes** in this README -3. **Check your automations** to ensure they reference the correct entity IDs -4. **Update during a quiet period** when you can monitor the upgrade process +## 2.0.x Breaking Changes (v2) + +**Do NOT upgrade unless your panel is running firmware `spanos2/r202603/05` or later.** + +**What you need:** + +- SPAN Panel firmware `spanos2/r202603/05` or later +- Panel passphrase (found in the SPAN mobile app, On-premise settings) **or** physical access to the panel door for proof-of-proximity authentication -If you encounter any issues during the upgrade, you can: +**Breaking:** -- Restore from your backup -- Check the [troubleshooting section](#troubleshooting) below -- Open an issue on GitHub with details about your installation +- Requires firmware `spanos2/r202603/05` or later — panels on older firmware will not work +- `Cellular` binary sensor removed — replaced by `Vendor Cloud` sensor + +> Running older firmware? See [v1 Legacy Documentation](v1-legacy.md). + +See [CHANGELOG.md](CHANGELOG.md) for all additions or value changes. ## Prerequisites - [Home Assistant](https://www.home-assistant.io/) installed - [HACS](https://hacs.xyz/) installed -- SPAN Panel installed and connected to your network -- SPAN Panel's IP address - -## Features - -### Available Devices & Entities - -This integration provides a Home Assistant device for your SPAN panel with entities for: - -- User Managed Circuits - - On/Off Switch (user managed circuits) - - Priority Selector (user managed circuits) -- Power Sensors - - Power Usage / Generation (Watts) - - Energy Usage / Generation (Wh) - - Net Energy (Wh) - Calculated as consumed energy minus produced energy -- Panel and Grid Status - - Main Relay State (e.g., CLOSED) - - Current Run Config (e.g., PANEL_ON_GRID) - - DSM State (e.g., DSM_GRID_UP) - - DSM Grid State (e.g., DSM_ON_GRID) - - Network Connectivity Status (Wi-Fi, Wired, & Cellular) - - Door State (device class is tamper) -- Storage Battery - - Battery percentage (options configuration) +- SPAN Panel with firmware `spanos2/r202603/05` or later +- SPAN Panel integration v1.3.0 or later +- Panel passphrase (found via the SPAN app) **or** physical access to the panel door ## Installation 1. Install [HACS](https://hacs.xyz/) 2. Go to HACS in the left side bar of your Home Assistant installation 3. Search for "Span" -4. Open the repository -5. Click on the "Download" button at the lower right -6. Restart Home Assistant - You will be prompted for this by a Home Assistant repair notification -7. In the Home Assistant UI go to `Settings`. -8. Click `Devices & Services` and you should see this integration. -9. Click `+ Add Integration`. -10. Search for "Span". This entry should correspond to this repository and offer the current version. -11. Enter the IP of your SPAN Panel to begin setup, or select the automatically discovered panel if it shows up or another address if you have multiple panels. -12. Use the door proximity authentication (see below) and optionally create a token for future configurations. Obtaining a token **_may_** be more durable - against network changes, for example, if you change client hostname or IP and don't want to access the panel for authorization. -13. See post install steps for solar or scan frequency configuration to optionally add additional sensors if applicable. +4. Open the repository and click "Download" +5. Restart Home Assistant (you will be prompted by a repair notification) +6. Go to `Settings` > `Devices & Services` +7. Click `+ Add Integration` and search for "Span" +8. Enter the IP address of your SPAN Panel +9. The integration detects the panel as v2 and presents an authentication choice: + - **Enter Panel Passphrase** — type the passphrase found in the SPAN mobile app under On-premise settings + - **Proof of Proximity** — open and close the panel door 3 times, then click Submit +10. Choose your entity naming pattern +11. Optionally adjust the snapshot update interval — 0 is real-time, up to 15 seconds based on CPU -## Authorization Methods +### Upgrade Process -### Method 1: Door Proximity Authentication +When upgrading through HACS: -1. Open your SPAN Panel door -2. Press the door sensor button at the top 3 times in succession -3. Wait for the frame lights to blink, indicating the panel is "unlocked" for 15 minutes -4. Complete the integration setup in Home Assistant +1. **Create a backup** of your Home Assistant configuration and database +2. **Review the changes** in this README and CHANGELOG +3. **Check your automations** — review any references to removed entities +4. **Update during a quiet period** when you can monitor the upgrade + +If you encounter issues, restore from your backup or check the [troubleshooting section](#troubleshooting) below. + +## Key Terms + +The following terms appear throughout this document and in the integration's sensors: + +- **Grid-forming entity (GFE)** — The power source that sets the voltage and frequency reference for the home. When the utility grid is up, it is the GFE. When + islanded on battery, the battery inverter becomes the GFE. +- **Islanded** — The home is electrically disconnected from the utility grid and running on its own power source, typically battery. Circuits may be shed to + conserve battery life. +- **Microgrid** — When the home is islanded, the battery inverter creates a small, self-contained electrical grid for the home. This local grid functions + independently of the utility — the inverter generates AC power at the correct voltage and frequency, and the home's circuits run on it just as they would on + utility power. +- **Microgrid Interconnect Device (MID)** — A switch, part of or alongside the battery system, that disconnects the home from the utility grid during an outage. + While open, the panel's sensors can only see the home side. +- **Shedding** — Automatically turning off lower-priority circuits to conserve battery during an outage, based on each circuit's configured shed priority. + +## Entity Reference -### Method 2: Authentication Token (Optional) +### Panel-Level Sensors + +| Sensor | Device Class | Unit | Notes | +| ---------------------------- | ------------ | ---- | ---------------------------------------------------------------------------------------------------------------------- | +| Current Power | Power | W | Total panel power (grid import/export) | +| Feed Through Power | Power | W | Feedthrough (non-breaker) power | +| Main Meter Produced Energy | Energy | Wh | Grid energy exported | +| Main Meter Consumed Energy | Energy | Wh | Grid energy imported | +| Main Meter Net Energy | Energy | Wh | Consumed minus produced | +| Feed Through Produced Energy | Energy | Wh | Feedthrough energy exported | +| Feed Through Consumed Energy | Energy | Wh | Feedthrough energy imported | +| Feed Through Net Energy | Energy | Wh | Feedthrough net energy | +| DSM State | — | — | dsm_on_grid (grid connected), dsm_off_grid (islanded), unknown. Derived from multiple eBus signals | +| Current Run Config | — | — | panel_on_grid (grid connected), panel_off_grid (islanded on PV/generator), panel_backup (islanded on battery), unknown | +| Grid Forming Entity | — | — | (v2) GRID, BATTERY, PV, GENERATOR, NONE, UNKNOWN. See [Grid Forming Entity](#grid-forming-entity) | +| Main Relay State | — | — | closed (power flowing), open (disconnected), unknown | +| Vendor Cloud | — | — | (v2) CONNECTED, UNCONNECTED, UNKNOWN | +| Software Version | — | — | Firmware version string | -To acquire an authorization token, proceed as follows while the panel is in its unlocked period: +### Panel Diagnostic Sensors (v2 only) -1. To record the token use a tool like the VS code extension 'Rest Client' or curl to make a POST to `{Span_Panel_IP}/api/v1/auth/register` with a JSON body of - `{"name": "home-assistant-UNIQUEID", "description": "Home Assistant Local SPAN Integration"}`. - - Replace UNIQUEID with your own random unique value. If the name conflicts with one that's already been created, then the request will fail. - - Example via CLI: +| Sensor | Device Class | Unit | Notes | +| --------------------- | ------------ | ---- | -------------------------- | +| L1 Voltage | Voltage | V | L1 leg actual voltage | +| L2 Voltage | Voltage | V | L2 leg actual voltage | +| Upstream L1 Current | Current | A | Upstream lugs L1 current | +| Upstream L2 Current | Current | A | Upstream lugs L2 current | +| Downstream L1 Current | Current | A | Downstream lugs L1 current | +| Downstream L2 Current | Current | A | Downstream lugs L2 current | +| Main Breaker Rating | Current | A | Main breaker amperage | + +### Power Flow Sensors (v2 only) + +| Sensor | Device Class | Unit | Notes | +| ------------- | ------------ | ---- | --------------------------------------------------------------------------- | +| Grid Power | Power | W | Grid power flow | +| Site Power | Power | W | Total site power (grid + PV + battery) | +| Battery Power | Power | W | Battery charge/discharge (+discharge, -charge). Only when BESS commissioned | +| PV Power | Power | W | PV generation (+producing). Only when PV commissioned | - ```bash - curl -X POST https://192.168.1.2/api/v1/auth/register \ - -H 'Content-Type: application/json' \ - -d '{"name": "home-assistant-123456", "description": "Home Assistant Local SPAN Integration"}' - ``` +### PV Metadata Sensors (v2 only, on main panel device) -2. If the panel is already "unlocked", you will get a 2xx response to this call containing the `"accessToken"`. If not, then you will be prompted to open and - close the door of the panel 3 times, once every two seconds, and then retry the query. -3. Store the value from the `"accessToken"` property of the response (it will be a long random string of characters). The token can be used with future SPAN - integration configurations of the same panel. -4. If you are calling the SPAN API directly for testing, requests would load the HTTP header `"Authorization: Bearer "` +| Sensor | Device Class | Unit | Notes | +| ------------------ | ------------ | ---- | --------------------------------------------- | +| PV Vendor | — | — | PV inverter vendor (e.g., "Enphase", "Other") | +| PV Product | — | — | PV inverter product (e.g., "IQ8+") | +| Nameplate Capacity | Power | kW | Rated inverter capacity | -#### Multiple SPAN Panels +**Deprecated:** -If you have multiple SPAN Panels, you will need to repeat this process for each panel, as tokens are only accepted by the panel that generated them. +| Sensor | Reason | +| -------------- | ------------------------------------------------------------------------------------------------------------------------- | +| DSM Grid State | Deprecated — still available, but users should rely on `DSM State` as `DSM Grid State` may be removed in a future version | + +### Power Sensor Attributes + +Applies to Current Power, Feed Through Power, Battery Power, PV Power, Grid Power, and Site Power sensors. + +| Attribute | Type | Notes | +| ---------- | ------ | ------------------------------------ | +| `voltage` | string | Nominal panel voltage ("240") | +| `amperage` | string | Calculated current (power / voltage) | + +### Software Version Sensor Attributes + +| Attribute | Type | Notes | +| ------------ | ------ | ----------------------------------- | +| `panel_size` | int | Total breaker spaces (e.g., 32, 40) | +| `wifi_ssid` | string | Current Wi-Fi network | + +### EVSE (EV Charger) Entities + +Created automatically when a SPAN Drive or other EVSE is commissioned on the panel. Each EVSE appears as a separate sub-device linked to the panel via +`via_device`. Vendor, product, serial number, and software version are surfaced as device info attributes — not separate entities. + +#### EVSE Device Naming + +The EVSE device name includes the panel device name prefix for collision avoidance across multi-panel installations and to support HA's bulk device rename +feature. A display suffix differentiates multiple chargers on the same panel: + +- **Friendly names** (`USE_CIRCUIT_NUMBERS=False`): suffix is the fed circuit's panel name (e.g., "Garage") +- **Circuit numbers** (`USE_CIRCUIT_NUMBERS=True`): suffix is the EVSE serial number (e.g., "SN-EVSE-001") +- **No suffix available**: the display suffix is omitted entirely (no empty parentheses) + +| Naming Mode | Example Device Name | Example Entity ID | +| --------------- | ------------------------------------- | --------------------------------------------------------- | +| Friendly names | `Main House SPAN Drive (Garage)` | `sensor.main_house_span_drive_garage_charger_status` | +| Circuit numbers | `Main House SPAN Drive (SN-EVSE-001)` | `sensor.main_house_span_drive_sn_evse_001_charger_status` | +| No suffix | `Main House SPAN Drive` | `sensor.main_house_span_drive_charger_status` | + +#### EVSE Sensors (per charger) + +| Sensor | Device Class | Unit | Notes | +| ------------------ | ------------ | ---- | -------------------------------------------------------------------------------- | +| Charger Status | Enum | — | OCPP-based states: AVAILABLE, PREPARING, CHARGING, SUSPENDED_EV, etc. Translated | +| Advertised Current | Current | A | Amps offered to the vehicle | +| Lock State | Enum | — | LOCKED, UNLOCKED, UNKNOWN. Translated | + +#### EVSE Binary Sensors (per charger) + +| Sensor | Device Class | Notes | +| ------------ | ---------------- | ------------------------------------------------------------------ | +| Charging | Battery Charging | ON when status is CHARGING | +| EV Connected | Plug | ON when status is PREPARING, CHARGING, SUSPENDED\_\*, or FINISHING | + +#### EVSE Device Info Attributes -If you have this auth token, you can enter it in the "Existing Auth Token" flow in the configuration menu. +| Attribute | Source | +| ---------------- | ------------------ | +| Manufacturer | `vendor-name` | +| Model | `product-name` | +| Serial Number | `serial-number` | +| Software Version | `software-version` | -## Configuration Options +### BESS Sub-Device (v2 only, conditional) -### Basic Settings +When a Battery Energy Storage System (BESS) is commissioned, the integration creates a separate BESS sub-device linked to the panel via `via_device`. The BESS +device uses manufacturer, model, serial number, and software version from battery metadata as device info attributes. -- Integration scan frequency (default: 15 seconds) -- Battery storage percentage display -- Solar inverter mapping +#### BESS Sensors -### Entity Naming Pattern Options +| Sensor | Device Class | Unit | Notes | +| ------------------ | -------------- | ---- | ----------------------------------------------------------------- | +| Battery Level | Battery | % | State of energy as percentage | +| Battery Power | Power | W | Same entity as Power Flow Battery Power, shown on BESS sub-device | +| BESS Vendor | — | — | Battery system vendor (diagnostic) | +| BESS Model | — | — | Battery system model (diagnostic) | +| BESS Serial Number | — | — | Battery system serial number (diagnostic) | +| BESS Firmware | — | — | Battery system firmware (diagnostic) | +| Nameplate Capacity | Energy Storage | kWh | Rated battery capacity (diagnostic) | +| Stored Energy | Energy Storage | kWh | Current stored energy (diagnostic) | -The integration provides flexible entity naming patterns to suit different preferences and use cases. You can configure these options through the integration's -configuration menu when you first install: +#### BESS Binary Sensors -#### Available Naming Patterns +| Sensor | Device Class | Notes | +| -------------- | ------------ | -------------------------------------------- | +| BESS Connected | Connectivity | Whether the BESS is communicating with panel | -1. **Friendly Names** (Recommended for new installations) - - Entity IDs use descriptive circuit names from your SPAN panel - - Example: `sensor.span_panel_kitchen_outlets_power` - - Automatically updates when you rename circuits in the SPAN panel - - More intuitive for automations and scripts +### Panel Energy Sensor Attributes -2. **Circuit Numbers** (Stable entity IDs) - - Entity IDs use generic circuit numbers - - Example: `sensor.span_panel_circuit_15_power` - - Entity IDs remain stable even when circuits are renamed - - Friendly names still sync from SPAN panel for display +Applies to Main Meter and Feed Through energy sensors. -### Solar Configuration +| Attribute | Type | Notes | +| --------- | ------ | ----------------------------- | +| `voltage` | string | Nominal panel voltage ("240") | -The solar configuration is only for solar that is directly connected to the panel tabs. SPAN does expose data for inverters otherwise. If the inverter sensors -are enabled, four sensors are created (power, produced energy, consumed energy, and net energy). The entity naming pattern depends on your configured naming -pattern: +### Circuit-Level Sensors (per circuit) -**Circuit Numbers Pattern**: +| Sensor | Device Class | Unit | Notes | +| --------------- | ------------ | ---- | --------------------------------------------------------------------- | +| Power | Power | W | Instantaneous circuit power (+producing for PV, +consuming otherwise) | +| Produced Energy | Energy | Wh | Cumulative energy produced | +| Consumed Energy | Energy | Wh | Cumulative energy consumed | +| Net Energy | Energy | Wh | Net energy (sign depends on device type — PV circuits invert) | +| Current | Current | A | (v2) Measured circuit current. Only when panel reports `current_a` | +| Breaker Rating | Current | A | (v2) Circuit breaker amperage (diagnostic). Only when reported | -```yaml -sensor.span_panel_circuit_30_32_instant_power # (watts) - dual circuit -sensor.span_panel_circuit_30_32_energy_produced # (Wh) - dual circuit -sensor.span_panel_circuit_30_32_energy_consumed # (Wh) - dual circuit -sensor.span_panel_circuit_30_32_energy_net # (Wh) - dual circuit -``` +### Circuit Power Sensor Attributes -**Friendly Names Pattern**: +| Attribute | Type | Notes | +| ----------------- | ------ | ----------------------------------------------------- | +| `tabs` | string | Breaker slot position(s) | +| `voltage` | string | 120 or 240 (derived from tab count) | +| `always_on` | bool | Whether circuit is always-on | +| `relay_state` | string | OPEN / CLOSED / UNKNOWN | +| `relay_requester` | string | Who requested relay state | +| `shed_priority` | string | API value: NEVER / SOC_THRESHOLD / OFF_GRID / UNKNOWN | +| `is_sheddable` | bool | Whether circuit can be shed | -```yaml -sensor.span_panel_solar_inverter_instant_power # (watts) -sensor.span_panel_solar_inverter_energy_produced # (Wh) -sensor.span_panel_solar_inverter_energy_consumed # (Wh) -sensor.span_panel_solar_inverter_energy_net # (Wh) -``` +### Circuit Energy Sensor Attributes -**Note**: For circuit numbers pattern, the numbers in the entity IDs (e.g., `30_32`) correspond to your configured inverter leg circuits. For single-circuit -configurations, only one number appears (e.g., `circuit_30_instant_power`). +| Attribute | Type | Notes | +| --------- | ------ | ----------------------------------- | +| `tabs` | string | Breaker slot position(s) | +| `voltage` | string | 120 or 240 (derived from tab count) | -Disabling the inverter in the configuration removes these specific sensors. No reboot is required to add/remove these inverter sensors. +### Binary Sensors -Although the solar inverter configuration is primarily aimed at installations that don't have a way to monitor their solar directly from their inverter, one -could use this configuration to monitor any circuit(s) not provided directly by the underlying SPAN API for whatever reason. The two circuits are always added -together to indicate their combined power if both circuits are enabled. +| Sensor | Device Class | Notes | +| --------------- | ------------ | ------------------------------------------------------------------- | +| Door State | Tamper | Panel door open/closed | +| Ethernet Link | Connectivity | Wired network status | +| Wi-Fi Link | Connectivity | Wireless network status | +| Panel Status | Connectivity | Overall panel online/offline | +| Grid Islandable | — | (v2) Whether the panel can island from the grid. Only when reported | -Adding your own platform integration sensor like so converts to kWh: +**Removed from binary sensors:** -```yaml -sensor - - platform: integration - source: sensor.span_panel_solar_inverter_instant_power # Use appropriate entity ID for your installation - name: Solar Inverter Produced kWh - unique_id: sensor.solar_inverter_produced_kwh - unit_prefix: k - round: 2 -``` +| Sensor | Reason | +| --------------- | ------------------------------------------------------ | +| Cellular (wwan) | Replaced by `Vendor Cloud` sensor (cloud connectivity) | -### Customizing Entity Precision +### Circuit Controls (per user-controllable circuit) -The power sensors provided by this add-on report with the exact precision from the SPAN panel, which may be more decimal places than you will want for practical -purposes. By default the sensors will display with precision 2, for example `0.00`, with the exception of battery percentage. Battery percentage will have -precision of 0, for example `39`. +| Entity | Type | Notes | +| --------------------- | ------ | -------------------------------------------------------------------------- | +| Breaker | Switch | On/off relay control | +| Circuit Shed Priority | Select | (v2) Controls when circuit is shed during off-grid (translated, see below) | -You can change the display precision for any entity in Home Assistant via `Settings` -> `Devices & Services` -> `Entities` tab. Find the entity you would like -to change in the list and click on it, then click on the gear wheel in the top right. Select the precision you prefer from the "Display Precision" menu and then -press `UPDATE`. +### Panel Controls -## Limitations +| Entity | Type | Notes | +| ---------------------------- | ------ | ---------------------------------------------------------------------- | +| GFE Override: Grid Connected | Button | (v2) Tell the panel the grid is up when BESS communication interrupted | -The original SPAN Panel MAIN 32 has a standardized OpenAPI endpoint that is leveraged by this integration. +### BESS & Grid Management -However, the new SPAN Panel MAIN 40 and MLO 48 that were released in Q2 of 2025 leverage a different hardware/software stack, even going so far as to use a -different mobile app logins. This stack is not yet publicly documented and as such, we have not had a chance to discern how to support this stack at the time of -writing this. The underlying software may be the same codebase as the MAIN 32, so in theory, SPAN may provide access that we have yet to discover or that they -will eventually expose. +This section explains how the SPAN panel manages power sources and load shedding when a Battery Energy Storage System (BESS) is installed, and what the +integration can and cannot tell you about grid status. -## Troubleshooting +#### Grid Forming Entity + +The Grid Forming Entity (GFE) sensor identifies which power source provides the voltage and frequency reference for the home — not which source is producing the +most watts. When GFE is Grid, the utility grid sets the reference and all circuits remain on, even if 100% of consumption comes from solar. When GFE is Battery, +the battery inverter is the reference and circuits are shed based on each circuit's configured shed priority. + +| GFE Value | Meaning | +| --------- | ----------------------------------------------------------------- | +| GRID | Panel is grid-connected (includes generator power, see deep dive) | +| BATTERY | Panel is islanded, running on battery | +| PV | Panel is islanded, running on solar (future) | +| GENERATOR | Panel is islanded, running on generator (future) | +| NONE | Panel is islanded with no power source | +| UNKNOWN | State not yet determined or fault condition | + +When a BESS is installed, the panel relies on the BESS to determine whether the grid is online and to set the GFE accordingly. If BESS communication is lost +while the panel is islanded, the GFE value becomes stale — it may show Battery when the grid has actually been restored, causing unnecessary shedding to +continue. + +#### What the Panel Can Detect -### Common Issues +**Grid loss** — The panel independently detects grid loss via its own voltage monitoring, even if BESS communication is already lost. The MID is still closed at +this point, so the panel's sensors see the real voltage drop and respond immediately. -1. Door Sensor Unavailable - We have observed the SPAN API returning UNKNOWN if the cabinet door has not been operated recently. This behavior is a defect in - the SPAN API, so we report that sensor as unavailable until it reports a proper value. Opening or closing the door will reflect the proper value. The door - state is classified as a tamper sensor (reflecting 'Detected' or 'Clear') to differentiate the sensor from a normal entry door. -2. State Class Warnings - "Feed Through" sensors may produce erroneous data in the sense that logs may complain the sensor data is not constantly increasing - when the sensor statistics type is set to total/increasing. These sensors reflect the feed through lugs which may be used for a downstream panel. If you are - getting warnings in the log about a feed through sensor that has state class total_increasing, but its state is not strictly increasing, you can opt to - disable these sensors in the Home Assistant settings/devices/entities section: +**Grid restoration while islanded** — Not detectable by the panel. While the MID is open, the panel's sensors are on the home side and measure only +battery-supplied power. Grid restoration on the utility side of the open MID is invisible to any panel-side measurement. This is a physical limitation, not a +software gap. A utility-side sensor — such as a current clamp (e.g., Emporia Vue), ATS/MTS contact closure, or any device that can see the grid side of the MID +— integrated into Home Assistant as a binary sensor can provide this signal. - ```text - sensor.span_panel_feed_through_consumed_energy - sensor.span_panel_feed_through_produced_energy - ``` +#### DSM State Sensor - **Note**: The exact entity names depend on your configured naming pattern. For circuit numbers pattern, feed through sensors use generic naming (e.g., - `sensor.span_panel_circuit_X_consumed_energy` where X is the circuit number). For friendly names pattern, they use descriptive names based on the circuit - purpose. +The integration's `DSM State` sensor combines multiple panel signals to provide defense-in-depth for grid status detection. It corroborates the Grid Forming +Entity with BESS grid state and power measurements, which adds confidence during transient inconsistencies and detects some edge cases — for example, when BESS +communication is lost while on-grid and the grid subsequently drops, the panel self-corrects via voltage detection and the corroborating signals confirm it. -3. No Switch - If a circuit is set in the SPAN App as one of the "Always on Circuits", then that circuit will not have a switch because the API does not allow - the user to control it. -4. Circuit Priority - The SPAN API doesn't allow the user to set the circuit priority. We leave this dropdown active because SPAN's browser also shows the - dropdown. The circuit priority is affected by two settings the user can adjust in the SPAN app - the "Always-on circuits" which define critical or other - must-have circuits. The PowerUp circuits are less clear, but what we know is that those at the top of the PowerUp list tend to be "Non-Essential", but this - rule is inconsistent with respect to all circuit order, which may indicate a defect in SPAN PowerUp, the API, or indicate something we don't fully - understand. +However, when the panel is islanded and the MID is open, all of the panel's signals measure the home side. No combination of panel-sourced data can detect grid +restoration in this state. Only an external signal (utility-side sensor) or manual confirmation via the GFE Override button can resolve it. -## Development Notes +#### GFE Override Button + +The **GFE Override: Grid Connected** button tells the panel that the grid is back and shedding can stop. When the BESS restores communication, it automatically +reclaims control and the override is superseded — no manual undo is needed. + +**Risk asymmetry** — Telling the panel to shed (conservative direction) is low-risk; worst case is unnecessary circuit disruption. Telling the panel the grid is +back when it is not means unmanaged battery drain and reduced runtime, which could affect critical equipment. The battery protects itself by disconnecting when +depleted, so there is no overload risk, but runtime will be reduced. Use the override button only with confidence that the grid has actually been restored — via +a utility-side sensor or manual confirmation. + +**WARNING** — Do _not_ automate the GFE override button based on `DSM State` — it inherits the same MID blind spot described above and will read `dsm_off_grid` +even after the grid is restored. Manual confirmation or an external sensor is required before pressing the button. + +When `bess_connected` returns to on, no action is needed — firmware resumes normal GFE management automatically. + +For a detailed discussion of failure scenarios, the MID topology, generator and non-integrated BESS behavior, and `/set` risk analysis, see +[BESS & Grid Management Deep Dive](bess-grid-management.md). + +## Configuration Options -### Developer Prerequisites +### Snapshot Update Interval -- Poetry -- Pre-commit -- Python 3.13.2+ +Controls how often the integration rebuilds the panel snapshot from incoming MQTT data. The SPAN panel publishes high-frequency MQTT messages (~100/second), but +each individual message is a cheap dictionary write. The expensive operation — rebuilding the full snapshot and dispatching entity updates — is rate-limited by +this timer. -This project uses [poetry](https://python-poetry.org/) for dependency management. Linting and type checking are accomplished using -[pre-commit](https://pre-commit.com/) which is installed by poetry. +- **Default:** 1 second +- **Range:** 0–15 seconds +- **Set to 0** for no debounce (every MQTT message triggers a snapshot rebuild) +- **Increase on low-power hardware** (e.g., Raspberry Pi) to reduce CPU usage -### Developer Setup +Configure via `Settings` > `Devices & Services` > `SPAN Panel` > `Configure` > `General Options`. -1. Install [poetry](https://python-poetry.org/). -2. In the project root run `poetry install --with dev` to install dependencies. -3. Run `poetry run pre-commit install` to install pre-commit hooks. -4. Optionally use `Tasks: Run Task` from the command palette to run `Run all Pre-commit checks` or `poetry run pre-commit run --all-files` from the terminal to - manually run pre-commit hooks on files locally in your environment as you make changes. +### Entity Naming Pattern + +The integration provides flexible entity naming patterns, configured during initial setup: + +1. **Friendly Names** (Recommended for new installations) + - Entity IDs use descriptive circuit names from your SPAN panel + - Example: `sensor.span_panel_kitchen_outlets_power` + - Automatically updates when you rename circuits in the SPAN panel + - More intuitive for automations and scripts + +2. **Circuit Numbers** (Stable entity IDs) + - Entity IDs use generic circuit numbers + - Example: `sensor.span_panel_circuit_15_power` + - Entity IDs remain stable even when circuits are renamed + - Friendly names still sync from SPAN panel for display + +### Energy Dip Compensation + +SPAN panels occasionally report lower energy readings for cumulative energy sensors after firmware updates or resets. Home Assistant's statistics engine +interprets any decrease as a counter reset, creating negative spikes in the energy dashboard. + +When enabled, the integration automatically detects these dips and maintains a cumulative offset per sensor so Home Assistant always sees a monotonically +increasing value. + +- **Default for new installs:** ON +- **Default for existing installs:** OFF (enable via General Options) +- **Threshold:** 1.0 Wh minimum to avoid false triggers from float precision noise +- **Disabling:** Clears all accumulated offsets (starts fresh if re-enabled) + +When a dip is detected, a persistent notification lists the affected sensors and their dip amounts. + +**Diagnostic attributes** (visible when compensation is active): + +| Attribute | Description | +| ---------------- | --------------------------------------------- | +| `energy_offset` | Cumulative Wh compensation applied (when > 0) | +| `last_dip_delta` | Size of the most recent dip in Wh | + +Configure via `Settings` > `Devices & Services` > `SPAN Panel` > `Configure` > `General Options`. + +### Customizing Entity Precision + +The power sensors report with the exact precision from the SPAN panel, which may be more decimal places than you need. By default, sensors display with +precision 2 (e.g., `0.00`), except battery percentage which uses precision 0 (e.g., `39`). + +You can change the display precision for any entity via `Settings` > `Devices & Services` > `Entities` tab. Find the entity, click on it, click the gear wheel, +and select your preferred precision from the "Display Precision" menu. + +## WebSocket API + +The integration provides a `span_panel/panel_topology` WebSocket command that returns the full physical layout of a panel in a single call — circuits with their +breaker slot positions, entity IDs grouped by role, and sub-devices (BESS, EVSE) with their entities. + +See [WebSocket API Reference](websocket-api.md) for the full schema, response format, and usage examples. + +## Troubleshooting -The linters may make changes to files when you try to commit, for example to sort imports. Files that are changed or fail tests will be unstaged. After -reviewing these changes or making corrections, you can re-stage the changes and recommit or rerun the checks. After the pre-commit hook run succeeds, your -commit can proceed. +| Issue | Symptoms | Resolution | +| --------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Energy Dashboard spikes after firmware updates** | Huge energy-consumption spikes after panel firmware updates; charts showing untracked values that dwarf normal usage; negative energy values in statistics. Caused by the panel reporting decreased values on otherwise `TOTAL_INCREASING` sensors. | **Prevention:** enable [Energy Dip Compensation](#energy-dip-compensation) in General Options (on by default for new installs). **Fix existing spikes:** in **Developer Tools → Statistics**, search for the affected sensor (e.g. `sensor.span_panel_main_meter_consumed_energy`) and use **Adjust sum** to correct the errant entry. The integration also notifies when a decrease in the main meter consumed sensor is detected. | +| **High CPU usage** | Elevated CPU on low-power hardware (e.g. Raspberry Pi). The integration rebuilds a full panel snapshot from MQTT messages at a configurable interval (default 1 s). | Increase **Snapshot Update Interval** in **General Options**. 10–15 s is recommended for resource-constrained systems. Setting it to 0 disables debouncing and rebuilds on every MQTT message — not recommended. | +| **Replaced sub-device shows the old serial number** | After replacing a SPAN sub-device (Drive / EVSE, BESS, PV inverter), the device entry in Home Assistant keeps showing the previous hardware's serial number. The integration keys entities off the panel-assigned node identity, which is intentionally stable across hardware swaps so long-term history (e.g. lifetime charging kWh for a Drive) is preserved. The device-registry serial number, however, does not auto-refresh. | In **Settings → Devices & Services → Span Panel**, open the affected sub-device and delete it, then reload the integration (or restart Home Assistant). The device re-registers with the new serial number. Entity IDs and their recorded history are preserved. | +| **Door sensor unavailable** | The SPAN API returns UNKNOWN if the cabinet door has not been operated recently. This is a defect in the SPAN API. | The integration reports the sensor as unavailable until a proper value arrives. Opening or closing the door publishes the correct state. The door is classified as a tamper sensor (`Detected` / `Clear`) to differentiate it from a normal entry door. | +| **No switch on a circuit** | A circuit has no switch entity exposed in Home Assistant. | The circuit is configured in the SPAN App as one of the "Always on Circuits". The API does not permit user control of those circuits, so no switch is created. | -### VS Code +## Development -See the .vscode/settings.json.example file for starter settings +See [Developer Documentation](developer.md) for setup instructions, prerequisites, and tooling. ## License @@ -303,6 +490,7 @@ Additional contributors: - pavandave - sargonas +- NickBorgersOnLowSecurityNode ## Issues diff --git a/bess-grid-management.md b/bess-grid-management.md new file mode 100644 index 00000000..154ee853 --- /dev/null +++ b/bess-grid-management.md @@ -0,0 +1,167 @@ +# BESS & Grid Management Deep Dive + +This document provides detailed technical context for how the SPAN panel manages grid status, load shedding, and battery system interaction. It supplements the +[BESS & Grid Management](README.md#bess--grid-management) section in the README. + +## What You Should Know + +**The panel detects outages on its own.** When the utility grid drops, the panel sees the voltage sag and responds immediately — even if BESS communication is +already lost. No action is needed from you. + +**The panel cannot detect grid restoration while islanded.** Once the MID (Microgrid Interconnect Device) opens to isolate the home, every sensor on the panel +measures battery-supplied power. Grid restoration on the utility side of the open MID is invisible. This is a physical limitation — no combination of panel +sensors or integration logic can work around it without an upstream electrical current clamp or Automatic Transfer Switch (ATS) signal. + +**If BESS communication is lost while islanded, shedding continues indefinitely** — even after the grid comes back. The panel's last-known state was "off-grid" +and it has no way to learn otherwise until either the BESS reconnects or you intervene. + +**The GFE Override button is the manual fix.** It tells the panel the grid is back and stops shedding. But only press it when you are confident the grid has +actually been restored — pressing it while truly off-grid means unmanaged battery drain and reduced runtime. See +[GFE Override — Risk by Direction](#gfe-override--risk-by-direction) for details. + +**An external utility-side sensor is the best long-term solution.** A current clamp (e.g., Emporia Vue), ATS/MTS contact closure, or any device on the grid side +of the MID, integrated into Home Assistant as a binary sensor, can detect grid restoration and trigger automations that the panel's own sensors cannot. + +**The `DSM State` sensor adds confidence but shares the blind spot.** It corroborates the Grid Forming Entity with additional signals and catches some edge +cases (like the panel self-correcting after detecting grid loss). But for the core problem — grid restored while islanded with BESS comms lost — it cannot help. +All its inputs measure the home side of the open MID. + +**Generators appear as grid power.** The panel cannot distinguish between utility and generator power. GFE reports GRID when a generator is running. No +automatic load shedding is available with generators — that requires a +[compatible integrated BESS](https://support.span.io/hc/en-us/articles/4412059545111-Storage-System-Integrations-with-SPAN). + +**Non-integrated battery systems provide no panel awareness.** If the BESS is not on the +[compatible integrations list](https://support.span.io/hc/en-us/articles/4412059545111-Storage-System-Integrations-with-SPAN), the panel does not know it is +islanded during an outage. GFE reports GRID, power flows show battery as grid, and no automatic shedding is available. + +## System Topology + +The diagrams below show the key components and where sensors can observe power. The critical insight is that the panel's sensors are on the **home side** of the +MID — they cannot see what is happening on the utility side when the MID is open. + +![Integrated BESS Topology](images/bess-topology-integrated.svg) + +Editable source: [images/bess-topology-integrated.drawio](images/bess-topology-integrated.drawio) + +**Key observations:** + +- When the MID is **closed** (on-grid), the panel's sensors see real grid power and can detect changes. +- When the MID is **open** (islanded), the panel's sensors see battery-supplied power only. The utility side is invisible. +- A **utility-side sensor** (current clamp, ATS contact closure, etc.) is the only way to detect grid restoration while islanded. +- **Generators** connect upstream of the MID via an ATS/MTS. The panel sees generator power as grid power — it cannot distinguish between the two. + +### Non-Integrated BESS Topology + +When the battery system is not on the +[compatible integrations list](https://support.span.io/hc/en-us/articles/4412059545111-Storage-System-Integrations-with-SPAN), there is no communication link +between the BESS and the panel. The panel cannot distinguish battery power from grid power and has no awareness that it is islanded during an outage. No +automatic load shedding is available. + +![Non-Integrated BESS Topology](images/bess-topology-non-integrated.svg) + +Editable source: [images/bess-topology-non-integrated.drawio](images/bess-topology-non-integrated.drawio) + +**Key differences from the integrated topology:** + +- **No comms link** — The panel has no communication with the BESS and cannot receive grid state updates. +- **GFE reports GRID** even when islanded — the panel does not know it is running on battery. +- **No automatic shedding** — Without BESS communication, the panel cannot manage circuits during an outage. +- **Power flows show battery as grid** — All incoming power appears as grid power in sensors and dashboards. + +## Technical Details + +### How the Panel Determines Grid Status + +The Grid Forming Entity (GFE) is determined from multiple inputs: + +1. **BESS reporting** — The BESS communicates grid state to the panel and is authoritative when connected. This is the primary source for grid status. +2. **Panel voltage monitoring** — The panel monitors voltage on the main conductors and can independently detect grid loss (voltage sag/collapse) even if BESS + communication is already lost. + +The panel is **not** entirely BESS-dependent for detecting outages. However, for detecting grid **restoration** while islanded, the BESS is the only source — +the panel's voltage monitoring cannot distinguish BESS-generated power from utility power on the home side of an open MID. + +### Stale GFE Scenarios + +When BESS communication is lost, the GFE value reflects the last-known state. The following matrix shows what happens in each failure sequence and whether the +actual state is detectable. + +| # | Sequence | GFE (stale) | Actual State | Impact | Detectable? | +| --- | -------------------------------------------------- | ----------- | ------------ | --------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | +| 1 | BESS comms drop while on-grid, grid stays up | GRID | On-grid | None — stale value happens to be correct | N/A | +| 2 | BESS comms drop while off-grid, grid stays down | BATTERY | Off-grid | None — stale value happens to be correct | N/A | +| 3 | Grid restored, BESS comms still down | BATTERY | On-grid | Unnecessary shedding continues | **No** — MID is open, all panel sensors show battery power. Only correctable via GFE Override | +| 4 | BESS comms drop while on-grid, then grid drops | GRID | Off-grid | No shedding — battery drains faster, reduced runtime | **Yes** — MID still closed, panel detects voltage sag independently and self-corrects | +| 5 | BESS comms drop while off-grid, then grid restores | BATTERY | On-grid | Unnecessary shedding continues (same as #3) | **No** — MID is open, same blind spot as #3. Only correctable via GFE Override | +| 6 | BESS itself fails (comms + battery), grid up | Stale | On-grid | Shedding state depends on last GFE; `bess_connected` goes false | **Yes** — `bess_connected` = false is a distinct signal, but GFE may still be stale | + +Scenario 4 self-corrects because the MID is still closed and the panel can see the real voltage change. Scenarios 3 and 5 cannot self-correct because the MID is +open and all panel-side signals measure battery power. The GFE Override button or an external utility-side sensor is the only resolution. + +### DSM State Sensor — What It Can and Cannot Do + +The integration's `DSM State` sensor corroborates GFE with additional signals: + +- `bess/grid-state` — The BESS's own view of grid connectivity +- Power flow measurements — Grid power flow and upstream lugs active power + +This multi-signal approach adds genuine value for: + +- **Transient inconsistencies** during normal BESS communication +- **Scenario 4** — Grid drops while on-grid with BESS comms already lost. The MID is still closed, so power measurements reflect reality and confirm the panel's + self-correction. +- **Scenario 6** — BESS failure detection via `bess_connected` +- **Defense-in-depth** for edge cases not yet enumerated + +**Limitation:** For scenarios 3 and 5 (grid restored while islanded, BESS comms lost, MID open), no combination of panel-sourced signals can help. All signals +measure the home side of the open MID. Only the GFE Override button or an external utility-side sensor can resolve the stale state. + +### GFE Override — Risk by Direction + +The GFE Override button allows a user or automation to tell the panel what power regime it should operate in. Not all directions carry equal risk. + +| Direction | Action | Risk | Automation Safe? | +| ------------------------- | ------------------ | ---------------------------------------------------------------------------------------------------------- | ----------------------------- | +| GRID → BATTERY | Triggers shedding | **Low** — conservative, extends runtime. Worst case is unnecessary circuit disruption. | Yes | +| BATTERY → GRID | Stops shedding | **Moderate** — if actually off-grid, unmanaged battery drain reduces runtime, could affect critical loads. | User confirmation recommended | +| Any → UNKNOWN | Behavior undefined | **Unknown** | No | +| Any → PV / GENERATOR | Future / undefined | **Unknown** | No | + +**Firmware reclaim behavior:** When the BESS restores communication, it reasserts its authoritative grid state. This produces a new state transition that +overrides any previous GFE Override command. The override is a temporary measure for the BESS-communication-loss window, not a persistent latch. + +### Generator Systems + +SPAN is always installed downstream of a transfer switch (ATS or MTS) in generator installations. There is no communication wiring between the panel and the +generator — the panel sees whatever voltage the transfer switch feeds it. When a generator is running, the panel reports GFE as GRID because it literally cannot +distinguish generator power from utility power. + +Implications: + +- **GFE = GRID does not necessarily mean utility power.** On panels with a generator, it could mean generator power. +- **No automatic load shedding with generators.** Shedding requires a + [compatible integrated BESS](https://support.span.io/hc/en-us/articles/4412059545111-Storage-System-Integrations-with-SPAN). +- **Power flow sensors report generator power as grid power.** This is expected behavior, not a bug. +- The `GENERATOR` GFE value is reserved for possible future use. Distinguishing generator from utility would require either communication with the + generator/transfer switch or a grid-side voltage sensor, neither of which exists in current configurations. + +### Non-Integrated BESS + +For battery systems that are not on the +[compatible integrations list](https://support.span.io/hc/en-us/articles/4412059545111-Storage-System-Integrations-with-SPAN), the panel has no communication +with the battery system. During an outage with a non-integrated BESS: + +- GFE reports **GRID** even when actually off-grid — the panel does not know it is islanded. +- **No automatic load shedding** is available. +- Power flow sensors report all incoming power as grid power. + +This underscores a broader point: GFE reflects what the panel **knows**, which depends on what systems are communicating with it. A non-integrated BESS provides +backup power but no panel awareness. + +## Reference Links + +- [Storage System Integrations with SPAN](https://support.span.io/hc/en-us/articles/4412059545111-Storage-System-Integrations-with-SPAN) +- [Adding Battery Backup to your SPAN Panel](https://support.span.io/hc/en-us/articles/4574797073303-Adding-Battery-Backup-to-your-SPAN-Panel) +- [Can I install SPAN with a standby generator?](https://support.span.io/hc/en-us/articles/6064757711255-Can-I-install-SPAN-with-a-standby-generator) +- [SPAN API Scope & Responsibility Model](https://github.com/spanio/SPAN-API-Client-Docs#span-api-scope--responsibility-model) +- [GitHub Discussion: Migration Guide — v1 dsmState to v2 dominant-power-source](https://github.com/spanio/SPAN-API-Client-Docs/discussions/8) diff --git a/custom_components/span_panel/__init__.py b/custom_components/span_panel/__init__.py index 51011867..dba10167 100644 --- a/custom_components/span_panel/__init__.py +++ b/custom_components/span_panel/__init__.py @@ -3,49 +3,84 @@ from __future__ import annotations import asyncio -from datetime import datetime +from dataclasses import dataclass import logging -import os +from homeassistant.components.frontend import async_remove_panel as async_remove_panel +from homeassistant.components.panel_custom import async_register_panel as async_register_panel from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - CONF_ACCESS_TOKEN, - CONF_HOST, - CONF_SCAN_INTERVAL, - Platform, -) +from homeassistant.const import CONF_HOST, Platform from homeassistant.core import CoreState, HomeAssistant -from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.util import slugify +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryError, + ConfigEntryNotReady, +) +from homeassistant.helpers import config_validation as cv, device_registry as dr +from homeassistant.helpers.typing import ConfigType +from span_panel_api import SpanMqttClient, SpanPanelSnapshot +from span_panel_api.exceptions import ( + SpanPanelAuthError, + SpanPanelConnectionError, + SpanPanelTimeoutError, +) +from span_panel_api.mqtt.models import MqttClientConfig # Import config flow to ensure it's registered -from . import config_flow # noqa: F401 # type: ignore[misc] +from . import config_flow # noqa: F401 from .const import ( - CONF_SIMULATION_CONFIG, - CONF_SIMULATION_OFFLINE_MINUTES, - CONF_SIMULATION_START_TIME, - CONF_USE_SSL, - COORDINATOR, - DEFAULT_SCAN_INTERVAL, + CONF_API_VERSION, + CONF_EBUS_BROKER_HOST, + CONF_EBUS_BROKER_PASSWORD, + CONF_EBUS_BROKER_PORT, + CONF_EBUS_BROKER_USERNAME, + CONF_HTTP_PORT, + DEFAULT_SNAPSHOT_INTERVAL, DOMAIN, - NAME, + ENABLE_CURRENT_MONITORING, ) from .coordinator import SpanPanelCoordinator -from .migration import migrate_config_entry_sensors - -# Handle solar options changes before reload (battery is now native sensor) -from .options import ( - INVERTER_ENABLE, - INVERTER_LEG1, - INVERTER_LEG2, - Options, +from .current_monitor import CurrentMonitor +from .frontend import ( + PANEL_FRONTEND_DIR as PANEL_FRONTEND_DIR, + PANEL_URL as PANEL_URL, + _async_ensure_lovelace_resource as _async_ensure_lovelace_resource, + async_apply_panel_registration as async_apply_panel_registration, + async_load_panel_settings as async_load_panel_settings, + async_save_panel_settings as async_save_panel_settings, +) +from .graph_horizon import GraphHorizonManager +from .migrations import CURRENT_CONFIG_VERSION, async_migrate_entry # noqa: F401 +from .options import SNAPSHOT_UPDATE_INTERVAL +from .services import ( # noqa: F401 + _async_register_favorites_services, + _async_register_graph_horizon_services, + _async_register_monitoring_services, + _async_register_services, + _build_clear_circuit_threshold_schema, + _build_clear_mains_threshold_schema, + _build_set_circuit_threshold_schema, + _build_set_global_monitoring_schema, + _build_set_mains_threshold_schema, ) -from .span_panel import SpanPanel -from .span_panel_api import SpanPanelAuthError, set_async_delay_func -from .util import panel_to_device_info +from .util import snapshot_to_device_info +from .websocket import async_register_commands + +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + + +@dataclass +class SpanPanelRuntimeData: + """Runtime data for a Span Panel config entry.""" + + coordinator: SpanPanelCoordinator + + +type SpanPanelConfigEntry = ConfigEntry[SpanPanelRuntimeData] PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, + Platform.BUTTON, Platform.SELECT, Platform.SENSOR, Platform.SWITCH, @@ -53,226 +88,135 @@ _LOGGER = logging.getLogger(__name__) -# Config entry version for unique ID consistency migration -CURRENT_CONFIG_VERSION = 2 - -# Test that module loads -_LOGGER.debug("SPAN PANEL MODULE LOADED! Version: %s", CURRENT_CONFIG_VERSION) +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Span Panel integration (domain-level, called once).""" + _async_register_services(hass) + _async_register_monitoring_services(hass) + _async_register_graph_horizon_services(hass) + _async_register_favorites_services(hass) -async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: - """Migrate config entry - normalize unique_ids and set migration flag.""" - - if config_entry.version < CURRENT_CONFIG_VERSION: - _LOGGER.debug( - "Migrating config entry %s from version %s to %s", - config_entry.entry_id, - config_entry.version, - CURRENT_CONFIG_VERSION, - ) - - # Call the actual migration logic - migration_success = await migrate_config_entry_sensors(hass, config_entry) - if not migration_success: - _LOGGER.warning( - "Migration failed for config entry %s - cannot reconstruct unique_id", - config_entry.entry_id, - ) - return False - else: - # Normal successful migration - hass.config_entries.async_update_entry( - config_entry, - data=config_entry.data, - options=config_entry.options, - title=config_entry.title, - version=CURRENT_CONFIG_VERSION, - ) - _LOGGER.debug( - "Successfully migrated config entry %s to version %s", - config_entry.entry_id, - CURRENT_CONFIG_VERSION, - ) + await async_apply_panel_registration(hass) return True -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: SpanPanelConfigEntry) -> bool: """Set up Span Panel from a config entry.""" - _LOGGER.debug("SETUP ENTRY CALLED! Entry ID: %s, Version: %s", entry.entry_id, entry.version) - - # Migration flags will be handled by the coordinator during its update cycle - - async def ha_compatible_delay(seconds: float) -> None: - """HA-compatible delay function that works well with HA's event loop.""" - await asyncio.sleep(seconds) + _LOGGER.debug("Setting up entry %s (version %s)", entry.entry_id, entry.version) - # Register the HA-compatible delay function for retry logic - set_async_delay_func(ha_compatible_delay) - _LOGGER.debug("Configured span-panel-api with HA-compatible delay function") + # Register WebSocket commands once per HA instance + domain_data: dict[str, bool] = hass.data.setdefault(DOMAIN, {}) + if not domain_data.get("websocket_registered"): + domain_data["websocket_registered"] = True + async_register_commands(hass) config = entry.data - host = config[CONF_HOST] - name = "SpanPanel" + api_version = config.get(CONF_API_VERSION, "v1") - _LOGGER.debug("DEBUG: Starting SPAN Panel integration setup for host: %s", host) - - use_ssl_value = config.get(CONF_USE_SSL, False) - - # Get scan interval from options with a default, with coercion and clamp - raw_scan_interval = entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL.seconds) - try: - scan_interval = int(float(raw_scan_interval)) - except (TypeError, ValueError): - scan_interval = int(DEFAULT_SCAN_INTERVAL.total_seconds()) - - if scan_interval < 5: - _LOGGER.debug( - "Configured scan interval %s is below minimum; clamping to 5 seconds", - scan_interval, + # v1 entries: trigger reauthentication so user can provide v2 credentials + if api_version == "v1": + raise ConfigEntryAuthFailed( + "This panel requires reauthentication. " + "Please reauthenticate with your panel passphrase or proximity." ) - scan_interval = 5 - if str(raw_scan_interval) != str(scan_interval): - _LOGGER.debug( - "Coerced scan interval option from raw=%s to %s seconds", - raw_scan_interval, - scan_interval, - ) - - # Log at INFO so it is visible without debug logging - _LOGGER.info( - "Span Panel startup: using scan interval=%s seconds (raw option=%s)", - scan_interval, - raw_scan_interval, - ) - - # Determine simulation config path if in simulation mode - simulation_mode = config.get("simulation_mode", False) - simulation_config_path = None - simulation_start_time = None - simulation_offline_minutes = entry.options.get(CONF_SIMULATION_OFFLINE_MINUTES, 0) - - if simulation_mode: - # Get the selected simulation config from config entry, default to 32-circuit - selected_config = config.get(CONF_SIMULATION_CONFIG, "simulation_config_32_circuit") - - current_dir = os.path.dirname(__file__) - simulation_config_path = os.path.join( - current_dir, "simulation_configs", f"{selected_config}.yaml" - ) - _LOGGER.debug( - "Using simulation config: %s (selected: %s)", simulation_config_path, selected_config - ) - - # Get simulation start time from config entry data or options - simulation_start_time_str = config.get(CONF_SIMULATION_START_TIME) - if not simulation_start_time_str: - # Try to get from options (for existing integrations) - simulation_start_time_str = entry.options.get(CONF_SIMULATION_START_TIME) - - if simulation_start_time_str: - try: - simulation_start_time = datetime.fromisoformat(simulation_start_time_str) - _LOGGER.debug("Using simulation start time: %s", simulation_start_time) - except (ValueError, TypeError) as e: - _LOGGER.warning( - "Invalid simulation start time format '%s': %s", simulation_start_time_str, e - ) - simulation_start_time = None - else: - # Ensure no simulation parameters are used for live panels - _LOGGER.debug("Simulation mode disabled - using live panel connection") + coordinator: SpanPanelCoordinator | None = None try: - span_panel = SpanPanel( - host=config[CONF_HOST], - access_token=config[CONF_ACCESS_TOKEN], - options=Options(entry), - use_ssl=use_ssl_value, - scan_interval=scan_interval, - simulation_mode=simulation_mode, - simulation_config_path=simulation_config_path, - simulation_start_time=simulation_start_time, - simulation_offline_minutes=simulation_offline_minutes, - ) - - _LOGGER.debug("Created SpanPanel instance: %s", span_panel) - - # Initialize the API client using Long-Lived Pattern - await span_panel.api.setup() + # --- v2 MQTT entries --- + if api_version == "v2": + required_keys = ( + CONF_EBUS_BROKER_HOST, + CONF_EBUS_BROKER_USERNAME, + CONF_EBUS_BROKER_PASSWORD, + CONF_EBUS_BROKER_PORT, + ) + missing = [k for k in required_keys if not config.get(k)] + if missing: + raise ConfigEntryAuthFailed( # noqa: TRY301 + f"v2 panel is missing MQTT credentials ({', '.join(missing)}). " + "Please reauthenticate to provide a passphrase." + ) - async def _test_connection() -> None: - """Test API connection and raise ConnectionError if it fails.""" - ping_result = await span_panel.api.ping() - if not ping_result: - _LOGGER.error("API ping test failed during setup") - raise ConnectionError("Failed to establish connection to SPAN Panel") + host = config[CONF_HOST] + serial_number = entry.unique_id + if not serial_number: + raise ConfigEntryNotReady( # noqa: TRY301 + "Config entry has no unique_id (serial number)" + ) - async def _test_authenticated_connection() -> None: - """Test authenticated API connection and raise ConnectionError if it fails.""" - try: - auth_test_success = await span_panel.api.ping_with_auth() - if not auth_test_success: - _LOGGER.error("Authenticated API test failed during setup") - raise ConnectionError("Failed to authenticate with SPAN Panel") - _LOGGER.debug("Successfully tested authenticated connection") - except SpanPanelAuthError as e: - _LOGGER.error("Authentication error during setup: %s", e) - _LOGGER.error( - "The stored access token may be invalid. " - "Please reconfigure the integration with a new token." + # The MQTT broker runs on the panel itself. The panel advertises + # its own mDNS hostname (.local) as ebusBrokerHost, but mDNS + # does not resolve across VLAN boundaries. Use the user-configured + # panel host (IP or FQDN) which is known reachable. + advertised_broker = config[CONF_EBUS_BROKER_HOST] + if advertised_broker != host: + _LOGGER.debug( + "Panel advertised broker host '%s' differs from configured " + "host '%s'; using configured host for MQTT connection", + advertised_broker, + host, ) - raise ConnectionError( - f"Authentication failed: {e}. Please reconfigure with a new access token." - ) from e - # Verify the connection is working by doing a test call - _LOGGER.debug("Testing API connection...") - await _test_connection() + broker_config = MqttClientConfig( + broker_host=host, + username=config[CONF_EBUS_BROKER_USERNAME], + password=config[CONF_EBUS_BROKER_PASSWORD], + mqtts_port=int(config[CONF_EBUS_BROKER_PORT]), + ) - # If we have a token, also test authenticated endpoints - if span_panel.api.access_token: - _LOGGER.debug("Testing authenticated API connection...") - await _test_authenticated_connection() + panel_http_port = int(config.get(CONF_HTTP_PORT, 80)) - _LOGGER.debug("Successfully set up and tested SPAN Panel API client") + snapshot_interval = entry.options.get( + SNAPSHOT_UPDATE_INTERVAL, DEFAULT_SNAPSHOT_INTERVAL + ) + client = SpanMqttClient( + host, + serial_number, + broker_config, + snapshot_interval=snapshot_interval, + panel_http_port=panel_http_port, + ) + try: + await client.connect() + except SpanPanelAuthError as err: + await client.close() + raise ConfigEntryAuthFailed(f"MQTT authentication failed: {err}") from err + except (SpanPanelConnectionError, SpanPanelTimeoutError) as err: + await client.close() + raise ConfigEntryNotReady(f"Failed to connect to SPAN panel: {err}") from err + + coordinator = SpanPanelCoordinator(hass, client, entry) + await coordinator.async_config_entry_first_refresh() + await coordinator.async_setup_streaming() + + if entry.options.get( + ENABLE_CURRENT_MONITORING, False + ) or await CurrentMonitor.async_is_enabled(hass, entry): + monitor = CurrentMonitor(hass, entry) + await monitor.async_start() + coordinator.current_monitor = monitor + + graph_horizon = GraphHorizonManager(hass, entry) + await graph_horizon.async_load() + coordinator.graph_horizon_manager = graph_horizon - coordinator = SpanPanelCoordinator(hass, span_panel, entry) - _LOGGER.debug("DEBUG: Created coordinator: %s", coordinator) + else: + raise ConfigEntryError( # noqa: TRY301 + f"Unknown api_version: {api_version}" + ) - await coordinator.async_config_entry_first_refresh() - _LOGGER.debug( - "DEBUG: Initial data refresh completed - coordinator data: %s", - type(coordinator.data).__name__ if coordinator.data else "None", - ) + # --- Common setup for all transport modes --- entry.async_on_unload(entry.add_update_listener(update_listener)) - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = { - COORDINATOR: coordinator, - NAME: name, - } + entry.runtime_data = SpanPanelRuntimeData(coordinator=coordinator) - # Generate default device name based on existing devices - serial_number = span_panel.status.serial_number - - # Determine if this is a simulator - is_simulator = any( - [ - "sim" in serial_number.lower(), - serial_number.lower().startswith("myserial"), - serial_number.lower().startswith("span-sim"), - entry.data.get(CONF_SIMULATION_CONFIG) is not None, - ] - ) + snapshot: SpanPanelSnapshot = coordinator.data + serial_number = snapshot.serial_number - # Create smart default name - base_name = "SPAN Simulator" if is_simulator else "SPAN Panel" - _LOGGER.debug( - "DEVICE_NAME_DEBUG: Base name selected: %s (is_simulator: %s)", base_name, is_simulator - ) + base_name = "SPAN Panel" # Check existing config entries to avoid conflicts existing_entries = hass.config_entries.async_entries(DOMAIN) @@ -282,7 +226,6 @@ async def _test_authenticated_connection() -> None: if e.title and e.title != serial_number and e.entry_id != entry.entry_id } - # Find unique name smart_device_name = base_name counter = 2 while smart_device_name in existing_titles: @@ -293,342 +236,104 @@ async def _test_authenticated_connection() -> None: if entry.title == serial_number: hass.config_entries.async_update_entry(entry, title=smart_device_name) - # PHASE 1 Ensure device is registered BEFORE sensors are created - await ensure_device_registered(hass, entry, span_panel, smart_device_name) + await ensure_device_registered(hass, entry, snapshot, smart_device_name) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - - # Migration detection moved to coordinator update cycle - except Exception: - # Clean up on failure - try: - if "span_panel" in locals(): - span_panel_instance = locals().get("span_panel") - if isinstance(span_panel_instance, SpanPanel): - await span_panel_instance.close() - except (ConnectionError, OSError, TimeoutError, AttributeError) as cleanup_error: - _LOGGER.debug("Error during cleanup: %s", cleanup_error) + if coordinator is not None: + await coordinator.async_shutdown() raise else: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Unload a config entry.""" - _LOGGER.debug("Unloading SPAN Panel integration") +async def async_unload_entry(hass: HomeAssistant, entry: SpanPanelConfigEntry) -> bool: + """Unload a config entry. - # Reset span-panel-api delay function to default - set_async_delay_func(None) # Reset to default asyncio.sleep - _LOGGER.debug("Reset span-panel-api delay function to default") - - # Get the coordinator and clean up resources - coordinator_data = hass.data[DOMAIN].get(entry.entry_id) - if coordinator_data and COORDINATOR in coordinator_data: - coordinator = coordinator_data[COORDINATOR] - # Clean up the API client resources - if hasattr(coordinator, "span_panel_api") and coordinator.span_panel_api: - span_panel = coordinator.span_panel_api - try: - # SpanPanel has a close method that properly cleans up the API client - if isinstance(span_panel, SpanPanel): - await span_panel.close() - _LOGGER.debug("Successfully closed SpanPanel API client") - except TypeError as e: - # Handle non-awaitable objects gracefully - _LOGGER.debug("API close method is not awaitable, skipping cleanup: %s", e) - except Exception as e: - _LOGGER.error("Error during API cleanup: %s", e) - else: - _LOGGER.warning("No coordinator data found for entry %s", entry.entry_id) + Unload the platforms first; only tear the coordinator down if that + succeeded. If a platform raises during unload, HA retries with the + coordinator still alive — shutting it down first would leave + entities pointing at a closed MQTT client. + """ + _LOGGER.debug("Unloading SPAN Panel integration") - _LOGGER.debug("Unloading platforms: %s", PLATFORMS) unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if not unload_ok: + return False - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id, None) - _LOGGER.debug("Successfully unloaded SPAN Panel integration") - else: - _LOGGER.error("Failed to unload some platforms") - - return bool(unload_ok) - - -async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Remove a config entry and clean up all associated data.""" - _LOGGER.debug("Removing SPAN Panel integration entry: %s", entry.entry_id) - - # Ensure the entry is unloaded first - if entry.state.recoverable: - await async_unload_entry(hass, entry) - - # Additional cleanup for any remaining data - # This is called when the entry is permanently removed from Home Assistant - _LOGGER.debug("SPAN Panel integration entry removed: %s", entry.entry_id) - + if hasattr(entry, "runtime_data") and entry.runtime_data is not None: + if entry.runtime_data.coordinator.current_monitor is not None: + entry.runtime_data.coordinator.current_monitor.async_stop() + await entry.runtime_data.coordinator.async_shutdown() -async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Update listener.""" - _LOGGER.debug("=== SPAN PANEL UPDATE LISTENER CALLED ===") - _LOGGER.debug("Configuration options changed, reloading SPAN Panel integration") - _LOGGER.debug("Update listener called with options: %s", entry.options) - _LOGGER.debug("Update listener called for entry_id: %s, title: %s", entry.entry_id, entry.title) - try: - # Check if Home Assistant is shutting down - if hass.state is not CoreState.running: - _LOGGER.debug("Home Assistant is shutting down, skipping update listener") - return + return True - # Get the coordinator from hass data - coordinator_data = hass.data.get(DOMAIN, {}).get(entry.entry_id, {}) - coordinator = coordinator_data.get(COORDINATOR) - _LOGGER.debug( - "Update listener - coordinator: %s", - coordinator is not None, - ) +async def async_remove_config_entry_device( + hass: HomeAssistant, + config_entry: SpanPanelConfigEntry, + device_entry: dr.DeviceEntry, +) -> bool: + """Allow manual removal of a device (e.g., stale EVSE sub-device). - if coordinator: - # Handle simulation options change (offline minutes, start time) - simulation_offline_minutes = entry.options.get(CONF_SIMULATION_OFFLINE_MINUTES, 0) - simulation_timestamp = entry.options.get("_simulation_timestamp", 0) - _LOGGER.info( - "Update listener: processing simulation_offline_minutes = %s (timestamp: %s)", - simulation_offline_minutes, - simulation_timestamp, - ) + The main panel device cannot be removed — only sub-devices (like EVSE + chargers) that are no longer present can be removed by the user. + """ + if not hasattr(config_entry, "runtime_data") or config_entry.runtime_data is None: + return True - # Update simulation parameters in the existing API instance - if hasattr(coordinator, "span_panel") and coordinator.span_panel: - span_panel = coordinator.span_panel - if hasattr(span_panel, "api") and span_panel.api: - _LOGGER.info( - "Found existing SpanPanel API instance, updating simulation parameters" - ) - - # Only update simulation offline mode if the API is actually in simulation mode - if span_panel.api.simulation_mode: - # Update simulation offline mode - this should start the offline timer - # from "now" - # The simulation_start_time is separate and used for the simulated time of day - _LOGGER.info( - "Calling span_panel.api.set_simulation_offline_mode(%s)", - simulation_offline_minutes, - ) - span_panel.api.set_simulation_offline_mode(simulation_offline_minutes) - _LOGGER.info("Successfully called set_simulation_offline_mode") - else: - _LOGGER.debug( - "Skipping simulation offline mode update - API not in simulation mode" - ) - else: - _LOGGER.warning("SpanPanel API instance not found in coordinator") - else: - _LOGGER.warning("SpanPanel instance not found in coordinator") - - # Handle solar options change - native sensors are created/removed via reload - solar_enabled = entry.options.get(INVERTER_ENABLE, False) - leg1_raw = entry.options.get(INVERTER_LEG1, 0) - leg2_raw = entry.options.get(INVERTER_LEG2, 0) - - # Coerce legs to integers to handle legacy string-stored options - try: - leg1 = int(leg1_raw) - except (TypeError, ValueError): - leg1 = 0 - try: - leg2 = int(leg2_raw) - except (TypeError, ValueError): - leg2 = 0 + coordinator = config_entry.runtime_data.coordinator + snapshot = coordinator.data - _LOGGER.debug( - "Solar options change - enabled: %s, leg1: %s, leg2: %s", solar_enabled, leg1, leg2 - ) + # Identify the main panel device identifier + panel_identifier = snapshot.serial_number - # With native sensors, solar configuration changes require integration reload - # to recreate sensors with the new configuration - if solar_enabled and (leg1 <= 0 or leg2 <= 0): - _LOGGER.warning( - "Solar enabled but invalid leg configuration: leg1=%s, leg2=%s", leg1, leg2 - ) + # Prevent removal of the main panel device + for identifier in device_entry.identifiers: + if identifier == (DOMAIN, panel_identifier): + return False - _LOGGER.debug("Solar options changed - integration will reload to apply changes") + return True - # Start/refresh performance instrumentation immediately if enabled - else: - _LOGGER.warning("No coordinator found for options change handling") +async def update_listener(hass: HomeAssistant, entry: SpanPanelConfigEntry) -> None: + """Handle options updates.""" + _LOGGER.debug("Configuration options changed for entry: %s", entry.entry_id) - # Check again if Home Assistant is shutting down before reload + try: if hass.state is not CoreState.running: - _LOGGER.debug("Home Assistant is shutting down, skipping reload") return - # Only reload if solar configuration changed, not for precision-only changes - if _requires_full_reload(entry): - await hass.config_entries.async_reload(entry.entry_id) - _LOGGER.debug("Successfully reloaded SPAN Panel integration") - else: - _LOGGER.debug("No full reload needed - changes applied in place") + await hass.config_entries.async_reload(entry.entry_id) + _LOGGER.debug("Successfully reloaded SPAN Panel integration") - # Clean up the simulation-only change flag if present (after reload check) - # Note: We don't clean up the flag here to avoid triggering another update listener call - # The flag will be cleaned up on the next options save except asyncio.CancelledError: - _LOGGER.debug("Update listener was cancelled during shutdown") - # Re-raise the CancelledError to properly handle the cancellation raise - except Exception as e: - _LOGGER.error("Failed to reload SPAN Panel integration: %s", e, exc_info=True) - - -def _requires_full_reload(entry: ConfigEntry) -> bool: - """Determine if a full integration reload is required based on option changes. - - Precision changes are handled in-place by handle_precision_options_change(), - simulation options are handled in-place by update_listener(), - while solar and other configuration changes require a full reload. - - Args: - entry: The config entry with current options - - Returns: - True if full reload is required, False if changes can be applied in-place - - """ - # Check if this is a simulation-only change (indicated by a flag) - has_simulation_flag = entry.options.get("_simulation_only_change", False) - _LOGGER.info( - "_requires_full_reload check: simulation_flag=%s, options=%s", - has_simulation_flag, - list(entry.options.keys()), - ) - - if has_simulation_flag: - _LOGGER.info("Simulation-only change detected - no reload needed") - return False - - _LOGGER.info("Full reload required") - - # Since we don't track previous option values, we use a conservative approach: - # - Precision changes are handled in-place (no reload needed) - # - Simulation options are handled in-place (no reload needed) - # - Solar configuration changes need reload - # - Other changes (battery, naming, etc.) need reload - - # For now, reload for solar and other configuration changes. - # The precision and simulation changes are applied in-place before this check. - _LOGGER.info("_requires_full_reload returning True - will trigger reload") - return True + except Exception as err: # noqa: BLE001 + _LOGGER.error("Failed to reload SPAN Panel integration: %s", err) async def ensure_device_registered( - hass: HomeAssistant, entry: ConfigEntry, span_panel: SpanPanel, device_name: str + hass: HomeAssistant, + entry: SpanPanelConfigEntry, + snapshot: SpanPanelSnapshot, + device_name: str, ) -> None: - """Register or reconcile the Home Assistant Device before creating sensors. - - Why this is necessary - - Synthetic sensors must attach to a specific HA Device. Ensuring the device - exists first prevents sensors from being created without a proper parent, - which can lead to mis-grouping in the UI and harder migrations later. - - Real panels: the device identifier is the hardware serial, which is stable. - If the user renames the device, we may update the display name, but no - reassignment is needed because the underlying identifier does not change. - - Simulators: the device identifier is derived from the chosen device name - (slug). If the simulator name changes (e.g., becomes unique like "SPAN Simulator 2"), - the target identifier can change, and a new device may be created. In those - cases, reassignment moves existing entities to the correct simulator device so - grouping remains consistent. For fresh simulator entries, the device is - registered before entities are created, so reassignment is a no-op; it mainly - matters after a rename that changes the identifier. - - Device renames: when a simulator device name changes, the identifier (slug) can - change, creating a new device entry. Reassignment moves entities from the old - device to the new one so grouping remains consistent. For fresh simulator - entries, the device is registered before entities are created, so reassignment - is a no-op. - - Entity IDs are preserved via registry lookup by unique_id. Renaming the device - does not retroactively rename existing entity_ids; only newly created entities - use the current device name (when USE_DEVICE_PREFIX is enabled) for ID construction. - - The device name is later used in several places (for example, as an - entity_id prefix when USE_DEVICE_PREFIX is enabled and as a fallback for - simulator device identifiers). Establishing the final device early ensures - consistent naming and attachment. - - Behavior - - If a device with the expected identifier exists, update its display name to - the smart device name when appropriate; otherwise create a new device using - ``panel_to_device_info`` with ``device_name``. - - For simulator entries, move existing entities of this config entry to the - target device if their current device_id differs, to maintain grouping and - prevent collisions. - - Args: - hass: Home Assistant instance - entry: Config entry for this SPAN device/simulator - span_panel: Connected SPAN panel API wrapper - device_name: Smart device name to use for registry and naming + """Register or reconcile the HA Device before creating sensors. + Ensures the device exists in the device registry with proper naming and + identifiers. """ - device_registry = dr.async_get(hass) - _LOGGER.debug("DEVICE_NAME_DEBUG: Received device_name parameter: %s", device_name) + serial_number = snapshot.serial_number + host = entry.data.get(CONF_HOST) - # Check if device already exists - serial_number = span_panel.status.serial_number - # Use per-entry identifier for simulators to avoid collisions - is_simulator = bool(entry.data.get("simulation_mode", False)) - desired_identifier = ( - slugify(device_name) if is_simulator and device_name and device_name else serial_number - ) - existing_device = device_registry.async_get_device(identifiers={(DOMAIN, desired_identifier)}) + existing_device = device_registry.async_get_device(identifiers={(DOMAIN, serial_number)}) if existing_device: - _LOGGER.debug("DEVICE_NAME_DEBUG: Found existing device: %s", existing_device.name) - # If device exists but has wrong name (serial number), update it if existing_device.name == serial_number: - _LOGGER.debug( - "DEVICE_NAME_DEBUG: Updating device name from '%s' to '%s'", - existing_device.name, - device_name, - ) device_registry.async_update_device(existing_device.id, name=device_name) - else: - _LOGGER.debug( - "DEVICE_NAME_DEBUG: Device already has correct name: %s", existing_device.name - ) - target_device = existing_device else: - _LOGGER.debug("DEVICE_NAME_DEBUG: No existing device found, creating new one") - # Use the provided smart device name - device_info = panel_to_device_info(span_panel, device_name) - - _LOGGER.debug("DEVICE_NAME_DEBUG: Generated device_info: %s", device_info) - - # Register device with smart default name - HA will use this as the suggested name in UI - device = device_registry.async_get_or_create(config_entry_id=entry.entry_id, **device_info) - target_device = device - - # For simulators: after a device rename that changes the identifier, move this entry's - # entities to the new target device. Fresh simulator entries register the device first - # and won't need reassignment (no-op in that case). - try: - if is_simulator: - entity_registry = er.async_get(hass) - entries = er.async_entries_for_config_entry(entity_registry, entry.entry_id) - for ent in entries: - if ent.device_id != target_device.id: - _LOGGER.debug( - "DEVICE_REASSIGN: Moving entity %s from device %s to %s", - ent.entity_id, - ent.device_id, - target_device.id, - ) - entity_registry.async_update_entity(ent.entity_id, device_id=target_device.id) - except (KeyError, ValueError, AttributeError) as mig_err: - _LOGGER.warning( - "DEVICE_REASSIGN: Failed to reassign entities to target device: %s", mig_err - ) - - -# Migration handling moved to coordinator update cycle + device_info = snapshot_to_device_info(snapshot, device_name, host=host) + device_registry.async_get_or_create(config_entry_id=entry.entry_id, **device_info) diff --git a/custom_components/span_panel/alert_dispatcher.py b/custom_components/span_panel/alert_dispatcher.py new file mode 100644 index 00000000..c49deb78 --- /dev/null +++ b/custom_components/span_panel/alert_dispatcher.py @@ -0,0 +1,305 @@ +"""Alert dispatch logic for SPAN Panel current monitoring. + +Handles notification dispatch through event bus, notify services, and +persistent notifications. All functions take ``hass`` and configuration +as explicit parameters rather than relying on instance state. +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any + +from homeassistant.core import CoreState +from homeassistant.util import dt as dt_util + +from .const import ( + DEFAULT_NOTIFICATION_MESSAGE_TEMPLATE, + DEFAULT_NOTIFICATION_PRIORITY, + DEFAULT_NOTIFICATION_TITLE_TEMPLATE, + EVENT_CURRENT_ALERT, +) +from .options import ( + NOTIFICATION_MESSAGE_TEMPLATE, + NOTIFICATION_PRIORITY, + NOTIFICATION_TITLE_TEMPLATE, + NOTIFY_TARGETS, +) + +EVENT_BUS_TARGET = "event_bus" + +if TYPE_CHECKING: + from homeassistant.core import HomeAssistant + +_LOGGER = logging.getLogger(__name__) + + +def format_notification( + *, + alert_type: str, + alert_name: str, + alert_id: str, + current_a: float, + breaker_rating_a: float, + threshold_pct: int, + utilization_pct: float, + window_duration_s: int | None, + title_template: str, + message_template: str, + local_time: str | None = None, +) -> tuple[str, str]: + """Format notification title and message using templates. + + Available placeholders: + {name} - Circuit/mains friendly name + {entity_id} - Entity ID (e.g. sensor.kitchen_current) + {alert_type} - "spike" or "continuous_overload" + {current_a} - Current draw in amps (e.g. 18.3) + {breaker_rating_a}- Breaker rating in amps (e.g. 20) + {threshold_pct} - Configured threshold percentage + {utilization_pct} - Actual utilization percentage + {window_m} - Window duration in minutes (continuous only) + {local_time} - Local time of the alert (e.g. 2:15 PM) + """ + window_m = (window_duration_s or 0) // 60 + template_vars = { + "name": alert_name, + "entity_id": alert_id, + "alert_type": alert_type, + "current_a": f"{current_a:.1f}", + "breaker_rating_a": f"{breaker_rating_a:.0f}", + "threshold_pct": str(threshold_pct), + "utilization_pct": str(utilization_pct), + "window_m": str(window_m), + "local_time": local_time or "", + } + try: + title = title_template.format_map(template_vars) + except (KeyError, ValueError): + title = f"SPAN: {alert_name} {alert_type}" + try: + message = message_template.format_map(template_vars) + except (KeyError, ValueError): + message = ( + f"{alert_name} at {current_a:.1f}A " + f"({utilization_pct}% of {breaker_rating_a:.0f}A rating) at {local_time or ''}" + ) + return title, message + + +def build_push_data(priority: str) -> dict[str, Any]: + """Build platform-specific push data for the given priority level. + + Returns a dict suitable for the ``data`` parameter of a notify service + call. Includes keys for both iOS (``push.interruption-level``) and + Android (``priority``, ``channel``) so the correct one is picked up + regardless of the receiving device platform. + """ + if priority == "default": + return {} + + android_priority_map = { + "passive": "low", + "active": "default", + "time-sensitive": "high", + "critical": "high", + } + data: dict[str, Any] = { + "push": {"interruption-level": priority}, + "priority": android_priority_map.get(priority, "default"), + } + if priority == "critical": + data["push"]["sound"] = { + "name": "default", + "critical": 1, + "volume": 1.0, + } + data["channel"] = "alarm_stream" + elif priority == "time-sensitive": + data["channel"] = "alarm_stream_other" + return data + + +async def dispatch_to_target( + hass: HomeAssistant, + target: str, + title: str, + message: str, + push_data: dict[str, Any], +) -> None: + """Send a notification to a target. + + Entity-based targets (present in ``hass.states``) use + ``notify.send_message`` with an ``entity_id``. Service-only targets + (e.g. ``notify.persistent_notification`` on older HA versions) fall + back to calling the service directly. + """ + service_data: dict[str, Any] = {"title": title, "message": message} + if push_data: + service_data["data"] = push_data + + if target.startswith("notify.") and hass.states.get(target): + service_data["entity_id"] = target + await hass.services.async_call("notify", "send_message", service_data) + else: + domain = target.split(".")[0] if "." in target else "notify" + service = target.split(".")[1] if "." in target else "notify" + await hass.services.async_call(domain, service, service_data) + + +def dispatch_alert( + hass: HomeAssistant, + settings: dict[str, Any], + *, + alert_type: str, + alert_name: str, + alert_id: str, + alert_source: str, + current_a: float, + breaker_rating_a: float, + threshold_pct: int, + utilization_pct: float, + panel_serial: str, + window_duration_s: int | None = None, + over_threshold_since: str | None = None, +) -> None: + """Dispatch alert through all enabled notification channels.""" + # `%-I` is glibc-only; strip any leading zero ourselves for portability. + local_time = dt_util.now().strftime("%I:%M %p").lstrip("0") + + event_data: dict[str, Any] = { + "alert_source": alert_source, + "alert_id": alert_id, + "alert_name": alert_name, + "alert_type": alert_type, + "current_a": round(current_a, 1), + "breaker_rating_a": breaker_rating_a, + "threshold_pct": threshold_pct, + "utilization_pct": utilization_pct, + "panel_serial": panel_serial, + "local_time": local_time, + } + if window_duration_s is not None: + event_data["window_duration_s"] = window_duration_s + if over_threshold_since is not None: + event_data["over_threshold_since"] = over_threshold_since + + raw_targets = settings.get(NOTIFY_TARGETS, "") + if isinstance(raw_targets, str): + all_targets = [t.strip() for t in raw_targets.split(",") if t.strip()] + else: + all_targets = list(raw_targets) + + if EVENT_BUS_TARGET in all_targets: + hass.bus.async_fire(EVENT_CURRENT_ALERT, event_data) + + title, message = format_notification( + alert_type=alert_type, + alert_name=alert_name, + alert_id=alert_id, + current_a=current_a, + breaker_rating_a=breaker_rating_a, + threshold_pct=threshold_pct, + utilization_pct=utilization_pct, + window_duration_s=window_duration_s, + local_time=local_time, + title_template=settings.get( + NOTIFICATION_TITLE_TEMPLATE, DEFAULT_NOTIFICATION_TITLE_TEMPLATE + ), + message_template=settings.get( + NOTIFICATION_MESSAGE_TEMPLATE, DEFAULT_NOTIFICATION_MESSAGE_TEMPLATE + ), + ) + + notify_targets = [t for t in all_targets if t != EVENT_BUS_TARGET] + + if hass.state is not CoreState.running: + _LOGGER.debug( + "Skipping alert notifications during startup (state=%s)", + hass.state, + ) + else: + priority = settings.get(NOTIFICATION_PRIORITY, DEFAULT_NOTIFICATION_PRIORITY) + push_data = build_push_data(priority) + + for target in notify_targets: + hass.async_create_task(dispatch_to_target(hass, target, title, message, push_data)) + + _LOGGER.warning( + "Current alert: %s — %s at %.1fA (%.1f%% of %.0fA rating)", + alert_name, + alert_type, + current_a, + utilization_pct, + breaker_rating_a, + ) + + +def dispatch_test_alert( + hass: HomeAssistant, + settings: dict[str, Any], +) -> None: + """Dispatch a test notification using sample values through all enabled channels.""" + alert_type = "spike" + alert_name = "Kitchen Oven" + alert_id = "sensor.kitchen_oven_current" + alert_source = "circuit" + current_a = 18.3 + breaker_rating_a = 20.0 + threshold_pct = 100 + utilization_pct = 91.5 + panel_serial = "TEST" + window_duration_s = 300 + # `%-I` is glibc-only; strip any leading zero ourselves for portability. + local_time = dt_util.now().strftime("%I:%M %p").lstrip("0") + + raw_targets = settings.get(NOTIFY_TARGETS, "") + if isinstance(raw_targets, str): + all_targets = [t.strip() for t in raw_targets.split(",") if t.strip()] + else: + all_targets = list(raw_targets) + + event_data: dict[str, Any] = { + "alert_source": alert_source, + "alert_id": alert_id, + "alert_name": alert_name, + "alert_type": alert_type, + "current_a": current_a, + "breaker_rating_a": breaker_rating_a, + "threshold_pct": threshold_pct, + "utilization_pct": utilization_pct, + "panel_serial": panel_serial, + "window_duration_s": window_duration_s, + "local_time": local_time, + "test": True, + } + + if EVENT_BUS_TARGET in all_targets: + hass.bus.async_fire(EVENT_CURRENT_ALERT, event_data) + + title, message = format_notification( + alert_type=alert_type, + alert_name=alert_name, + alert_id=alert_id, + current_a=current_a, + breaker_rating_a=breaker_rating_a, + threshold_pct=threshold_pct, + utilization_pct=utilization_pct, + window_duration_s=window_duration_s, + local_time=local_time, + title_template=settings.get( + NOTIFICATION_TITLE_TEMPLATE, DEFAULT_NOTIFICATION_TITLE_TEMPLATE + ), + message_template=settings.get( + NOTIFICATION_MESSAGE_TEMPLATE, DEFAULT_NOTIFICATION_MESSAGE_TEMPLATE + ), + ) + + notify_targets = [t for t in all_targets if t != EVENT_BUS_TARGET] + priority = settings.get(NOTIFICATION_PRIORITY, DEFAULT_NOTIFICATION_PRIORITY) + push_data = build_push_data(priority) + + for target in notify_targets: + hass.async_create_task(dispatch_to_target(hass, target, title, message, push_data)) + + _LOGGER.info("Test notification dispatched to %d target(s)", len(all_targets)) diff --git a/custom_components/span_panel/binary_sensor.py b/custom_components/span_panel/binary_sensor.py index a46aaf69..4c899e00 100644 --- a/custom_components/span_panel/binary_sensor.py +++ b/custom_components/span_panel/binary_sensor.py @@ -5,50 +5,52 @@ from collections.abc import Callable from dataclasses import dataclass import logging -from typing import Any, Generic, TypeVar from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from span_panel_api import SpanEvseSnapshot, SpanPanelSnapshot +from . import SpanPanelConfigEntry from .const import ( CONF_DEVICE_NAME, - COORDINATOR, - DOMAIN, PANEL_STATUS, - SYSTEM_CELLULAR_LINK, SYSTEM_DOOR_STATE, SYSTEM_DOOR_STATE_CLOSED, SYSTEM_DOOR_STATE_OPEN, SYSTEM_ETHERNET_LINK, SYSTEM_WIFI_LINK, + USE_CIRCUIT_NUMBERS, ) from .coordinator import SpanPanelCoordinator +from .entity import SpanPanelEntity from .helpers import ( build_binary_sensor_unique_id_for_entry, + build_evse_unique_id_for_entry, + has_bess, + resolve_evse_display_suffix, ) -from .span_panel import SpanPanel -from .span_panel_hardware_status import SpanPanelHardwareStatus -from .util import panel_to_device_info +from .util import bess_device_info, evse_device_info # pylint: disable=invalid-overridden-method _LOGGER: logging.Logger = logging.getLogger(__name__) +PARALLEL_UPDATES = 0 + @dataclass(frozen=True) class SpanPanelRequiredKeysMixin: """Required keys mixin for Span Panel binary sensors.""" - value_fn: Callable[[SpanPanelHardwareStatus], bool | None] + value_fn: Callable[[SpanPanelSnapshot], bool | None] @dataclass(frozen=True) @@ -58,7 +60,7 @@ class SpanPanelBinarySensorEntityDescription( """Describes an SpanPanelCircuits sensor entity.""" -# Door state has benn observed to return UNKNOWN if the door +# Door state has been observed to return UNKNOWN if the door # has not been operated recently so we check for invalid values # pylint: disable=unexpected-keyword-arg BINARY_SENSORS: tuple[ @@ -66,87 +68,88 @@ class SpanPanelBinarySensorEntityDescription( SpanPanelBinarySensorEntityDescription, SpanPanelBinarySensorEntityDescription, SpanPanelBinarySensorEntityDescription, - SpanPanelBinarySensorEntityDescription, ] = ( SpanPanelBinarySensorEntityDescription( key=SYSTEM_DOOR_STATE, - name="Door State", + translation_key="door_state", device_class=BinarySensorDeviceClass.TAMPER, - value_fn=lambda status_data: ( + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda s: ( None - if status_data.door_state not in [SYSTEM_DOOR_STATE_CLOSED, SYSTEM_DOOR_STATE_OPEN] - else not status_data.is_door_closed + if s.door_state not in [SYSTEM_DOOR_STATE_CLOSED, SYSTEM_DOOR_STATE_OPEN] + else s.door_state != SYSTEM_DOOR_STATE_CLOSED ), ), SpanPanelBinarySensorEntityDescription( key=SYSTEM_ETHERNET_LINK, - name="Ethernet Link", + translation_key="ethernet_link", device_class=BinarySensorDeviceClass.CONNECTIVITY, - value_fn=lambda status_data: status_data.is_ethernet_connected, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda s: s.eth0_link, ), SpanPanelBinarySensorEntityDescription( key=SYSTEM_WIFI_LINK, - name="Wi-Fi Link", - device_class=BinarySensorDeviceClass.CONNECTIVITY, - value_fn=lambda status_data: status_data.is_wifi_connected, - ), - SpanPanelBinarySensorEntityDescription( - key=SYSTEM_CELLULAR_LINK, - name="Cellular Link", + translation_key="wifi_link", device_class=BinarySensorDeviceClass.CONNECTIVITY, - value_fn=lambda status_data: status_data.is_cellular_connected, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda s: s.wlan_link, ), SpanPanelBinarySensorEntityDescription( key=PANEL_STATUS, - name="Panel Status", + translation_key="panel_status", device_class=BinarySensorDeviceClass.CONNECTIVITY, - value_fn=lambda status_data: True, # Placeholder - actual logic handled in sensor class + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda s: True, # Placeholder - actual logic handled in sensor class ), ) -T = TypeVar("T", bound=SpanPanelBinarySensorEntityDescription) +GRID_ISLANDABLE_SENSOR = SpanPanelBinarySensorEntityDescription( + key="grid_islandable", + translation_key="grid_islandable", + device_class=BinarySensorDeviceClass.POWER, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda s: s.grid_islandable, +) + +BESS_CONNECTED_SENSOR = SpanPanelBinarySensorEntityDescription( + key="bess_connected", + translation_key="bess_connected", + device_class=BinarySensorDeviceClass.CONNECTIVITY, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda s: s.battery.connected, +) -class SpanPanelBinarySensor( - CoordinatorEntity[SpanPanelCoordinator], BinarySensorEntity, Generic[T] +class SpanPanelBinarySensor[T: SpanPanelBinarySensorEntityDescription]( + SpanPanelEntity, BinarySensorEntity ): """Binary Sensor status entity.""" - _attr_has_entity_name = True - _attr_icon: str | None = "mdi:flash" - def __init__( self, data_coordinator: SpanPanelCoordinator, description: T, + device_info_override: DeviceInfo | None = None, ) -> None: """Initialize Span Panel Circuit entity.""" super().__init__(data_coordinator, context=description) - span_panel: SpanPanel = data_coordinator.data + snapshot: SpanPanelSnapshot = data_coordinator.data - # See developer_attrtribute_readme.md for why we use - # entity_description instead of _attr_entity_description self.entity_description = description self._attr_device_class = description.device_class - - # Store direct reference to the value_fn so we don't need to access through - # entity_description later, avoiding type issues self._value_fn = description.value_fn - # Consistent device name logic (matches switch) self._device_name = data_coordinator.config_entry.data.get( CONF_DEVICE_NAME, data_coordinator.config_entry.title ) - device_info: DeviceInfo = panel_to_device_info(span_panel, self._device_name) - self._attr_device_info = device_info - base_name: str | None = f"{description.name}" - - # Set entity name for HA automatic naming - self._attr_name = base_name or "" + if device_info_override is not None: + self._attr_device_info = device_info_override + else: + self._attr_device_info = self._build_device_info(data_coordinator, snapshot) self._attr_unique_id = self._construct_binary_sensor_unique_id( - data_coordinator, span_panel, description.key + data_coordinator, snapshot, description.key ) @property @@ -163,22 +166,21 @@ def available(self) -> bool: # Hardware status sensors should remain available when offline to show Unknown hardware_status_sensors = { - SYSTEM_DOOR_STATE, # Door State - SYSTEM_ETHERNET_LINK, # Ethernet Link - SYSTEM_WIFI_LINK, # Wi-Fi Link - SYSTEM_CELLULAR_LINK, # Cellular Link + SYSTEM_DOOR_STATE, + SYSTEM_ETHERNET_LINK, + SYSTEM_WIFI_LINK, } if ( hasattr(self.entity_description, "key") and self.entity_description.key in hardware_status_sensors ): - # Keep hardware status sensors available when offline so they can show Unknown if getattr(self.coordinator, "panel_offline", False): return True - # All other binary sensors (switches) use default coordinator availability logic - # This makes them unavailable when panel is offline + if getattr(self, "_attr_available", True) is False: + return False + return super().available def _handle_coordinator_update(self) -> None: @@ -187,41 +189,29 @@ def _handle_coordinator_update(self) -> None: if hasattr(self.entity_description, "key") and self.entity_description.key == PANEL_STATUS: self._attr_is_on = not self.coordinator.panel_offline self._attr_available = True - _LOGGER.debug( - "PANEL_STATUS_DEBUG: Set is_on=%s, available=%s", - self._attr_is_on, - self._attr_available, - ) super()._handle_coordinator_update() - _LOGGER.debug( - "PANEL_STATUS_DEBUG: After super() is_on=%s, available=%s", - self._attr_is_on, - self._attr_available, - ) return # Check for panel offline status first to prevent accessing None data if self.coordinator.panel_offline or self.coordinator.data is None: - # Hardware status sensors show as unknown when offline - # Other sensors (switches) will become unavailable via availability property hardware_status_sensors = { - SYSTEM_DOOR_STATE, # Door State - SYSTEM_ETHERNET_LINK, # Ethernet Link - SYSTEM_WIFI_LINK, # Wi-Fi Link - SYSTEM_CELLULAR_LINK, # Cellular Link + SYSTEM_DOOR_STATE, + SYSTEM_ETHERNET_LINK, + SYSTEM_WIFI_LINK, } if ( hasattr(self.entity_description, "key") and self.entity_description.key in hardware_status_sensors ): - self._attr_is_on = None # Show as 'unknown' for hardware status sensors + self._attr_is_on = None + self._attr_available = True _LOGGER.debug( "Hardware status sensor %s: panel offline or no data - showing as unknown", self.entity_id, ) else: - # For switches and other sensors, let availability property handle unavailable state + self._attr_available = False _LOGGER.debug( "Binary sensor %s: panel offline or no data - will be unavailable", self.entity_id, @@ -230,45 +220,167 @@ def _handle_coordinator_update(self) -> None: super()._handle_coordinator_update() return - # Panel is online and data is available - use normal logic - status_data = self.coordinator.data.status - status_value = self._value_fn(status_data) + # Panel is online and data is available — snapshot provides status fields directly + snapshot = self.coordinator.data + status_value = self._value_fn(snapshot) self._attr_is_on = status_value - self._attr_available = status_value is not None + # None means the panel reported an indeterminate state (e.g. door=UNKNOWN), + # not that the entity is broken — keep available so HA shows "unknown". + self._attr_available = True super()._handle_coordinator_update() def _construct_binary_sensor_unique_id( self, data_coordinator: SpanPanelCoordinator, - span_panel: SpanPanel, + snapshot: SpanPanelSnapshot, description_key: str, ) -> str: """Construct unique ID for binary sensor entities.""" return build_binary_sensor_unique_id_for_entry( - data_coordinator, span_panel, description_key, self._device_name + data_coordinator, snapshot, description_key, self._device_name ) +# --------------------------------------------------------------------------- +# EVSE (EV Charger) binary sensors +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class SpanEvseBinarySensorRequiredKeysMixin: + """Required keys mixin for EVSE binary sensors.""" + + value_fn: Callable[[SpanEvseSnapshot], bool | None] + + +@dataclass(frozen=True) +class SpanEvseBinarySensorEntityDescription( + BinarySensorEntityDescription, SpanEvseBinarySensorRequiredKeysMixin +): + """Describes an EVSE binary sensor entity.""" + + +_EV_CONNECTED_STATUSES: frozenset[str] = frozenset( + {"PREPARING", "CHARGING", "SUSPENDED_EV", "SUSPENDED_EVSE", "FINISHING"} +) + +EVSE_BINARY_SENSORS: tuple[ + SpanEvseBinarySensorEntityDescription, + SpanEvseBinarySensorEntityDescription, +] = ( + SpanEvseBinarySensorEntityDescription( + key="evse_charging", + translation_key="evse_charging", + device_class=BinarySensorDeviceClass.BATTERY_CHARGING, + value_fn=lambda e: (e.status or "") == "CHARGING", + ), + SpanEvseBinarySensorEntityDescription( + key="evse_ev_connected", + translation_key="evse_ev_connected", + device_class=BinarySensorDeviceClass.PLUG, + value_fn=lambda e: (e.status or "") in _EV_CONNECTED_STATUSES, + ), +) + +# Fallback EVSE snapshot used when the EVSE disappears mid-session +_EMPTY_EVSE = SpanEvseSnapshot(node_id="", feed_circuit_id="") + + +class SpanEvseBinarySensor(SpanPanelEntity, BinarySensorEntity): + """EVSE (EV charger) binary sensor entity.""" + + def __init__( + self, + data_coordinator: SpanPanelCoordinator, + description: SpanEvseBinarySensorEntityDescription, + evse_id: str, + ) -> None: + """Initialize EVSE binary sensor.""" + super().__init__(data_coordinator, context=description) + snapshot: SpanPanelSnapshot = data_coordinator.data + self._evse_id = evse_id + self.entity_description = description + self._attr_device_class = description.device_class + self._value_fn = description.value_fn + + # Build EVSE sub-device info + panel_name = ( + data_coordinator.config_entry.data.get( + CONF_DEVICE_NAME, data_coordinator.config_entry.title + ) + or "Span Panel" + ) + panel_identifier = snapshot.serial_number + + evse = snapshot.evse.get(evse_id, _EMPTY_EVSE) + use_circuit_numbers = data_coordinator.config_entry.options.get(USE_CIRCUIT_NUMBERS, False) + display_suffix = resolve_evse_display_suffix(evse, snapshot, use_circuit_numbers) + self._attr_device_info = evse_device_info( + panel_identifier, evse, panel_name, display_suffix + ) + + device_name = data_coordinator.config_entry.data.get( + CONF_DEVICE_NAME, data_coordinator.config_entry.title + ) + self._attr_unique_id = build_evse_unique_id_for_entry( + data_coordinator, snapshot, evse_id, description.key, device_name + ) + + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + if self.coordinator.panel_offline or self.coordinator.data is None: + self._attr_is_on = None + super()._handle_coordinator_update() + return + + snapshot = self.coordinator.data + evse = snapshot.evse.get(self._evse_id, _EMPTY_EVSE) + self._attr_is_on = self._value_fn(evse) + super()._handle_coordinator_update() + + async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + config_entry: SpanPanelConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up status sensor platform.""" _LOGGER.debug("ASYNC SETUP ENTRY BINARYSENSOR") - data: dict[str, Any] = hass.data[DOMAIN][config_entry.entry_id] - coordinator: SpanPanelCoordinator = data[COORDINATOR] + coordinator = config_entry.runtime_data.coordinator - entities: list[SpanPanelBinarySensor[SpanPanelBinarySensorEntityDescription]] = [] + entities: list[ + SpanPanelBinarySensor[SpanPanelBinarySensorEntityDescription] | SpanEvseBinarySensor + ] = [SpanPanelBinarySensor(coordinator, description) for description in BINARY_SENSORS] - for description in BINARY_SENSORS: - entities.append(SpanPanelBinarySensor(coordinator, description)) + # Add grid islandable binary sensor when v2 data is available + snapshot: SpanPanelSnapshot = coordinator.data + if snapshot.grid_islandable is not None: + entities.append(SpanPanelBinarySensor(coordinator, GRID_ISLANDABLE_SENSOR)) - async_add_entities(entities) + # Add BESS connected sensor on the BESS sub-device when battery is commissioned + if has_bess(snapshot): + panel_name = ( + coordinator.config_entry.data.get(CONF_DEVICE_NAME, coordinator.config_entry.title) + or "Span Panel" + ) + + bess_info = bess_device_info(snapshot.serial_number, snapshot.battery, panel_name) + entities.append( + SpanPanelBinarySensor( + coordinator, BESS_CONNECTED_SENSOR, device_info_override=bess_info + ) + ) + + # Add EVSE binary sensors for each commissioned charger + if snapshot.evse: + for evse_id in snapshot.evse: + entities.extend( + SpanEvseBinarySensor(coordinator, evse_desc, evse_id) + for evse_desc in EVSE_BINARY_SENSORS + ) - # Force immediate coordinator refresh to ensure hardware sensors update right away - await coordinator.async_request_refresh() + async_add_entities(entities) diff --git a/custom_components/span_panel/brand/dark_icon.png b/custom_components/span_panel/brand/dark_icon.png new file mode 100644 index 00000000..cab45dd1 Binary files /dev/null and b/custom_components/span_panel/brand/dark_icon.png differ diff --git a/custom_components/span_panel/brand/dark_icon@2x.png b/custom_components/span_panel/brand/dark_icon@2x.png new file mode 100644 index 00000000..85fcd1d0 Binary files /dev/null and b/custom_components/span_panel/brand/dark_icon@2x.png differ diff --git a/custom_components/span_panel/brand/dark_logo.png b/custom_components/span_panel/brand/dark_logo.png new file mode 100644 index 00000000..d99e10c3 Binary files /dev/null and b/custom_components/span_panel/brand/dark_logo.png differ diff --git a/custom_components/span_panel/brand/dark_logo@2x.png b/custom_components/span_panel/brand/dark_logo@2x.png new file mode 100644 index 00000000..5fe9e520 Binary files /dev/null and b/custom_components/span_panel/brand/dark_logo@2x.png differ diff --git a/custom_components/span_panel/brand/icon.png b/custom_components/span_panel/brand/icon.png new file mode 100644 index 00000000..407193dd Binary files /dev/null and b/custom_components/span_panel/brand/icon.png differ diff --git a/custom_components/span_panel/brand/icon@2x.png b/custom_components/span_panel/brand/icon@2x.png new file mode 100644 index 00000000..a3e35268 Binary files /dev/null and b/custom_components/span_panel/brand/icon@2x.png differ diff --git a/custom_components/span_panel/brand/logo.png b/custom_components/span_panel/brand/logo.png new file mode 100644 index 00000000..973dccb5 Binary files /dev/null and b/custom_components/span_panel/brand/logo.png differ diff --git a/custom_components/span_panel/brand/logo@2x.png b/custom_components/span_panel/brand/logo@2x.png new file mode 100644 index 00000000..dc8e68e4 Binary files /dev/null and b/custom_components/span_panel/brand/logo@2x.png differ diff --git a/custom_components/span_panel/button.py b/custom_components/span_panel/button.py new file mode 100644 index 00000000..faf24fd4 --- /dev/null +++ b/custom_components/span_panel/button.py @@ -0,0 +1,125 @@ +"""Button entities for the Span Panel.""" + +import logging +from typing import Final + +from homeassistant.components.button import ButtonEntity, ButtonEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from span_panel_api import SpanMqttClient, SpanPanelSnapshot +from span_panel_api.exceptions import SpanPanelServerError + +from . import SpanPanelConfigEntry +from .const import CONF_DEVICE_NAME +from .coordinator import SpanPanelCoordinator +from .entity import SpanPanelEntity +from .helpers import ( + async_create_span_notification, + construct_panel_unique_id_for_entry, + has_bess, +) + +_LOGGER = logging.getLogger(__name__) + +PARALLEL_UPDATES = 1 + + +GFE_OVERRIDE_DESCRIPTION: Final = ButtonEntityDescription( + key="gfe_override", + translation_key="gfe_override", +) + + +class SpanPanelGFEOverrideButton(SpanPanelEntity, ButtonEntity): + """Button entity for overriding the panel's grid-forming entity. + + The SPAN panel's GFE (dominant-power-source) is normally managed by the + battery system (BESS). When BESS communication is lost, the GFE value + becomes stale. These buttons allow a user or automation to publish a + temporary override via the eBus MQTT /set topic. The BESS automatically + reclaims control when communication is restored. + """ + + def __init__( + self, + coordinator: SpanPanelCoordinator, + description: ButtonEntityDescription, + override_value: str, + ) -> None: + """Initialize the GFE override button.""" + super().__init__(coordinator) + snapshot: SpanPanelSnapshot = coordinator.data + + self.entity_description = description + self._override_value = override_value + + self._attr_device_info = self._build_device_info(coordinator, snapshot) + + device_name = coordinator.config_entry.data.get( + CONF_DEVICE_NAME, coordinator.config_entry.title + ) + self._attr_unique_id = construct_panel_unique_id_for_entry( + coordinator, snapshot, description.key, device_name + ) + + async def async_press(self) -> None: + """Publish the GFE override to the panel.""" + client = self.coordinator.client + if not hasattr(client, "set_dominant_power_source"): + _LOGGER.warning("Client does not support GFE override") + return + + try: + await client.set_dominant_power_source(self._override_value) + await self.coordinator.async_request_refresh() + except SpanPanelServerError: + warning_msg = ( + f"SPAN API returned a server error attempting " + f"to override GFE to {self._override_value}." + ) + _LOGGER.warning(warning_msg) + await async_create_span_notification( + self.hass, + message=warning_msg, + title="SPAN API Error", + notification_id="span_panel_gfe_override_error", + ) + + @property + def available(self) -> bool: + """Return entity availability. + + The override is only relevant when BESS communication is lost and the + panel is not already reporting grid-connected. When BESS is online or + GFE is already GRID, firmware is managing correctly and the button + should not be pressable. + """ + if getattr(self.coordinator, "panel_offline", False): + return False + if not super().available: + return False + snapshot: SpanPanelSnapshot = self.coordinator.data + bess_connected = snapshot.battery.connected if snapshot.battery else None + gfe = snapshot.dominant_power_source + if bess_connected is True: + return False + if gfe == "GRID": + return False + return True + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: SpanPanelConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up button entities for Span Panel.""" + coordinator = config_entry.runtime_data.coordinator + + entities: list[SpanPanelGFEOverrideButton] = [] + + snapshot: SpanPanelSnapshot = coordinator.data + if isinstance(coordinator.client, SpanMqttClient) and has_bess(snapshot): + entities.append(SpanPanelGFEOverrideButton(coordinator, GFE_OVERRIDE_DESCRIPTION, "GRID")) + + async_add_entities(entities) diff --git a/custom_components/span_panel/config_flow.py b/custom_components/span_panel/config_flow.py index 4036d092..75e149fd 100644 --- a/custom_components/span_panel/config_flow.py +++ b/custom_components/span_panel/config_flow.py @@ -2,121 +2,99 @@ from __future__ import annotations +import asyncio from collections.abc import Mapping import enum import logging -from pathlib import Path -import shutil -from time import time from typing import TYPE_CHECKING, Any -import uuid from homeassistant import config_entries from homeassistant.config_entries import ( - ConfigEntry, ConfigFlowContext, ConfigFlowResult, ) -from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST, CONF_SCAN_INTERVAL -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.selector import selector +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST +from homeassistant.core import callback +from homeassistant.helpers.httpx_client import get_async_client +from homeassistant.helpers.service_info.hassio import HassioServiceInfo from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo -from homeassistant.util import slugify from homeassistant.util.network import is_ipv4_address -from span_panel_api import SpanPanelClient -from span_panel_api.simulation import DynamicSimulationEngine, SimulationConfig -import voluptuous as vol -import yaml - -from custom_components.span_panel.span_panel_hardware_status import ( - SpanPanelHardwareStatus, +from span_panel_api import ( + V2AuthResponse, + delete_fqdn, + detect_api_version, + register_fqdn, +) +from span_panel_api.exceptions import ( + SpanPanelAPIError, + SpanPanelAuthError, + SpanPanelConnectionError, + SpanPanelTimeoutError, ) +import voluptuous as vol -from .config_flow_utils import ( - build_general_options_schema, - get_available_simulation_configs, - get_available_unmapped_tabs, - get_current_naming_pattern, +from .config_flow_options import ( + GENERAL_OPTIONS_SCHEMA, get_general_options_defaults, - pattern_to_flags, process_general_options_input, - validate_auth_token, - validate_host, - validate_simulation_time, ) -from .config_flow_utils.options import ( - build_entity_naming_options_schema, - get_entity_naming_options_defaults, - process_entity_naming_options_input, +from .config_flow_validation import ( + check_fqdn_tls_ready, + is_fqdn, + validate_host, + validate_v2_passphrase, + validate_v2_proximity, ) from .const import ( - CONF_API_RETRIES, - CONF_API_RETRY_BACKOFF_MULTIPLIER, - CONF_API_RETRY_TIMEOUT, - CONF_SIMULATION_CONFIG, - CONF_SIMULATION_OFFLINE_MINUTES, - CONF_SIMULATION_START_TIME, - CONF_USE_SSL, - CONFIG_API_RETRIES, - CONFIG_API_RETRY_BACKOFF_MULTIPLIER, - CONFIG_API_RETRY_TIMEOUT, - CONFIG_TIMEOUT, - COORDINATOR, + CONF_API_VERSION, + CONF_EBUS_BROKER_HOST, + CONF_EBUS_BROKER_PASSWORD, + CONF_EBUS_BROKER_PORT, + CONF_EBUS_BROKER_USERNAME, + CONF_HOP_PASSPHRASE, + CONF_HTTP_PORT, + CONF_PANEL_SERIAL, + CONF_REGISTERED_FQDN, DOMAIN, + ENABLE_ENERGY_DIP_COMPENSATION, ENTITY_NAMING_PATTERN, USE_CIRCUIT_NUMBERS, USE_DEVICE_PREFIX, EntityNamingPattern, ) -from .helpers import generate_unique_simulator_serial_number from .options import ( - BATTERY_ENABLE, ENERGY_DISPLAY_PRECISION, - ENERGY_REPORTING_GRACE_PERIOD, - INVERTER_ENABLE, - INVERTER_LEG1, - INVERTER_LEG2, POWER_DISPLAY_PRECISION, ) -from .simulation_utils import clone_panel_to_simulation -from .span_panel_api import SpanPanelApi - -_LOGGER = logging.getLogger(__name__) +if TYPE_CHECKING: + from . import SpanPanelConfigEntry -# Simulation config import/export option keys -SIM_FILE_KEY = "simulation_config_file" -SIM_EXPORT_PATH = "simulation_export_path" -SIM_IMPORT_PATH = "simulation_import_path" +_LOGGER = logging.getLogger(__name__) class ConfigFlowError(Exception): """Custom exception for config flow internal errors.""" -if TYPE_CHECKING: - from span_panel_api import SpanPanelClient - - def get_user_data_schema(default_host: str = "") -> vol.Schema: """Get the user data schema with optional default host.""" return vol.Schema( { vol.Optional(CONF_HOST, default=default_host): str, - vol.Optional(CONF_USE_SSL, default=False): bool, - vol.Optional("simulator_mode", default=False): bool, + vol.Optional(CONF_HTTP_PORT, default=80): int, vol.Optional(POWER_DISPLAY_PRECISION, default=0): int, vol.Optional(ENERGY_DISPLAY_PRECISION, default=2): int, + vol.Optional(ENABLE_ENERGY_DIP_COMPENSATION, default=True): bool, } ) STEP_USER_DATA_SCHEMA = get_user_data_schema() -STEP_AUTH_TOKEN_DATA_SCHEMA = vol.Schema( +STEP_AUTH_PASSPHRASE_DATA_SCHEMA = vol.Schema( { - vol.Optional(CONF_ACCESS_TOKEN): str, + vol.Required(CONF_HOP_PASSPHRASE): str, } ) @@ -128,34 +106,10 @@ class TriggerFlowType(enum.Enum): UPDATE_ENTRY = enum.auto() -def create_config_client(host: str, use_ssl: bool = False) -> SpanPanelClient: - """Create a SpanPanelClient with config settings for quick feedback.""" - return SpanPanelClient( - host=host, - timeout=CONFIG_TIMEOUT, - use_ssl=use_ssl, - retries=CONFIG_API_RETRIES, - retry_timeout=CONFIG_API_RETRY_TIMEOUT, - retry_backoff_multiplier=CONFIG_API_RETRY_BACKOFF_MULTIPLIER, - ) - - -def create_api_controller( - hass: HomeAssistant, - host: str, - access_token: str | None = None, # nosec -) -> SpanPanelApi: - """Create a Span Panel API controller.""" - params: dict[str, Any] = {"host": host} - if access_token is not None: - params["access_token"] = access_token - return SpanPanelApi(**params) - - class SpanPanelConfigFlow(config_entries.ConfigFlow): """Handle a config flow for Span Panel.""" - VERSION = 2 + VERSION = 6 MINOR_VERSION = 1 domain = DOMAIN @@ -169,7 +123,6 @@ def __init__(self) -> None: self.host: str | None = None self.serial_number: str | None = None self.access_token: str | None = None - self.use_ssl: bool = False self.power_display_precision: int = 0 self.energy_display_precision: int = 2 self._is_flow_setup: bool = False @@ -177,44 +130,20 @@ def __init__(self) -> None: # Initial naming selection chosen during pre-setup self._chosen_use_device_prefix: bool | None = None self._chosen_use_circuit_numbers: bool | None = None - - async def setup_flow( - self, trigger_type: TriggerFlowType, host: str, use_ssl: bool = False - ) -> None: - """Set up the flow.""" - - if self._is_flow_setup is True: - _LOGGER.error("Flow setup attempted when already set up") - raise ConfigFlowError("Flow is already set up") - - # Use config settings for quick feedback - no retries and shorter timeout - async with SpanPanelClient( - host=host, - timeout=CONFIG_TIMEOUT, - use_ssl=use_ssl, - retries=CONFIG_API_RETRIES, - retry_timeout=CONFIG_API_RETRY_TIMEOUT, - retry_backoff_multiplier=CONFIG_API_RETRY_BACKOFF_MULTIPLIER, - ) as client: - status_response = await client.get_status() - # Convert to our data class format - status_dict = status_response.to_dict() # type: ignore[attr-defined] - panel_status = SpanPanelHardwareStatus.from_dict(status_dict) - - self.trigger_flow_type = trigger_type - self.host = host - self.serial_number = panel_status.serial_number - - # Keep the existing context values and add the host value - self.context = { - **self.context, - "title_placeholders": { - **self.context.get("title_placeholders", {}), - CONF_HOST: self.host, - }, - } - - self._is_flow_setup = True + # v2 provisioning state + self.api_version: str = "v2" + self._v2_broker_host: str | None = None + self._v2_broker_port: int | None = None + self._v2_broker_username: str | None = None + self._v2_broker_password: str | None = None + self._v2_passphrase: str | None = None + self._v2_panel_serial: str | None = None + self._http_port: int = 80 + # Energy dip compensation default for fresh installs + self._enable_dip_compensation: bool = True + # FQDN registration task (async_show_progress) + self._fqdn_task: asyncio.Task[None] | None = None + self._reconfigure_fqdn_task: asyncio.Task[None] | None = None def ensure_flow_is_set_up(self) -> None: """Ensure the flow is set up.""" @@ -222,12 +151,14 @@ def ensure_flow_is_set_up(self) -> None: _LOGGER.error("Flow method called before setup") raise ConfigFlowError("Flow is not set up") - async def ensure_not_already_configured(self) -> None: + async def ensure_not_already_configured(self, raise_on_progress: bool = True) -> None: """Ensure the panel is not already configured.""" self.ensure_flow_is_set_up() - # Abort if we had already set this panel up - await self.async_set_unique_id(self.serial_number) + # Abort if we had already set this panel up. + # User-initiated flows pass raise_on_progress=False so they can + # proceed when a zeroconf discovery flow is already running. + await self.async_set_unique_id(self.serial_number, raise_on_progress=raise_on_progress) self._abort_if_unique_id_configured(updates={CONF_HOST: self.host}) async def async_step_zeroconf(self, discovery_info: ZeroconfServiceInfo) -> ConfigFlowResult: @@ -239,14 +170,100 @@ async def async_step_zeroconf(self, discovery_info: ZeroconfServiceInfo) -> Conf if not is_ipv4_address(discovery_info.host): return self.async_abort(reason="not_ipv4_address") - # Validate that this is a valid Span Panel (assume HTTP for discovery) - if not await validate_host(self.hass, discovery_info.host, use_ssl=False): + # Set a preliminary unique_id from the host to prevent duplicate + # in-progress discovery flows when mDNS fires repeatedly for the + # same IP. The default raise_on_progress=True causes subsequent + # flows for the same host to abort immediately with + # "already_in_progress". This is replaced with the serial number + # in ensure_not_already_configured() once the device is validated. + await self.async_set_unique_id(discovery_info.host) + + # Detect whether this is a v2 panel based on zeroconf service type + svc_type = getattr(discovery_info, "type", "") or "" + is_v2_service = svc_type in ("_ebus._tcp.local.", "_secure-mqtt._tcp.local.") + + if is_v2_service: + # v2 panels discovered via eBus / secure-mqtt service types + # Read optional httpPort from mDNS TXT records (non-standard port) + props = discovery_info.properties or {} + http_port_str = props.get("httpPort", props.get("httpport", "")) + try: + http_port = int(http_port_str) if http_port_str else 80 + except (ValueError, TypeError): + http_port = 80 + self._http_port = http_port + + detection = await detect_api_version( + discovery_info.host, + port=http_port, + httpx_client=get_async_client(self.hass, verify_ssl=False), + ) + if detection.api_version != "v2" or detection.status_info is None: + return self.async_abort(reason="not_span_panel") + self.api_version = "v2" + self.host = discovery_info.host + self.serial_number = detection.status_info.serial_number + self.trigger_flow_type = TriggerFlowType.CREATE_ENTRY + self.context = { + **self.context, + "title_placeholders": { + **self.context.get("title_placeholders", {}), + CONF_HOST: discovery_info.host, + }, + } + self._is_flow_setup = True + await self.ensure_not_already_configured() + return await self.async_step_confirm_discovery() + + # Non-v2 panels are not supported + return self.async_abort(reason="v1_not_supported") + + async def async_step_hassio(self, discovery_info: HassioServiceInfo) -> ConfigFlowResult: + """Handle discovery from Home Assistant Supervisor (add-on). + + Unlike zeroconf, several panels may be reachable on the same host IP + on different HTTP ports (for example the SPAN Panel Simulator add-on). + Deduplicate by panel serial, not by host, so each panel gets its own + config entry. + """ + config = discovery_info.config + host = str(config.get("host", "")) + port = int(config.get("port", 80)) + serial = str(config.get("serial", "")) + + if not host: + return self.async_abort(reason="no_host") + + # Validate panel is reachable and v2 + self._http_port = port + detection = await detect_api_version( + host, port=port, httpx_client=get_async_client(self.hass, verify_ssl=False) + ) + if detection.api_version != "v2" or detection.status_info is None: return self.async_abort(reason="not_span_panel") - # Discovered devices default to HTTP/no SSL - self.use_ssl = False - await self.setup_flow(TriggerFlowType.CREATE_ENTRY, discovery_info.host, False) - await self.ensure_not_already_configured() + # Use the serial from the panel (prefer detected over discovery hint) + panel_serial = detection.status_info.serial_number or serial + if not panel_serial: + return self.async_abort(reason="no_serial") + + # Dedup by serial — multiple panels may share the same host IP + await self.async_set_unique_id(panel_serial) + self._abort_if_unique_id_configured(updates={CONF_HOST: host, CONF_HTTP_PORT: port}) + + # Set up flow — same path as v2 zeroconf discovery + self.api_version = "v2" + self.host = host + self.serial_number = panel_serial + self.trigger_flow_type = TriggerFlowType.CREATE_ENTRY + self.context = { + **self.context, + "title_placeholders": { + **self.context.get("title_placeholders", {}), + CONF_HOST: panel_serial, + }, + } + self._is_flow_setup = True return await self.async_step_confirm_discovery() async def async_step_user(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: @@ -254,9 +271,10 @@ async def async_step_user(self, user_input: dict[str, Any] | None = None) -> Con if user_input is None: return self.async_show_form(step_id="user", data_schema=STEP_USER_DATA_SCHEMA) - # Store precision settings from user input (needed for both simulator and regular mode) + # Store precision settings from user input for later flow steps. self.power_display_precision = user_input.get(POWER_DISPLAY_PRECISION, 0) self.energy_display_precision = user_input.get(ENERGY_DISPLAY_PRECISION, 2) + self._enable_dip_compensation = user_input.get(ENABLE_ENERGY_DIP_COMPENSATION, True) _LOGGER.debug( "CONFIG_INPUT_DEBUG: User input precision - power: %s, energy: %s, full input: %s", @@ -265,12 +283,8 @@ async def async_step_user(self, user_input: dict[str, Any] | None = None) -> Con user_input, ) - # Check if simulator mode is enabled - if user_input.get("simulator_mode", False): - return await self._handle_simulator_setup(user_input) - - # For non-simulator mode, host is required host: str = user_input.get(CONF_HOST, "").strip() + self._http_port = int(user_input.get(CONF_HTTP_PORT, 80)) if not host: return self.async_show_form( step_id="user", @@ -278,170 +292,91 @@ async def async_step_user(self, user_input: dict[str, Any] | None = None) -> Con errors={"base": "host_required"}, ) - use_ssl: bool = user_input.get(CONF_USE_SSL, False) - # Validate host before setting up flow - if not await validate_host(self.hass, host, use_ssl=use_ssl): + if not await validate_host(self.hass, host, port=self._http_port): return self.async_show_form( step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors={"base": "cannot_connect"}, ) - # Store SSL setting for later use - self.use_ssl = use_ssl - - # Only setup flow if validation succeeded - if not self._is_flow_setup: - await self.setup_flow(TriggerFlowType.CREATE_ENTRY, host, use_ssl) - await self.ensure_not_already_configured() - - return await self.async_step_choose_auth_type() - - async def _handle_simulator_setup(self, user_input: dict[str, Any]) -> ConfigFlowResult: - """Handle simulator mode setup.""" - # Precision settings already stored in async_step_user - - # Check if this is the initial simulator selection or the config selection - if CONF_SIMULATION_CONFIG not in user_input: - # Show simulator configuration selection - return await self.async_step_simulator_config() - - # Get the simulation config and host - simulation_config = user_input[CONF_SIMULATION_CONFIG] - host = user_input.get(CONF_HOST, "").strip() - simulation_start_time = user_input.get(CONF_SIMULATION_START_TIME, "").strip() - - # Generate unique simulator serial number first - simulator_serial = generate_unique_simulator_serial_number(self.hass) - - # Use the generated simulator serial number as the host - # This ensures the span panel API uses the correct serial number - host = simulator_serial - - # Create entry for simulator mode - base_name = "Span Simulator" - device_name = self.get_unique_device_name(base_name) - - # Prepare config data - config_data = { - CONF_HOST: host, # This is now the simulator serial number (sim-nnn) - CONF_ACCESS_TOKEN: "simulator_token", - CONF_USE_SSL: False, - "simulation_mode": True, - CONF_SIMULATION_CONFIG: simulation_config, - "device_name": device_name, - "simulator_serial_number": simulator_serial, - } - - # Add simulation start time if provided - if simulation_start_time: - try: - validated_time = validate_simulation_time(simulation_start_time) - config_data[CONF_SIMULATION_START_TIME] = validated_time - except ValueError as e: - return self.async_show_form( - step_id="simulator_config", - data_schema=self.add_suggested_values_to_schema( - vol.Schema( - { - vol.Required( - CONF_SIMULATION_CONFIG, default="simulation_config_32_circuit" - ): vol.In(get_available_simulation_configs()), - vol.Optional(CONF_HOST, default=""): str, - vol.Optional(CONF_SIMULATION_START_TIME, default=""): str, - } - ), - user_input, - ), - errors={"base": str(e)}, - ) - - _LOGGER.debug( - "SIMULATOR_CONFIG_DEBUG: Creating simulator entry with precision - power: %s, energy: %s", - self.power_display_precision, - self.energy_display_precision, - ) - # Determine simulator naming flags based on selection (default Friendly Names) - selected_pattern = user_input.get( - ENTITY_NAMING_PATTERN, EntityNamingPattern.FRIENDLY_NAMES.value - ) - sim_use_device_prefix = True - sim_use_circuit_numbers = selected_pattern == EntityNamingPattern.CIRCUIT_NUMBERS.value - - return self.async_create_entry( - title=device_name, - data=config_data, - options={ - USE_DEVICE_PREFIX: sim_use_device_prefix, - USE_CIRCUIT_NUMBERS: sim_use_circuit_numbers, - POWER_DISPLAY_PRECISION: self.power_display_precision, - ENERGY_DISPLAY_PRECISION: self.energy_display_precision, - }, + # Detect API version — only v2 is supported + detection = await detect_api_version( + host, + port=self._http_port, + httpx_client=get_async_client(self.hass, verify_ssl=False), ) - - async def async_step_simulator_config( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Handle simulator configuration selection.""" - if user_input is None: - # Discover files dynamically and build dropdown options - available_configs = get_available_simulation_configs() - options_list = [ - {"value": key, "label": label} for key, label in available_configs.items() - ] - - # Choose a sensible default - default_key = ( - "simulation_config_32_circuit" - if "simulation_config_32_circuit" in available_configs - else next(iter(available_configs.keys())) - ) - - # Create schema with forced dropdown for simulation configuration - schema = vol.Schema( - { - vol.Required(CONF_SIMULATION_CONFIG, default=default_key): selector( - { - "select": { - "options": options_list, - "mode": "dropdown", - } - } - ), - vol.Optional(CONF_HOST, default=""): str, - vol.Optional(CONF_SIMULATION_START_TIME, default=""): str, - vol.Required( - ENTITY_NAMING_PATTERN, default=EntityNamingPattern.FRIENDLY_NAMES.value - ): vol.In( - { - EntityNamingPattern.FRIENDLY_NAMES.value: "Circuit Friendly Names", - EntityNamingPattern.CIRCUIT_NUMBERS.value: "Tab Based Names", - } - ), - } + if detection.probe_failed: + return self.async_show_form( + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA, + errors={"base": "cannot_connect"}, ) + self.api_version = detection.api_version - return self.async_show_form( - step_id="simulator_config", - data_schema=schema, - description_placeholders={ - "config_count": str(len(available_configs)), + if self.api_version == "v2": + if detection.status_info is None: + return self.async_show_form( + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA, + errors={"base": "cannot_connect"}, + ) + # Serial comes from detection + self.host = host + self.serial_number = detection.status_info.serial_number + self.trigger_flow_type = TriggerFlowType.CREATE_ENTRY + self.context = { + **self.context, + "title_placeholders": { + **self.context.get("title_placeholders", {}), + CONF_HOST: host, }, - ) + } + self._is_flow_setup = True + await self.ensure_not_already_configured(raise_on_progress=False) + return await self.async_step_choose_v2_auth() - # Continue with simulator setup using the selected config - # Ensure simulator_mode is set since it's not in the form data - user_input_with_sim_mode = dict(user_input) - user_input_with_sim_mode["simulator_mode"] = True - return await self._handle_simulator_setup(user_input_with_sim_mode) + # Non-v2 panels are not supported + return self.async_abort(reason="v1_not_supported") async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> ConfigFlowResult: """Handle a flow initiated by re-auth.""" - use_ssl = entry_data.get(CONF_USE_SSL, False) - self.use_ssl = use_ssl - await self.setup_flow(TriggerFlowType.UPDATE_ENTRY, entry_data[CONF_HOST], use_ssl) - return await self.async_step_auth_token(dict(entry_data)) + host = entry_data[CONF_HOST] + self._http_port = int(entry_data.get(CONF_HTTP_PORT, 80)) + + # Detect current API version of the panel + detection = await detect_api_version( + host, + port=self._http_port, + httpx_client=get_async_client(self.hass, verify_ssl=False), + ) + if detection.probe_failed: + return self.async_abort(reason="cannot_connect") + self.api_version = detection.api_version + + if self.api_version == "v2": + if detection.status_info is None: + return self.async_abort(reason="cannot_connect") + # v2 reauth: set up flow state manually and show confirmation + self.host = host + self.serial_number = detection.status_info.serial_number + self.trigger_flow_type = TriggerFlowType.UPDATE_ENTRY + self._is_flow_setup = True + self.context["title_placeholders"] = {"host": host} + return await self.async_step_reauth_confirm() + + # Non-v2 panels are not supported + return self.async_abort(reason="v1_not_supported") + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Show reauth context and let user choose authentication method.""" + return self.async_show_menu( + step_id="reauth_confirm", + menu_options=["auth_passphrase", "auth_proximity"], + description_placeholders={"host": self.host or ""}, + ) async def async_step_confirm_discovery( self, user_input: dict[str, Any] | None = None @@ -460,154 +395,192 @@ async def async_step_confirm_discovery( }, ) - # Pass (empty) dictionary to signal the call came from this step, not abort - return await self.async_step_choose_auth_type(user_input) + return await self.async_step_choose_v2_auth() - async def async_step_choose_auth_type( + async def async_step_choose_v2_auth( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Choose the authentication method to use.""" - self.ensure_flow_is_set_up() - - # None means this method was called by HA core as an abort - if user_input is None: - return await self.async_step_confirm_discovery() - + """Choose v2 authentication method: passphrase or proximity.""" return self.async_show_menu( - step_id="choose_auth_type", - menu_options={ - "auth_proximity": "Proof of Proximity (recommended)", - "auth_token": "Existing Auth Token", - }, + step_id="choose_v2_auth", + menu_options=["auth_passphrase", "auth_proximity"], ) async def async_step_auth_proximity( self, - entry_data: dict[str, Any] | None = None, + user_input: dict[str, Any] | None = None, ) -> ConfigFlowResult: - """Step that guide users through the proximity authentication process.""" - self.ensure_flow_is_set_up() + """Instruct user to complete the door challenge, then confirm or switch method.""" + return self.async_show_menu( + step_id="auth_proximity", + menu_options=["auth_proximity_confirm", "auth_passphrase"], + ) + + async def async_step_auth_proximity_confirm( + self, + user_input: dict[str, Any] | None = None, + ) -> ConfigFlowResult: + """Verify proximity was proven, then register.""" + if not self.host: + return self.async_abort(reason="host_not_set") - # Use config settings for quick feedback - no retries and shorter timeout - async with SpanPanelClient( - host=self.host or "", - timeout=CONFIG_TIMEOUT, - use_ssl=self.use_ssl, - retries=CONFIG_API_RETRIES, - retry_timeout=CONFIG_API_RETRY_TIMEOUT, - retry_backoff_multiplier=CONFIG_API_RETRY_BACKOFF_MULTIPLIER, - ) as client: - # Get status to check proximity state - status_response = await client.get_status() - status_dict = status_response.to_dict() # type: ignore[attr-defined] - panel_status = SpanPanelHardwareStatus.from_dict(status_dict) - - # Check if running firmware newer or older than r202342 - if panel_status.proximity_proven is not None: - # Reprompt until we are able to do proximity auth for new firmware - proximity_verified: bool = panel_status.proximity_proven - if proximity_verified is False: - return self.async_show_form(step_id="auth_proximity") - else: - # Reprompt until we are able to do proximity auth for old firmware - remaining_presses: int = panel_status.remaining_auth_unlock_button_presses - if remaining_presses != 0: - return self.async_show_form( - step_id="auth_proximity", - ) - - # Ensure host is set - if not self.host: - return self.async_abort(reason="host_not_set") - - client_name = f"home-assistant-{uuid.uuid4()}" - auth_response = await client.authenticate( - client_name, "Home Assistant Local Span Integration" + # Check proximityProven before calling register_v2 (avoids 15-min block). + # On older firmware the field is None — fall through to register_v2 directly. + try: + detection = await detect_api_version( + self.host, + port=self._http_port, + httpx_client=get_async_client(self.hass, verify_ssl=False), ) - self.access_token = auth_response.access_token - # Type checking: ensure access_token is not None before calling validate_auth_token - if self.access_token is None: - return self.async_abort(reason="invalid_access_token") - if not await validate_auth_token(self.hass, self.host, self.access_token, self.use_ssl): - return self.async_abort(reason="invalid_access_token") + except (SpanPanelAPIError, SpanPanelConnectionError, SpanPanelTimeoutError) as err: + _LOGGER.warning("Failed to detect API version during proximity auth: %s", err) + return await self.async_step_auth_proximity() + proximity_status = ( + detection.status_info.proximity_proven if detection.status_info is not None else None + ) + if proximity_status is False: + # Door challenge not completed — send back to the instruction menu. + return await self.async_step_auth_proximity() - return await self.async_step_resolve_entity(entry_data) + try: + result = await validate_v2_proximity(self.hass, self.host, port=self._http_port) + except (SpanPanelAuthError, SpanPanelConnectionError): + return await self.async_step_auth_proximity() - async def async_step_auth_token( + self._store_v2_auth_result(result, passphrase="") # nosec B106 + return await self._async_finalize_v2_auth() + + async def async_step_auth_passphrase( self, user_input: dict[str, Any] | None = None, ) -> ConfigFlowResult: - """Step that prompts user for access token.""" - self.ensure_flow_is_set_up() - + """Collect the panel passphrase for v2 authentication.""" if user_input is None: - # Show the form to prompt for the access token return self.async_show_form( - step_id="auth_token", data_schema=STEP_AUTH_TOKEN_DATA_SCHEMA + step_id="auth_passphrase", + data_schema=STEP_AUTH_PASSPHRASE_DATA_SCHEMA, ) - # Extract access token from user input - access_token: str | None = user_input.get(CONF_ACCESS_TOKEN) + passphrase = user_input.get(CONF_HOP_PASSPHRASE, "").strip() + if not passphrase: + return self.async_show_form( + step_id="auth_passphrase", + data_schema=STEP_AUTH_PASSPHRASE_DATA_SCHEMA, + errors={"base": "invalid_auth"}, + ) - # Check if token was provided and is not empty - if access_token and access_token.strip(): - self.access_token = access_token.strip() + if not self.host: + return self.async_abort(reason="host_not_set") - # Ensure host is set - if not self.host: - return self.async_abort(reason="host_not_set") + try: + result = await validate_v2_passphrase( + self.hass, self.host, passphrase, port=self._http_port + ) + except SpanPanelAuthError: + return self.async_show_form( + step_id="auth_passphrase", + data_schema=STEP_AUTH_PASSPHRASE_DATA_SCHEMA, + errors={"base": "invalid_auth"}, + ) + except SpanPanelConnectionError: + return self.async_show_form( + step_id="auth_passphrase", + data_schema=STEP_AUTH_PASSPHRASE_DATA_SCHEMA, + errors={"base": "cannot_connect"}, + ) - # Validate the provided token - if not await validate_auth_token(self.hass, self.host, self.access_token, self.use_ssl): - return self.async_show_form( - step_id="auth_token", - data_schema=STEP_AUTH_TOKEN_DATA_SCHEMA, - errors={"base": "invalid_access_token"}, - ) + self._store_v2_auth_result(result, passphrase) + return await self._async_finalize_v2_auth() + + def _store_v2_auth_result(self, result: V2AuthResponse, passphrase: str) -> None: + """Store v2 auth credentials from registration result.""" + self.access_token = result.access_token + self._v2_broker_host = result.ebus_broker_host + self._v2_broker_port = result.ebus_broker_mqtts_port + self._v2_broker_username = result.ebus_broker_username + self._v2_broker_password = result.ebus_broker_password + self._v2_passphrase = passphrase + self._v2_panel_serial = result.serial_number + + async def _async_finalize_v2_auth(self) -> ConfigFlowResult: + """Route to appropriate next step after successful v2 auth.""" + if self.trigger_flow_type == TriggerFlowType.UPDATE_ENTRY: + if "entry_id" not in self.context: + raise ValueError("Entry ID is missing from context") + return self._update_v2_entry(self.context["entry_id"]) + # If host is an FQDN, register it with the panel for TLS cert SAN inclusion + if self.host and is_fqdn(self.host): + return await self.async_step_register_fqdn() + return await self.async_step_choose_entity_naming_initial() + + async def async_step_register_fqdn( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Register FQDN with the panel and wait for TLS certificate update.""" + if not self._fqdn_task: + self._fqdn_task = self.hass.async_create_task( + self._async_register_fqdn_and_wait(), + "span_panel_register_fqdn", + ) - # Proceed to pre-setup naming selection then to entry creation - return await self.async_step_resolve_entity(user_input) + if not self._fqdn_task.done(): + return self.async_show_progress( + step_id="register_fqdn", + progress_action="registering_fqdn", + progress_task=self._fqdn_task, + ) - # If no access token was provided or it's empty, show form with error - return self.async_show_form( - step_id="auth_token", - data_schema=STEP_AUTH_TOKEN_DATA_SCHEMA, - errors={"base": "missing_access_token"}, + try: + self._fqdn_task.result() + except Exception: + _LOGGER.exception("FQDN registration failed for %s", self.host) + self._fqdn_task = None + return self.async_show_progress_done(next_step_id="fqdn_failed") + + self._fqdn_task = None + return self.async_show_progress_done(next_step_id="choose_entity_naming_initial") + + async def _async_register_fqdn_and_wait(self) -> None: + """Register the FQDN and poll until the TLS cert includes it.""" + if not self.host or not self.access_token: + raise ConfigFlowError("Host and access token required for FQDN registration") + + httpx_client = get_async_client(self.hass, verify_ssl=False) + await register_fqdn( + self.host, + self.access_token, + self.host, + port=self._http_port, + httpx_client=httpx_client, ) - async def async_step_resolve_entity( - self, - entry_data: dict[str, Any] | None = None, - ) -> ConfigFlowResult: - """Resolve the entity.""" - self.ensure_flow_is_set_up() - - # Continue based on flow trigger type - match self.trigger_flow_type: - case TriggerFlowType.CREATE_ENTRY: - if self.host is None: - raise ValueError("Host cannot be None when creating a new entry") - if self.serial_number is None: - raise ValueError("Serial number cannot be None when creating a new entry") - if self.access_token is None: - raise ValueError("Access token cannot be None when creating a new entry") - # Before creating the entry, prompt for naming pattern selection - return await self.async_step_choose_entity_naming_initial() - case TriggerFlowType.UPDATE_ENTRY: - if self.host is None: - raise ValueError("Host cannot be None when updating an entry") - if self.access_token is None: - raise ValueError("Access token cannot be None when updating an entry") - if "entry_id" not in self.context: - raise ValueError("Entry ID is missing from context") - return self.update_existing_entry( - self.context["entry_id"], + mqtts_port = self._v2_broker_port or 8883 + max_attempts = 30 + for attempt in range(max_attempts): + await asyncio.sleep(2) + if await check_fqdn_tls_ready( + self.hass, self.host, mqtts_port, http_port=self._http_port + ): + _LOGGER.debug( + "FQDN %s found in TLS cert SAN after %d attempts", self.host, - self.access_token, - entry_data or {}, + attempt + 1, ) - case _: - raise NotImplementedError() + return + + raise ConfigFlowError(f"Timed out waiting for TLS certificate to include FQDN {self.host}") + + async def async_step_fqdn_failed( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle FQDN registration failure — user may continue without it.""" + if user_input is not None: + return await self.async_step_choose_entity_naming_initial() + return self.async_show_form( + step_id="fqdn_failed", + data_schema=vol.Schema({}), + errors={"base": "fqdn_registration_failed"}, + ) def create_new_entry( self, host: str, serial_number: str, access_token: str @@ -628,43 +601,55 @@ def create_new_entry( False if self._chosen_use_circuit_numbers is None else self._chosen_use_circuit_numbers ) + entry_data: dict[str, Any] = { + CONF_HOST: host, + CONF_ACCESS_TOKEN: access_token, + "device_name": device_name, + CONF_API_VERSION: "v2", + CONF_EBUS_BROKER_HOST: self._v2_broker_host, + CONF_EBUS_BROKER_PORT: self._v2_broker_port, + CONF_EBUS_BROKER_USERNAME: self._v2_broker_username, + CONF_EBUS_BROKER_PASSWORD: self._v2_broker_password, + CONF_HOP_PASSPHRASE: self._v2_passphrase, + CONF_PANEL_SERIAL: self._v2_panel_serial, + } + + if self._http_port != 80: + entry_data[CONF_HTTP_PORT] = self._http_port + if is_fqdn(host): + entry_data[CONF_REGISTERED_FQDN] = host + return self.async_create_entry( title=device_name, - data={ - CONF_HOST: host, - CONF_ACCESS_TOKEN: access_token, - CONF_USE_SSL: self.use_ssl, - "device_name": device_name, - }, + data=entry_data, options={ USE_DEVICE_PREFIX: use_device_prefix, USE_CIRCUIT_NUMBERS: use_circuit_numbers, POWER_DISPLAY_PRECISION: self.power_display_precision, ENERGY_DISPLAY_PRECISION: self.energy_display_precision, + ENABLE_ENERGY_DIP_COMPENSATION: self._enable_dip_compensation, }, ) - def update_existing_entry( - self, - entry_id: str, - host: str, - access_token: str, - entry_data: Mapping[str, Any], - ) -> ConfigFlowResult: - """Update an existing entry with new configurations.""" - # Update the existing data with reauthed data - # Create a new mutable copy of the entry data (Mapping is immutable) - updated_data = dict(entry_data) - updated_data[CONF_HOST] = host - updated_data[CONF_ACCESS_TOKEN] = access_token - updated_data[CONF_USE_SSL] = self.use_ssl - - # An existing entry must exist before we can update it - entry: ConfigEntry[Any] | None = self.hass.config_entries.async_get_entry(entry_id) + def _update_v2_entry(self, entry_id: str) -> ConfigFlowResult: + """Update an existing config entry with new v2 MQTT credentials.""" + entry: SpanPanelConfigEntry | None = self.hass.config_entries.async_get_entry(entry_id) if entry is None: - _LOGGER.error("Config entry %s does not exist during reauth", entry_id) + _LOGGER.error("Config entry %s does not exist during v2 reauth", entry_id) return self.async_abort(reason="reauth_failed") + updated_data = dict(entry.data) + updated_data[CONF_ACCESS_TOKEN] = self.access_token + updated_data[CONF_API_VERSION] = "v2" + updated_data[CONF_EBUS_BROKER_HOST] = self._v2_broker_host + updated_data[CONF_EBUS_BROKER_PORT] = self._v2_broker_port + updated_data[CONF_EBUS_BROKER_USERNAME] = self._v2_broker_username + updated_data[CONF_EBUS_BROKER_PASSWORD] = self._v2_broker_password + updated_data[CONF_HOP_PASSPHRASE] = self._v2_passphrase + updated_data[CONF_PANEL_SERIAL] = self._v2_panel_serial + if self._http_port != 80: + updated_data[CONF_HTTP_PORT] = self._http_port + self.hass.config_entries.async_update_entry(entry, data=updated_data) self.hass.async_create_task(self.hass.config_entries.async_reload(entry_id)) return self.async_abort(reason="reauth_successful") @@ -698,7 +683,8 @@ async def async_step_choose_entity_naming_initial( schema = vol.Schema( { vol.Required( - ENTITY_NAMING_PATTERN, default=EntityNamingPattern.FRIENDLY_NAMES.value + ENTITY_NAMING_PATTERN, + default=EntityNamingPattern.FRIENDLY_NAMES.value, ): vol.In(pattern_options) } ) @@ -716,696 +702,208 @@ async def async_step_choose_entity_naming_initial( raise ConfigFlowError("Missing required parameters during entry creation") return self.create_new_entry(self.host, self.serial_number, self.access_token) - @staticmethod - @callback - def async_get_options_flow( - config_entry: config_entries.ConfigEntry, - ) -> OptionsFlowHandler: - """Create the options flow.""" - return OptionsFlowHandler() - - -OPTIONS_SCHEMA: Any = vol.Schema( - { - vol.Optional(CONF_SCAN_INTERVAL): vol.All(int, vol.Range(min=5)), - vol.Optional(BATTERY_ENABLE): bool, - vol.Optional(INVERTER_ENABLE): bool, - vol.Optional(INVERTER_LEG1): vol.All(vol.Coerce(int), vol.Range(min=0)), - vol.Optional(INVERTER_LEG2): vol.All(vol.Coerce(int), vol.Range(min=0)), - vol.Optional(ENTITY_NAMING_PATTERN): vol.In([e.value for e in EntityNamingPattern]), - vol.Optional(CONF_API_RETRIES): vol.All(int, vol.Range(min=0, max=10)), - vol.Optional(CONF_API_RETRY_TIMEOUT): vol.All( - vol.Coerce(float), vol.Range(min=0.1, max=10.0) - ), - vol.Optional(CONF_API_RETRY_BACKOFF_MULTIPLIER): vol.All( - vol.Coerce(float), vol.Range(min=1.0, max=5.0) - ), - vol.Optional(ENERGY_REPORTING_GRACE_PERIOD): vol.All(int, vol.Range(min=0, max=60)), - } -) - - -class OptionsFlowHandler(config_entries.OptionsFlow): - """Handle the options flow for Span Panel.""" - - async def async_step_init(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: - """Show the main options menu.""" - if user_input is None: - menu_options = { - "general_options": "General Options", - } - - # Add entity naming options only for live panels (not simulations) - if not self.config_entry.data.get("simulation_mode", False): - menu_options["entity_naming_options"] = "Entity Naming Options" - - # Add simulation options if this is a simulation mode integration - if self.config_entry.data.get("simulation_mode", False): - menu_options["simulation_start_time"] = "Simulation Start Time" - menu_options["simulation_offline_minutes"] = "Simulation Offline Minutes" - else: - # Live panel: offer cloning into a simulation config - menu_options["clone_panel_to_simulation"] = "Clone Panel To Simulation" - - return self.async_show_menu( - step_id="init", - menu_options=menu_options, - ) - - # This shouldn't be reached since we're showing a menu - return self.async_abort(reason="unknown") - - async def async_step_general_options( + async def async_step_reconfigure( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Manage the general options (excluding entity naming).""" - # Get available unmapped tabs for dropdown - available_tabs = await get_available_unmapped_tabs(self.hass, self.config_entry) + """Handle reconfiguration of the integration (e.g. host change).""" + reconfigure_entry = self._get_reconfigure_entry() - if user_input is not None: - # Process the user input using the utility function - filtered_input, errors = process_general_options_input( - self.config_entry, user_input, available_tabs + if user_input is None: + current_host = reconfigure_entry.data.get(CONF_HOST, "") + return self.async_show_form( + step_id="reconfigure", + data_schema=vol.Schema({vol.Required(CONF_HOST, default=current_host): str}), ) - # If no errors, proceed with saving options - if not errors: - return self.async_create_entry(title="", data=filtered_input) - else: - errors = {} + host = user_input[CONF_HOST].strip() + if not host: + return self.async_show_form( + step_id="reconfigure", + data_schema=vol.Schema({vol.Required(CONF_HOST, default=""): str}), + errors={"base": "host_required"}, + ) - # Get current values for dynamic filtering - try: - current_leg1 = int(self.config_entry.options.get(INVERTER_LEG1, 0)) - except (TypeError, ValueError): - current_leg1 = 0 + # Validate the host is reachable and is a v2 panel + http_port = int(reconfigure_entry.data.get(CONF_HTTP_PORT, 80)) try: - current_leg2 = int(self.config_entry.options.get(INVERTER_LEG2, 0)) - except (TypeError, ValueError): - current_leg2 = 0 - - # Build schema and defaults using utility functions - schema = build_general_options_schema( - self.config_entry, available_tabs, current_leg1, current_leg2, user_input - ) - defaults = get_general_options_defaults(self.config_entry, current_leg1, current_leg2) - - return self.async_show_form( - step_id="general_options", - data_schema=self.add_suggested_values_to_schema(schema, defaults), - errors=errors, - ) - - async def async_step_entity_naming_options( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Manage entity naming options including legacy upgrade and naming patterns.""" - if user_input is not None: - # Process the user input for entity naming options - filtered_input, errors = process_entity_naming_options_input( - self.config_entry, user_input + detection = await detect_api_version( + host, + port=http_port, + httpx_client=get_async_client(self.hass, verify_ssl=False), + ) + except ( + ValueError, + SpanPanelConnectionError, + SpanPanelTimeoutError, + SpanPanelAPIError, + ): + return self.async_show_form( + step_id="reconfigure", + data_schema=vol.Schema({vol.Required(CONF_HOST, default=host): str}), + errors={"base": "cannot_connect"}, ) - # If no errors, proceed with saving options - if not errors: - # Check if there are pending migrations that need to be handled by coordinator - if filtered_input.get("pending_legacy_migration", False) or filtered_input.get( - "pending_naming_migration", False - ): - # Merge with existing options to preserve all settings - merged_options = dict(self.config_entry.options) - merged_options.update(filtered_input) - - # Log the migration flags for debugging - _LOGGER.info( - "Setting migration flags: pending_naming_migration=%s, old_flags=(%s,%s), new_flags=(%s,%s)", - merged_options.get("pending_naming_migration", False), - merged_options.get("old_use_circuit_numbers", "None"), - merged_options.get("old_use_device_prefix", "None"), - merged_options.get(USE_CIRCUIT_NUMBERS, "None"), - merged_options.get(USE_DEVICE_PREFIX, "None"), - ) - - # Return the merged options to trigger reload with migration flags - return self.async_create_entry(title="", data=merged_options) - else: - # No pending migrations, proceed with normal reload - # Merge with existing options to preserve all settings - merged_options = dict(self.config_entry.options) - merged_options.update(filtered_input) - return self.async_create_entry(title="", data=merged_options) - else: - errors = {} + if detection.probe_failed: + return self.async_show_form( + step_id="reconfigure", + data_schema=vol.Schema({vol.Required(CONF_HOST, default=host): str}), + errors={"base": "cannot_connect"}, + ) - # Build the entity naming options schema - schema = build_entity_naming_options_schema(self.config_entry) - defaults = get_entity_naming_options_defaults(self.config_entry) + if detection.api_version != "v2" or detection.status_info is None: + return self.async_show_form( + step_id="reconfigure", + data_schema=vol.Schema({vol.Required(CONF_HOST, default=host): str}), + errors={"base": "cannot_connect"}, + ) - return self.async_show_form( - step_id="entity_naming_options", - data_schema=self.add_suggested_values_to_schema(schema, defaults), - errors=errors, + # Ensure the serial number matches — prevent switching to a different panel + await self.async_set_unique_id(detection.status_info.serial_number) + self._abort_if_unique_id_mismatch(reason="unique_id_mismatch") + + if is_fqdn(host): + # New host is FQDN — register it (replaces any existing FQDN on the panel) + self.host = host + self.access_token = str(reconfigure_entry.data.get(CONF_ACCESS_TOKEN, "")) + self._http_port = http_port + self._v2_broker_port = int(reconfigure_entry.data.get(CONF_EBUS_BROKER_PORT, 8883)) + return await self.async_step_reconfigure_register_fqdn() + + # New host is not an FQDN — simple update + data_updates: dict[str, Any] = {CONF_HOST: host} + old_fqdn = str(reconfigure_entry.data.get(CONF_REGISTERED_FQDN, "")) + if old_fqdn: + # Switching from FQDN to IP — clean up old registration + access_token = str(reconfigure_entry.data.get(CONF_ACCESS_TOKEN, "")) + try: + await delete_fqdn( + host, + access_token, + port=http_port, + httpx_client=get_async_client(self.hass, verify_ssl=False), + ) + except ( + SpanPanelAPIError, + SpanPanelAuthError, + SpanPanelConnectionError, + SpanPanelTimeoutError, + ): + _LOGGER.warning("Failed to delete old FQDN registration: %s", old_fqdn) + data_updates[CONF_REGISTERED_FQDN] = "" + + return self.async_update_reload_and_abort( + reconfigure_entry, + data_updates=data_updates, ) - async def async_step_entity_naming( + async def async_step_reconfigure_register_fqdn( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Manage entity naming pattern options.""" - if user_input is not None: - # Check if entity naming pattern changed - current_pattern = self._get_current_naming_pattern() - new_pattern = user_input.get(ENTITY_NAMING_PATTERN, current_pattern) - - # For legacy installations, treat the selection as a change even - # if it matches the default since we default to Friendly Names - # for display but the actual pattern is Legacy - pattern_changed = False - if current_pattern == EntityNamingPattern.LEGACY_NAMES.value: - # Pre-1.0.4 installation - any selection is a migration - # But only if they actually selected something (not just submitted - # with defaults) - if ENTITY_NAMING_PATTERN in user_input: - pattern_changed = True - else: - # Modern installation - only migrate if pattern actually changed - pattern_changed = new_pattern != current_pattern - - if pattern_changed: - # Entity naming pattern changed - update the configuration flags - naming_options = {} - if new_pattern == EntityNamingPattern.CIRCUIT_NUMBERS.value: - naming_options[USE_CIRCUIT_NUMBERS] = True - naming_options[USE_DEVICE_PREFIX] = True - elif new_pattern == EntityNamingPattern.FRIENDLY_NAMES.value: - naming_options[USE_CIRCUIT_NUMBERS] = False - naming_options[USE_DEVICE_PREFIX] = True - - _LOGGER.info( - "Pattern change: %s -> %s, setting flags: USE_CIRCUIT_NUMBERS=%s, USE_DEVICE_PREFIX=%s", - current_pattern, - new_pattern, - naming_options.get(USE_CIRCUIT_NUMBERS), - naming_options.get(USE_DEVICE_PREFIX), - ) - - # Entity ID migration will be handled after reload via pending_legacy_migration flag - - # Update only the naming-related options, preserve ALL other options - current_options = dict(self.config_entry.options) - - # Only update the specific naming flags, preserve everything else - current_options[USE_CIRCUIT_NUMBERS] = naming_options[USE_CIRCUIT_NUMBERS] - current_options[USE_DEVICE_PREFIX] = naming_options[USE_DEVICE_PREFIX] - - # Debug: Log what options we're preserving - preserved_options = { - k: v - for k, v in current_options.items() - if k not in [USE_CIRCUIT_NUMBERS, USE_DEVICE_PREFIX] - } - _LOGGER.debug("Preserving existing options: %s", preserved_options) - _LOGGER.debug( - "Solar sensor enabled: %s", - current_options.get(INVERTER_ENABLE, False), - ) - _LOGGER.debug("Inverter leg 1: %s", current_options.get(INVERTER_LEG1, 0)) - _LOGGER.debug("Inverter leg 2: %s", current_options.get(INVERTER_LEG2, 0)) - _LOGGER.debug("All options after update: %s", current_options) - - # Schedule reload after the options flow completes - async def reload_after_options_complete() -> None: - # Wait for the options flow to complete first - await self.hass.async_block_till_done() - _LOGGER.info("Reloading integration after entity naming pattern change") - await self.hass.config_entries.async_reload(self.config_entry.entry_id) - - self.hass.async_create_task(reload_after_options_complete()) - - # Return success with the updated options - this will update the config entry - _LOGGER.debug("Returning updated options to complete the flow") - return self.async_create_entry(title="", data=current_options) - else: - # No pattern change - just return success - return self.async_create_entry(title="", data={}) - - # Show entity naming form - current_pattern = self._get_current_naming_pattern() - - # For legacy installations, default to Friendly Names but allow user to choose - # For modern installations, show the current pattern - if current_pattern == EntityNamingPattern.LEGACY_NAMES.value: - display_pattern = EntityNamingPattern.FRIENDLY_NAMES.value - else: - display_pattern = current_pattern - - defaults: dict[str, Any] = { - ENTITY_NAMING_PATTERN: display_pattern, - } + """Register FQDN during reconfiguration and wait for TLS cert update.""" + if not self._reconfigure_fqdn_task: + self._reconfigure_fqdn_task = self.hass.async_create_task( + self._async_register_fqdn_and_wait(), + "span_panel_reconfigure_fqdn", + ) - # Provide placeholders for the translation system - description_placeholders = { - "friendly_example": "**Friendly Names Example**: span_panel_kitchen_outlets_power", - "circuit_example": "**Circuit Numbers Example**: span_panel_circuit_15_power", - } + if not self._reconfigure_fqdn_task.done(): + return self.async_show_progress( + step_id="reconfigure_register_fqdn", + progress_action="registering_fqdn", + progress_task=self._reconfigure_fqdn_task, + ) - _LOGGER.debug("Entity naming step - current pattern: %s", current_pattern) - _LOGGER.debug( - "Entity naming step - description placeholders: %s", - description_placeholders, - ) + try: + self._reconfigure_fqdn_task.result() + except Exception: + _LOGGER.exception("FQDN registration failed during reconfigure for %s", self.host) + self._reconfigure_fqdn_task = None + return self.async_show_progress_done(next_step_id="reconfigure_fqdn_failed") - return self.async_show_form( - step_id="entity_naming", - data_schema=self.add_suggested_values_to_schema( - self._get_entity_naming_schema(), defaults - ), - description_placeholders=description_placeholders, - ) + self._reconfigure_fqdn_task = None + return self.async_show_progress_done(next_step_id="reconfigure_fqdn_done") - async def async_step_simulation_start_time( + async def async_step_reconfigure_fqdn_done( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Edit simulation start time settings.""" - if user_input is not None: - simulation_start_time = user_input.get(CONF_SIMULATION_START_TIME, "").strip() - - _LOGGER.info("Edit simulation start time - start_time: %s", simulation_start_time) - - if simulation_start_time: - try: - simulation_start_time = validate_simulation_time(simulation_start_time) - user_input[CONF_SIMULATION_START_TIME] = simulation_start_time - except ValueError as e: - return self.async_show_form( - step_id="simulation_start_time", - data_schema=self.add_suggested_values_to_schema( - self._get_simulation_start_time_schema(), - self._get_simulation_start_time_defaults(), - ), - errors={"base": str(e)}, - ) - - # Merge with existing options to preserve other settings - merged_options = dict(self.config_entry.options) - merged_options.update(user_input) - - # Clean up any simulation-only change flag since this will trigger a reload - merged_options.pop("_simulation_only_change", None) - - _LOGGER.info("Saving simulation start time: %s", user_input) - _LOGGER.info("Merged options: %s", merged_options) - - return self.async_create_entry(title="", data=merged_options) - - return self.async_show_form( - step_id="simulation_start_time", - data_schema=self.add_suggested_values_to_schema( - self._get_simulation_start_time_schema(), - self._get_simulation_start_time_defaults(), - ), + """Complete reconfiguration after successful FQDN registration.""" + reconfigure_entry = self._get_reconfigure_entry() + return self.async_update_reload_and_abort( + reconfigure_entry, + data_updates={ + CONF_HOST: self.host or "", + CONF_REGISTERED_FQDN: self.host or "", + }, ) - async def async_step_simulation_offline_minutes( + async def async_step_reconfigure_fqdn_failed( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Edit simulation offline minutes settings.""" + """Handle FQDN registration failure during reconfigure.""" if user_input is not None: - offline_minutes = user_input.get(CONF_SIMULATION_OFFLINE_MINUTES, 0) - - _LOGGER.info("Edit simulation offline minutes - offline_minutes: %s", offline_minutes) - - # Merge with existing options to preserve other settings - merged_options = dict(self.config_entry.options) - merged_options.update(user_input) - - # Add a flag to indicate this is a simulation-only change - merged_options["_simulation_only_change"] = True - - # Add a timestamp to force change detection even when offline_minutes value is the same - # This ensures the update listener is called to restart the offline timer - merged_options["_simulation_timestamp"] = int(time()) - - return self.async_create_entry(title="", data=merged_options) - + # User chose to continue anyway — update host without FQDN registration + reconfigure_entry = self._get_reconfigure_entry() + return self.async_update_reload_and_abort( + reconfigure_entry, + data_updates={CONF_HOST: self.host or ""}, + ) return self.async_show_form( - step_id="simulation_offline_minutes", - data_schema=self.add_suggested_values_to_schema( - self._get_simulation_offline_minutes_schema(), - self._get_simulation_offline_minutes_defaults(), - ), + step_id="reconfigure_fqdn_failed", + data_schema=vol.Schema({}), + errors={"base": "fqdn_registration_failed"}, ) - async def async_step_simulation_export( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Handle simulation config export.""" - errors: dict[str, str] = {} - - if user_input is not None: - config_key = user_input.get(SIM_FILE_KEY, "") - export_path_raw = str(user_input.get(SIM_EXPORT_PATH, "")).strip() - - if not config_key: - errors[SIM_FILE_KEY] = "Please select a simulation config to export" - elif not export_path_raw: - errors[SIM_EXPORT_PATH] = "Export path is required" - else: - try: - current_file = Path(__file__) - config_dir = current_file.parent / "simulation_configs" - src_yaml = config_dir / f"{config_key}.yaml" - - export_path = Path(export_path_raw) - await self.hass.async_add_executor_job( - lambda: export_path.parent.mkdir(parents=True, exist_ok=True) - ) - if not await self.hass.async_add_executor_job(src_yaml.exists): - raise FileNotFoundError(f"Source simulation file not found: {src_yaml}") - await self.hass.async_add_executor_job(shutil.copyfile, src_yaml, export_path) - _LOGGER.info("Exported simulation config '%s' to %s", config_key, export_path) - - # Build friendly name for confirmation - friendly = get_available_simulation_configs().get(config_key, config_key) - return self.async_create_entry( - title="", - data={}, - description=f"Exported '{friendly}' to {export_path}", - ) - - except Exception as e: - _LOGGER.error("Simulation config export error: %s", e) - errors["base"] = f"Export failed: {e}" - - # Show export form - available_configs = get_available_simulation_configs() - options_list = [{"value": k, "label": v} for k, v in available_configs.items()] - current_config_key = self.config_entry.data.get( - CONF_SIMULATION_CONFIG, "simulation_config_32_circuit" - ) - default_export = f"/tmp/{current_config_key}.yaml" # nosec - - export_schema = vol.Schema( - { - vol.Required(SIM_FILE_KEY, default=current_config_key): selector( - { - "select": { - "options": options_list, - "mode": "dropdown", - } - } - ), - vol.Required(SIM_EXPORT_PATH, default=default_export): str, - } - ) + @staticmethod + @callback + def async_get_options_flow( + config_entry: SpanPanelConfigEntry, + ) -> OptionsFlowHandler: + """Create the options flow.""" + return OptionsFlowHandler() - return self.async_show_form( - step_id="simulation_export", - data_schema=export_schema, - errors=errors, - ) - async def async_step_simulation_import( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Handle simulation config import.""" - errors: dict[str, str] = {} +class OptionsFlowHandler(config_entries.OptionsFlow): + """Handle the options flow for Span Panel.""" - if user_input is not None: - import_path_raw = str(user_input.get(SIM_IMPORT_PATH, "")).strip() - - if not import_path_raw: - errors[SIM_IMPORT_PATH] = "Import path is required" - else: - try: - import_path = Path(import_path_raw) - if not await self.hass.async_add_executor_job(import_path.exists): - raise FileNotFoundError(f"Import file not found: {import_path}") - - # Load and validate YAML using span-panel-api's validator - def load_yaml_file() -> dict[str, Any]: - with import_path.open("r", encoding="utf-8") as f: - result = yaml.safe_load(f) - if result is None: - return {} - if isinstance(result, dict): - return result - return {} - - loaded_yaml = await self.hass.async_add_executor_job(load_yaml_file) - # Use DynamicSimulationEngine internal validation - config = SimulationConfig(**loaded_yaml) - engine = DynamicSimulationEngine(config_data=config) - await engine.initialize_async() - - # Copy to simulation_configs directory - current_file = Path(__file__) - config_dir = current_file.parent / "simulation_configs" - dest_name = ( - import_path.name if import_path.suffix else f"{import_path.name}.yaml" - ) - dest_yaml = config_dir / dest_name - await self.hass.async_add_executor_job( - lambda: dest_yaml.parent.mkdir(parents=True, exist_ok=True) - ) - await self.hass.async_add_executor_job(shutil.copyfile, import_path, dest_yaml) - _LOGGER.info("Imported and validated simulation config to %s", dest_yaml) - - # Update config entry to point to the imported simulation config - try: - new_data = dict(self.config_entry.data) - new_data[CONF_SIMULATION_CONFIG] = dest_yaml.stem - self.hass.config_entries.async_update_entry( - self.config_entry, data=new_data - ) - _LOGGER.debug("Set CONF_SIMULATION_CONFIG to %s", dest_yaml.stem) - except Exception as update_err: - _LOGGER.warning( - "Failed to set CONF_SIMULATION_CONFIG to %s: %s", - dest_yaml.stem, - update_err, - ) - - return self.async_create_entry( - title="", - data={}, - description=f"Imported '{dest_yaml.stem}' into simulation configurations", - ) - - except Exception as e: - _LOGGER.error("Simulation config import error: %s", e) - errors["base"] = f"Import failed: {e}" - - # Show import form - import_schema = vol.Schema( - { - vol.Required(SIM_IMPORT_PATH, default=""): str, - } + async def async_step_init(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: + """Manage the general options.""" + from . import ( # pylint: disable=import-outside-toplevel + async_apply_panel_registration, + async_load_panel_settings, + async_save_panel_settings, ) - return self.async_show_form( - step_id="simulation_import", - data_schema=import_schema, - errors=errors, - ) + if user_input is not None: + filtered_input, errors, panel_settings = process_general_options_input( + self.config_entry, user_input + ) - async def async_step_clone_panel_to_simulation( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Clone the live panel into a simulation YAML stored in simulation_configs.""" - result = await clone_panel_to_simulation(self.hass, self.config_entry, user_input) - - # If result is a ConfigFlowResult, return it directly - if hasattr(result, "type"): - return result # type: ignore[return-value] - - # Otherwise, result is (dest_path, errors) for the form - if isinstance(result, tuple) and len(result) == 2: - dest_path, errors = result - if not isinstance(errors, dict): - errors = {} + if not errors: + # Save panel settings to domain-level storage + current_ps = await async_load_panel_settings(self.hass) + current_ps.update(panel_settings) + await async_save_panel_settings(self.hass, current_ps) + await async_apply_panel_registration(self.hass) + return self.async_create_entry(title="", data=filtered_input) else: - # Fallback if result format is unexpected - _LOGGER.error( - "Unexpected result format from clone_panel_to_simulation: %s", type(result) - ) - return self.async_abort(reason="unknown") - - # If user_input was provided and there are no errors, the operation succeeded - if user_input is not None and not errors: - return self.async_create_entry( - title="Simulation Created", - data={}, - description=f"Cloned panel to {dest_path.name} in simulation_configs", - ) + errors = {} - # Compute device name for form display - device_name = self.config_entry.data.get("device_name", self.config_entry.title) + panel_settings = await async_load_panel_settings(self.hass) + schema = GENERAL_OPTIONS_SCHEMA + defaults = get_general_options_defaults(self.config_entry, panel_settings=panel_settings) - # Confirm form with destination field - schema = vol.Schema( - { - vol.Required("destination", default=str(dest_path)): selector( - {"text": {"multiline": False}} - ) - } - ) return self.async_show_form( - step_id="clone_panel_to_simulation", - data_schema=schema, - description_placeholders={ - "panel": device_name or "Span Panel", - }, + step_id="init", + data_schema=self.add_suggested_values_to_schema(schema, defaults), errors=errors, ) - async def async_step_manage_simulation_configs( + async def async_step_general_options( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Menu to import or export simulation configs.""" - if user_input is None: - return self.async_show_menu( - step_id="manage_simulation_configs", - menu_options={ - "simulation_import": "Import Simulation Config", - "simulation_export": "Export Simulation Config", - }, - ) - return self.async_abort(reason="unknown") - - def _get_simulation_start_time_schema(self) -> vol.Schema: - """Get the simulation start time schema.""" - return vol.Schema( - { - vol.Optional(CONF_SIMULATION_START_TIME): str, - } - ) - - def _get_simulation_start_time_defaults(self) -> dict[str, Any]: - """Get the simulation start time defaults.""" - return { - CONF_SIMULATION_START_TIME: self.config_entry.options.get( - CONF_SIMULATION_START_TIME, "" - ), - } - - def _get_simulation_offline_minutes_schema(self) -> vol.Schema: - """Get the simulation offline minutes schema.""" - return vol.Schema( - { - vol.Optional(CONF_SIMULATION_OFFLINE_MINUTES): int, - } - ) - - def _get_simulation_offline_minutes_defaults(self) -> dict[str, Any]: - """Get the simulation offline minutes defaults.""" - return { - CONF_SIMULATION_OFFLINE_MINUTES: self.config_entry.options.get( - CONF_SIMULATION_OFFLINE_MINUTES, 0 - ), - } - - def _get_entity_naming_schema(self) -> vol.Schema: - """Get the entity naming options schema.""" - current_pattern = self._get_current_naming_pattern() - - # Legacy installations can only migrate to friendly names first - if current_pattern == EntityNamingPattern.LEGACY_NAMES.value: - pattern_options = { - EntityNamingPattern.FRIENDLY_NAMES.value: "Friendly Names (e.g., span_panel_kitchen_outlets_power)", - } - else: - # Modern installations can switch between the two modern patterns - pattern_options = { - EntityNamingPattern.FRIENDLY_NAMES.value: "Friendly Names (e.g., span_panel_kitchen_outlets_power)", - EntityNamingPattern.CIRCUIT_NUMBERS.value: "Circuit Numbers (e.g., span_panel_circuit_15_power)", - } - - return vol.Schema( - { - vol.Optional(ENTITY_NAMING_PATTERN): vol.In(pattern_options), - } - ) - - def _get_current_naming_pattern(self) -> str: - """Determine the current entity naming pattern from configuration flags.""" - return get_current_naming_pattern(self.config_entry) - - async def _migrate_entity_ids(self, old_pattern: str, new_pattern: str) -> None: - """Migrate entity IDs when naming pattern changes.""" - _LOGGER.info("Starting entity ID migration from %s to %s", old_pattern, new_pattern) - - # Get the coordinator to handle migration using actual entity objects - coordinator_data = self.hass.data.get(DOMAIN, {}).get(self.config_entry.entry_id, {}) - coordinator = coordinator_data.get(COORDINATOR) - - if not coordinator: - _LOGGER.error("Cannot migrate entities: coordinator not found") - return - - # Determine old and new flags based on patterns - old_flags = self._pattern_to_flags(old_pattern) - new_flags = self._pattern_to_flags(new_pattern) - - # Perform the migration using the coordinator with old and new flags - success = await coordinator.migrate_entity_ids(old_flags, new_flags) - - if success: - _LOGGER.debug("Entity migration completed successfully") - else: - _LOGGER.error("Entity migration failed") - - @staticmethod - def _entities_have_device_prefix(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: - """Best-effort detection if entities already use the device prefix. - - Checks the entity registry for any entity belonging to this config entry where - the object_id starts with the device name prefix. Both FRIENDLY_NAMES and CIRCUIT_NUMBERS - patterns include the device name prefix; only LEGACY lacks it. - """ - registry = er.async_get(hass) - - # Get the device name from config entry and sanitize it - device_name = config_entry.data.get("device_name", config_entry.title) - if not device_name: - return False - - sanitized_device_name = slugify(device_name) - for entry in registry.entities.values(): - try: - if entry.config_entry_id != config_entry.entry_id: - continue - object_id = entry.entity_id.split(".", 1)[1] - # Check if the object_id starts with the device name followed by underscore - if object_id.startswith(f"{sanitized_device_name}_"): - return True - except (IndexError, AttributeError): - continue - return False - - def _pattern_to_flags(self, pattern: str) -> dict[str, bool]: - """Convert entity naming pattern to configuration flags.""" - return pattern_to_flags(pattern) - - def _mark_for_legacy_migration(self) -> None: - """Mark the config entry for legacy migration after reload. - - This method stores a flag in the config entry data that indicates a legacy - migration is needed. The integration will check for this flag after startup - but before the first update. - """ - _LOGGER.info("Marking config entry for legacy migration after reload") - - # Update the config entry data to include the migration flag - current_data = dict(self.config_entry.data) - current_data["pending_legacy_migration"] = True - - _LOGGER.info("Setting pending_legacy_migration flag in config entry data: %s", current_data) - - # Update the config entry with the migration flag - self.hass.config_entries.async_update_entry(self.config_entry, data=current_data) - + """Redirect to init for backward compatibility.""" + return await self.async_step_init(user_input) -# Export commonly used items for backward compatibility # Register the config flow handler config_entries.HANDLERS.register(DOMAIN)(SpanPanelConfigFlow) diff --git a/custom_components/span_panel/config_flow_options.py b/custom_components/span_panel/config_flow_options.py new file mode 100644 index 00000000..024af7b3 --- /dev/null +++ b/custom_components/span_panel/config_flow_options.py @@ -0,0 +1,132 @@ +"""Options flow helpers for Span Panel config flow.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.config_entries import ConfigEntry +import voluptuous as vol + +from .const import ( + DEFAULT_SNAPSHOT_INTERVAL, + ENABLE_CIRCUIT_NET_ENERGY_SENSORS, + ENABLE_ENERGY_DIP_COMPENSATION, + ENABLE_PANEL_NET_ENERGY_SENSORS, + ENABLE_UNMAPPED_CIRCUIT_SENSORS, + PANEL_ADMIN_ONLY, + PANEL_SHOW_SIDEBAR, + USE_CIRCUIT_NUMBERS, + USE_DEVICE_PREFIX, + EntityNamingPattern, +) +from .options import ( + ENERGY_REPORTING_GRACE_PERIOD, + SNAPSHOT_UPDATE_INTERVAL, +) + +GENERAL_OPTIONS_SCHEMA: vol.Schema = vol.Schema( + { + vol.Optional(PANEL_SHOW_SIDEBAR): bool, + vol.Optional(PANEL_ADMIN_ONLY): bool, + vol.Optional(SNAPSHOT_UPDATE_INTERVAL): vol.All( + vol.Coerce(float), vol.Range(min=0, max=15) + ), + vol.Optional(ENABLE_PANEL_NET_ENERGY_SENSORS): bool, + vol.Optional(ENABLE_CIRCUIT_NET_ENERGY_SENSORS): bool, + vol.Optional(ENABLE_UNMAPPED_CIRCUIT_SENSORS): bool, + vol.Optional(ENERGY_REPORTING_GRACE_PERIOD): vol.All(int, vol.Range(min=0, max=60)), + vol.Optional(ENABLE_ENERGY_DIP_COMPENSATION): bool, + } +) + + +def get_general_options_defaults( + config_entry: ConfigEntry, + panel_settings: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Get default values for general options form. + + Args: + config_entry: The config entry + panel_settings: Domain-level panel sidebar settings from storage + + Returns: + Dictionary of default values + + """ + ps = panel_settings or {} + return { + PANEL_SHOW_SIDEBAR: ps.get(PANEL_SHOW_SIDEBAR, True), + PANEL_ADMIN_ONLY: ps.get(PANEL_ADMIN_ONLY, False), + SNAPSHOT_UPDATE_INTERVAL: config_entry.options.get( + SNAPSHOT_UPDATE_INTERVAL, DEFAULT_SNAPSHOT_INTERVAL + ), + ENABLE_PANEL_NET_ENERGY_SENSORS: config_entry.options.get( + ENABLE_PANEL_NET_ENERGY_SENSORS, True + ), + ENABLE_CIRCUIT_NET_ENERGY_SENSORS: config_entry.options.get( + ENABLE_CIRCUIT_NET_ENERGY_SENSORS, True + ), + ENABLE_UNMAPPED_CIRCUIT_SENSORS: config_entry.options.get( + ENABLE_UNMAPPED_CIRCUIT_SENSORS, False + ), + ENERGY_REPORTING_GRACE_PERIOD: config_entry.options.get(ENERGY_REPORTING_GRACE_PERIOD, 15), + ENABLE_ENERGY_DIP_COMPENSATION: config_entry.options.get( + ENABLE_ENERGY_DIP_COMPENSATION, True + ), + } + + +_PANEL_SETTING_KEYS = {PANEL_SHOW_SIDEBAR, PANEL_ADMIN_ONLY} + + +def process_general_options_input( + config_entry: ConfigEntry, + user_input: dict[str, Any], +) -> tuple[dict[str, Any], dict[str, str], dict[str, Any]]: + """Process user input for general options. + + Args: + config_entry: The config entry + user_input: User input from the form + + Returns: + Tuple of (processed_options, errors, panel_settings) + + """ + errors: dict[str, str] = {} + + # Extract panel settings (domain-level, not per-entry) + panel_settings = {k: v for k, v in user_input.items() if k in _PANEL_SETTING_KEYS} + + # Filter out separator fields and panel settings from entry options + filtered_input = { + k: v + for k, v in user_input.items() + if not k.startswith("_separator") and k not in _PANEL_SETTING_KEYS + } + + # Merge with existing options to preserve unchanged values + merged_options = dict(config_entry.options) + merged_options.update(filtered_input) + filtered_input = merged_options + + # Preserve existing naming flags + use_prefix = config_entry.options.get(USE_DEVICE_PREFIX, True) + use_circuit_numbers = config_entry.options.get(USE_CIRCUIT_NUMBERS, False) + filtered_input[USE_DEVICE_PREFIX] = use_prefix + filtered_input[USE_CIRCUIT_NUMBERS] = use_circuit_numbers + + return filtered_input, errors, panel_settings + + +def get_current_naming_pattern(config_entry: ConfigEntry) -> str: + """Determine the current entity naming pattern from configuration flags.""" + use_circuit_numbers = config_entry.options.get(USE_CIRCUIT_NUMBERS, False) + use_device_prefix = config_entry.options.get(USE_DEVICE_PREFIX, False) + + if use_circuit_numbers: + return EntityNamingPattern.CIRCUIT_NUMBERS.value + if use_device_prefix: + return EntityNamingPattern.FRIENDLY_NAMES.value + return EntityNamingPattern.LEGACY_NAMES.value diff --git a/custom_components/span_panel/config_flow_utils/__init__.py b/custom_components/span_panel/config_flow_utils/__init__.py deleted file mode 100644 index 67d3189e..00000000 --- a/custom_components/span_panel/config_flow_utils/__init__.py +++ /dev/null @@ -1,125 +0,0 @@ -"""Config flow package for Span Panel integration.""" - -from typing import Any - -from homeassistant.const import CONF_SCAN_INTERVAL -from span_panel_api import SpanPanelClient -import voluptuous as vol - -from custom_components.span_panel.const import ( - CONF_API_RETRIES, - CONF_API_RETRY_BACKOFF_MULTIPLIER, - CONF_API_RETRY_TIMEOUT, - CONFIG_API_RETRIES, - CONFIG_API_RETRY_BACKOFF_MULTIPLIER, - CONFIG_API_RETRY_TIMEOUT, - CONFIG_TIMEOUT, - ENTITY_NAMING_PATTERN, - EntityNamingPattern, -) -from custom_components.span_panel.options import ( - BATTERY_ENABLE, - ENERGY_REPORTING_GRACE_PERIOD, - INVERTER_ENABLE, - INVERTER_LEG1, - INVERTER_LEG2, -) - -from .options import ( - build_general_options_schema, - entities_have_device_prefix, - get_current_naming_pattern, - get_entity_naming_schema, - get_general_options_defaults, - get_simulation_offline_minutes_defaults, - get_simulation_offline_minutes_schema, - get_simulation_start_time_defaults, - get_simulation_start_time_schema, - pattern_to_flags, - process_general_options_input, -) -from .simulation import ( - extract_serial_from_config, - get_available_simulation_configs, - get_simulation_config_path, - validate_yaml_config, -) -from .validation import ( - get_available_unmapped_tabs, - get_filtered_tab_options, - validate_auth_token, - validate_host, - validate_ipv4_address, - validate_simulation_time, - validate_solar_configuration, - validate_solar_tab_selection, -) - -# Export commonly used items for backward compatibility -OPTIONS_SCHEMA = vol.Schema( - { - vol.Optional(CONF_SCAN_INTERVAL): vol.All(int, vol.Range(min=5)), - vol.Optional(BATTERY_ENABLE): bool, - vol.Optional(INVERTER_ENABLE): bool, - vol.Optional(INVERTER_LEG1): vol.All(vol.Coerce(int), vol.Range(min=0)), - vol.Optional(INVERTER_LEG2): vol.All(vol.Coerce(int), vol.Range(min=0)), - vol.Optional(ENTITY_NAMING_PATTERN): vol.In([e.value for e in EntityNamingPattern]), - vol.Optional(CONF_API_RETRIES): vol.All(int, vol.Range(min=0, max=10)), - vol.Optional(CONF_API_RETRY_TIMEOUT): vol.All( - vol.Coerce(float), vol.Range(min=0.1, max=10.0) - ), - vol.Optional(CONF_API_RETRY_BACKOFF_MULTIPLIER): vol.All( - vol.Coerce(float), vol.Range(min=1.0, max=5.0) - ), - vol.Optional(ENERGY_REPORTING_GRACE_PERIOD): vol.All(int, vol.Range(min=0, max=60)), - } -) - -__all__ = [ - # Validation - "get_available_unmapped_tabs", - "get_filtered_tab_options", - "validate_auth_token", - "validate_host", - "validate_ipv4_address", - "validate_simulation_time", - "validate_solar_configuration", - "validate_solar_tab_selection", - # Simulation - "extract_serial_from_config", - "get_available_simulation_configs", - "get_simulation_config_path", - "validate_yaml_config", - # Options - "build_general_options_schema", - "entities_have_device_prefix", - "get_current_naming_pattern", - "get_entity_naming_schema", - "get_general_options_defaults", - "get_simulation_offline_minutes_defaults", - "get_simulation_offline_minutes_schema", - "get_simulation_start_time_defaults", - "get_simulation_start_time_schema", - "pattern_to_flags", - "process_general_options_input", - # Backward compatibility - "OPTIONS_SCHEMA", -] - - -# Define backward compatibility items to avoid circular imports -def create_config_client(host: str, use_ssl: bool = False) -> Any: - """Create a SpanPanelClient with config settings for quick feedback.""" - - return SpanPanelClient( - host=host, - timeout=CONFIG_TIMEOUT, - use_ssl=use_ssl, - retries=CONFIG_API_RETRIES, - retry_timeout=CONFIG_API_RETRY_TIMEOUT, - retry_backoff_multiplier=CONFIG_API_RETRY_BACKOFF_MULTIPLIER, - ) - - -# Export create_config_client function -__all__.extend(["create_config_client"]) diff --git a/custom_components/span_panel/config_flow_utils/options.py b/custom_components/span_panel/config_flow_utils/options.py deleted file mode 100644 index 28bdd196..00000000 --- a/custom_components/span_panel/config_flow_utils/options.py +++ /dev/null @@ -1,490 +0,0 @@ -"""Options flow utilities for Span Panel config flow.""" - -from __future__ import annotations - -import logging -from typing import Any - -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_SCAN_INTERVAL -from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.selector import selector -from homeassistant.util import slugify -import voluptuous as vol - -from custom_components.span_panel.const import ( - CONF_API_RETRIES, - CONF_API_RETRY_BACKOFF_MULTIPLIER, - CONF_API_RETRY_TIMEOUT, - CONF_SIMULATION_OFFLINE_MINUTES, - CONF_SIMULATION_START_TIME, - DEFAULT_API_RETRIES, - DEFAULT_API_RETRY_BACKOFF_MULTIPLIER, - DEFAULT_API_RETRY_TIMEOUT, - DEFAULT_SCAN_INTERVAL, - ENABLE_CIRCUIT_NET_ENERGY_SENSORS, - ENABLE_PANEL_NET_ENERGY_SENSORS, - ENABLE_SOLAR_NET_ENERGY_SENSORS, - ENTITY_NAMING_PATTERN, - USE_CIRCUIT_NUMBERS, - USE_DEVICE_PREFIX, - EntityNamingPattern, -) -from custom_components.span_panel.options import ( - BATTERY_ENABLE, - ENERGY_REPORTING_GRACE_PERIOD, - INVERTER_ENABLE, - INVERTER_LEG1, - INVERTER_LEG2, -) - -from .validation import get_filtered_tab_options, validate_solar_configuration - -_LOGGER = logging.getLogger(__name__) - - -def build_general_options_schema( - config_entry: ConfigEntry, - available_tabs: list[int], - current_leg1: int = 0, - current_leg2: int = 0, - user_input: dict[str, Any] | None = None, -) -> vol.Schema: - """Build the schema for general options form. - - Args: - config_entry: The config entry - available_tabs: List of available unmapped tabs - current_leg1: Current leg 1 selection - current_leg2: Current leg 2 selection - user_input: Current user input for dynamic updates - - Returns: - Voluptuous schema for the form - - """ - # If user_input exists, use those values for filtering (for dynamic updates) - if user_input is not None: - leg1_raw_dyn = user_input.get(INVERTER_LEG1, current_leg1) - leg2_raw_dyn = user_input.get(INVERTER_LEG2, current_leg2) - try: - current_leg1 = int(leg1_raw_dyn) - except (TypeError, ValueError): - current_leg1 = 0 - try: - current_leg2 = int(leg2_raw_dyn) - except (TypeError, ValueError): - current_leg2 = 0 - - # Create filtered tab options for each dropdown - leg1_options = get_filtered_tab_options(current_leg2, available_tabs) - leg2_options = get_filtered_tab_options(current_leg1, available_tabs) - # Convert to selector options lists (value/label) to force dropdowns - leg1_select_options = [{"value": str(k), "label": v} for k, v in leg1_options.items()] - leg2_select_options = [{"value": str(k), "label": v} for k, v in leg2_options.items()] - - schema_fields = { - vol.Optional(CONF_SCAN_INTERVAL): vol.All(int, vol.Range(min=5)), - vol.Optional(BATTERY_ENABLE): bool, - vol.Optional(INVERTER_ENABLE): bool, - vol.Optional(INVERTER_LEG1, default=str(current_leg1)): selector( - {"select": {"options": leg1_select_options, "mode": "dropdown"}} - ), - vol.Optional(INVERTER_LEG2, default=str(current_leg2)): selector( - {"select": {"options": leg2_select_options, "mode": "dropdown"}} - ), - vol.Optional(ENABLE_SOLAR_NET_ENERGY_SENSORS): bool, - vol.Optional(ENABLE_PANEL_NET_ENERGY_SENSORS): bool, - vol.Optional(ENABLE_CIRCUIT_NET_ENERGY_SENSORS): bool, - vol.Optional(CONF_API_RETRIES): vol.All(int, vol.Range(min=0, max=10)), - vol.Optional(CONF_API_RETRY_TIMEOUT): vol.All( - vol.Coerce(float), vol.Range(min=0.1, max=10.0) - ), - vol.Optional(CONF_API_RETRY_BACKOFF_MULTIPLIER): vol.All( - vol.Coerce(float), vol.Range(min=1.0, max=5.0) - ), - vol.Optional(ENERGY_REPORTING_GRACE_PERIOD): vol.All(int, vol.Range(min=0, max=60)), - } - - # Legacy upgrade option moved to Entity Naming Options - - return vol.Schema(schema_fields) - - -def get_general_options_defaults( - config_entry: ConfigEntry, current_leg1: int, current_leg2: int -) -> dict[str, Any]: - """Get default values for general options form. - - Args: - config_entry: The config entry - current_leg1: Current leg 1 selection - current_leg2: Current leg 2 selection - - Returns: - Dictionary of default values - - """ - defaults = { - CONF_SCAN_INTERVAL: config_entry.options.get( - CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL.seconds - ), - BATTERY_ENABLE: config_entry.options.get("enable_battery_percentage", False), - INVERTER_ENABLE: config_entry.options.get("enable_solar_circuit", False), - # Defaults for selector values must be strings - INVERTER_LEG1: str(current_leg1), - INVERTER_LEG2: str(current_leg2), - ENABLE_PANEL_NET_ENERGY_SENSORS: config_entry.options.get( - ENABLE_PANEL_NET_ENERGY_SENSORS, True - ), - ENABLE_CIRCUIT_NET_ENERGY_SENSORS: config_entry.options.get( - ENABLE_CIRCUIT_NET_ENERGY_SENSORS, True - ), - ENABLE_SOLAR_NET_ENERGY_SENSORS: config_entry.options.get( - ENABLE_SOLAR_NET_ENERGY_SENSORS, True - ), - CONF_API_RETRIES: config_entry.options.get(CONF_API_RETRIES, DEFAULT_API_RETRIES), - CONF_API_RETRY_TIMEOUT: config_entry.options.get( - CONF_API_RETRY_TIMEOUT, DEFAULT_API_RETRY_TIMEOUT - ), - CONF_API_RETRY_BACKOFF_MULTIPLIER: config_entry.options.get( - CONF_API_RETRY_BACKOFF_MULTIPLIER, DEFAULT_API_RETRY_BACKOFF_MULTIPLIER - ), - ENERGY_REPORTING_GRACE_PERIOD: config_entry.options.get(ENERGY_REPORTING_GRACE_PERIOD, 15), - } - - # Add legacy upgrade default if applicable - is_legacy_install = not config_entry.options.get(USE_DEVICE_PREFIX, False) - if is_legacy_install: - defaults["legacy_upgrade_to_friendly"] = False - - return defaults - - -def process_general_options_input( - config_entry: ConfigEntry, user_input: dict[str, Any], available_tabs: list[int] -) -> tuple[dict[str, Any], dict[str, str]]: - """Process user input for general options. - - Args: - config_entry: The config entry - user_input: User input from the form - available_tabs: List of available unmapped tabs - - Returns: - Tuple of (processed_options, errors) - - """ - errors: dict[str, str] = {} - - # Filter out separator fields from user input - filtered_input = {k: v for k, v in user_input.items() if not k.startswith("_separator")} - - # Handle legacy upgrade flag if present - legacy_upgrade_requested: bool = bool(user_input.get("legacy_upgrade_to_friendly", False)) - filtered_input.pop("legacy_upgrade_to_friendly", None) - - # Merge with existing options to preserve unchanged values - merged_options = dict(config_entry.options) - merged_options.update(filtered_input) - filtered_input = merged_options - - # Validate solar tab selection if solar is enabled - if filtered_input.get(INVERTER_ENABLE, False): - # Coerce selector values (strings) back to integers - leg1_raw = filtered_input.get(INVERTER_LEG1, 0) - leg2_raw = filtered_input.get(INVERTER_LEG2, 0) - try: - leg1 = int(leg1_raw) - except (TypeError, ValueError): - leg1 = 0 - try: - leg2 = int(leg2_raw) - except (TypeError, ValueError): - leg2 = 0 - - # Validate solar configuration - is_valid, error_message = validate_solar_configuration(True, leg1, leg2, available_tabs) - if not is_valid: - errors["base"] = error_message - _LOGGER.warning("Solar tab validation failed: %s", error_message) - - # Persist coerced integer values - filtered_input[INVERTER_LEG1] = leg1 - filtered_input[INVERTER_LEG2] = leg2 - - # Handle legacy upgrade if requested - if legacy_upgrade_requested: - # Mark this config entry for legacy prefix upgrade after reload - # The migration code will check which entities actually need renaming - filtered_input[USE_DEVICE_PREFIX] = True - filtered_input[USE_CIRCUIT_NUMBERS] = False - filtered_input["pending_legacy_migration"] = True - else: - # Preserve existing naming flags by default. - # Important: default use_device_prefix to True for new installs - # so we do not accidentally treat them as legacy when the option - # was not yet persisted. - use_prefix: Any | bool = config_entry.options.get(USE_DEVICE_PREFIX, True) - use_circuit_numbers: Any | bool = config_entry.options.get(USE_CIRCUIT_NUMBERS, False) - filtered_input[USE_DEVICE_PREFIX] = use_prefix - filtered_input[USE_CIRCUIT_NUMBERS] = use_circuit_numbers - - # Remove any entity naming pattern from input (shouldn't be there anyway) - filtered_input.pop(ENTITY_NAMING_PATTERN, None) - - # Clean up any simulation-only change flag since this will trigger a reload - filtered_input.pop("_simulation_only_change", None) - - return filtered_input, errors - - -def get_entity_naming_schema() -> vol.Schema: - """Get the entity naming options schema.""" - pattern_options = { - EntityNamingPattern.FRIENDLY_NAMES.value: "Friendly Names (e.g., span_panel_kitchen_outlets_power)", - EntityNamingPattern.CIRCUIT_NUMBERS.value: "Circuit Numbers (e.g., span_panel_circuit_15_power)", - } - - return vol.Schema( - { - vol.Optional(ENTITY_NAMING_PATTERN): vol.In(pattern_options), - } - ) - - -def get_current_naming_pattern(config_entry: ConfigEntry) -> str: - """Determine the current entity naming pattern from configuration flags.""" - use_circuit_numbers = config_entry.options.get(USE_CIRCUIT_NUMBERS, False) - use_device_prefix = config_entry.options.get(USE_DEVICE_PREFIX, False) - - _LOGGER.debug("Config entry options: %s", config_entry.options) - _LOGGER.debug( - "USE_CIRCUIT_NUMBERS: %s, USE_DEVICE_PREFIX: %s", use_circuit_numbers, use_device_prefix - ) - - if use_circuit_numbers: - return EntityNamingPattern.CIRCUIT_NUMBERS.value - elif use_device_prefix: - return EntityNamingPattern.FRIENDLY_NAMES.value - else: - # Pre-1.0.4 installation - no device prefix - return EntityNamingPattern.LEGACY_NAMES.value - - -def entities_have_device_prefix(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: - """Best-effort detection if entities already use the device prefix. - - Checks the entity registry for any entity belonging to this config entry where - the object_id starts with the device name prefix. Both FRIENDLY_NAMES and CIRCUIT_NUMBERS - patterns include the device name prefix; only LEGACY lacks it. - """ - registry = er.async_get(hass) - - # Get the device name from config entry and sanitize it - device_name = config_entry.data.get("device_name", config_entry.title) - if not device_name: - return False - - sanitized_device_name = slugify(device_name) - for entry in registry.entities.values(): - try: - if entry.config_entry_id != config_entry.entry_id: - continue - object_id = entry.entity_id.split(".", 1)[1] - # Check if the object_id starts with the device name followed by underscore - if object_id.startswith(f"{sanitized_device_name}_"): - return True - except (IndexError, AttributeError): - continue - return False - - -def pattern_to_flags(pattern: str) -> dict[str, bool]: - """Convert entity naming pattern to configuration flags.""" - if pattern == EntityNamingPattern.CIRCUIT_NUMBERS.value: - return {USE_CIRCUIT_NUMBERS: True, USE_DEVICE_PREFIX: True} - elif pattern == EntityNamingPattern.FRIENDLY_NAMES.value: - return {USE_CIRCUIT_NUMBERS: False, USE_DEVICE_PREFIX: True} - else: # LEGACY_NAMES - return {USE_CIRCUIT_NUMBERS: False, USE_DEVICE_PREFIX: False} - - -def get_simulation_start_time_schema() -> vol.Schema: - """Get the simulation start time schema.""" - return vol.Schema( - { - vol.Optional(CONF_SIMULATION_START_TIME): str, - } - ) - - -def get_simulation_start_time_defaults(config_entry: ConfigEntry) -> dict[str, Any]: - """Get the simulation start time defaults.""" - return { - CONF_SIMULATION_START_TIME: config_entry.options.get(CONF_SIMULATION_START_TIME, ""), - } - - -def get_simulation_offline_minutes_schema() -> vol.Schema: - """Get the simulation offline minutes schema.""" - return vol.Schema( - { - vol.Optional(CONF_SIMULATION_OFFLINE_MINUTES): int, - } - ) - - -def get_simulation_offline_minutes_defaults(config_entry: ConfigEntry) -> dict[str, Any]: - """Get the simulation offline minutes defaults.""" - return { - CONF_SIMULATION_OFFLINE_MINUTES: config_entry.options.get( - CONF_SIMULATION_OFFLINE_MINUTES, 0 - ), - } - - -def build_entity_naming_options_schema(config_entry: ConfigEntry) -> vol.Schema: - """Build the schema for entity naming options form. - - Args: - config_entry: The config entry - - Returns: - Voluptuous schema for the form - - """ - # Check if this is a legacy installation (no device prefix) - is_legacy_install = not config_entry.options.get(USE_DEVICE_PREFIX, False) - - # Add mutually exclusive naming pattern options - naming_pattern_options = { - "friendly_names": "Friendly Entity ID Pattern", - "circuit_numbers": "Circuit Tab Naming Pattern", - } - - # Get the current pattern to use as default - current_pattern = get_current_naming_pattern(config_entry) - _LOGGER.debug("Current naming pattern: %s", current_pattern) - - # Always show naming pattern options for live panels - # For legacy installations, this allows them to choose their preferred pattern - # For modern installations, this allows them to switch between patterns - if current_pattern == EntityNamingPattern.FRIENDLY_NAMES.value: - # Currently using friendly names, so show friendly names as default - default_pattern = "friendly_names" - elif current_pattern == EntityNamingPattern.CIRCUIT_NUMBERS.value: - # Currently using circuit numbers, so show circuit numbers as default - default_pattern = "circuit_numbers" - else: - # Legacy installations or fallback - default to friendly names - default_pattern = "friendly_names" - - _LOGGER.debug("Setting default pattern to: %s", default_pattern) - - # Build schema fields conditionally - if is_legacy_install: - schema_fields = { - vol.Optional("entity_naming_pattern", default=default_pattern): vol.In( - naming_pattern_options - ), - vol.Optional("legacy_upgrade_to_friendly", default=False): bool, - } - else: - schema_fields = { - vol.Optional("entity_naming_pattern", default=default_pattern): vol.In( - naming_pattern_options - ) - } - - return vol.Schema(schema_fields) - - -def get_entity_naming_options_defaults(config_entry: ConfigEntry) -> dict[str, Any]: - """Get default values for entity naming options form. - - Args: - config_entry: The config entry - - Returns: - Dictionary of default values - - """ - # Determine current naming pattern - use_circuit_numbers = config_entry.options.get(USE_CIRCUIT_NUMBERS, False) - use_device_prefix = config_entry.options.get(USE_DEVICE_PREFIX, False) - - if use_circuit_numbers and use_device_prefix: - current_pattern = "circuit_numbers" - else: - current_pattern = "friendly_names" - - return { - "entity_naming_pattern": current_pattern, - "legacy_upgrade_to_friendly": False, - } - - -def process_entity_naming_options_input( - config_entry: ConfigEntry, user_input: dict[str, Any] -) -> tuple[dict[str, Any], dict[str, str]]: - """Process entity naming options input and return filtered data. - - Args: - config_entry: The config entry - user_input: Raw user input from the form - - Returns: - Tuple of (filtered_input, errors) - - """ - filtered_input: dict[str, Any] = {} - errors: dict[str, str] = {} - - # Handle legacy upgrade if requested - legacy_upgrade_requested = user_input.get("legacy_upgrade_to_friendly", False) - if legacy_upgrade_requested: - # Mark this config entry for legacy prefix upgrade after reload - filtered_input[USE_DEVICE_PREFIX] = True - filtered_input[USE_CIRCUIT_NUMBERS] = False - filtered_input["pending_legacy_migration"] = True - else: - # Handle naming pattern selection - only if the field is present - selected_pattern = user_input.get("entity_naming_pattern") - if selected_pattern is None: - # No naming pattern field present (legacy installation), preserve existing values - filtered_input[USE_CIRCUIT_NUMBERS] = config_entry.options.get( - USE_CIRCUIT_NUMBERS, False - ) - filtered_input[USE_DEVICE_PREFIX] = config_entry.options.get(USE_DEVICE_PREFIX, False) - return filtered_input, errors - - # Determine new flags based on selected pattern - if selected_pattern == "circuit_numbers": - new_use_circuit_numbers = True - new_use_device_prefix = True - else: # "friendly_names" - new_use_circuit_numbers = False - new_use_device_prefix = True - - # Check if this is actually a change that requires migration - current_use_circuit_numbers = config_entry.options.get(USE_CIRCUIT_NUMBERS, False) - current_use_device_prefix = config_entry.options.get(USE_DEVICE_PREFIX, False) - - # Only trigger migration if there's a real change in circuit numbers - # Device prefix changes are handled by legacy migration, not naming migration - if current_use_circuit_numbers != new_use_circuit_numbers: - # This is a naming pattern change (circuit numbers change) - # Store the old flags for migration - filtered_input["old_use_circuit_numbers"] = current_use_circuit_numbers - filtered_input["old_use_device_prefix"] = current_use_device_prefix - filtered_input[USE_CIRCUIT_NUMBERS] = new_use_circuit_numbers - filtered_input[USE_DEVICE_PREFIX] = new_use_device_prefix - filtered_input["pending_naming_migration"] = True - else: - # No change needed - preserve existing values - filtered_input[USE_CIRCUIT_NUMBERS] = current_use_circuit_numbers - filtered_input[USE_DEVICE_PREFIX] = current_use_device_prefix - - return filtered_input, errors diff --git a/custom_components/span_panel/config_flow_utils/simulation.py b/custom_components/span_panel/config_flow_utils/simulation.py deleted file mode 100644 index 46c4a60e..00000000 --- a/custom_components/span_panel/config_flow_utils/simulation.py +++ /dev/null @@ -1,113 +0,0 @@ -"""Simulation utilities for Span Panel config flow.""" - -from __future__ import annotations - -from pathlib import Path -from typing import Any - -import yaml - - -def get_available_simulation_configs() -> dict[str, str]: - """Get available simulation configuration files. - - Returns: - Dictionary mapping config keys to display names - - """ - configs = {} - - # Get the integration's simulation_configs directory - current_file = Path(__file__) - config_dir = current_file.parent.parent / "simulation_configs" - - if config_dir.exists(): - for yaml_file in config_dir.glob("*.yaml"): - config_key = yaml_file.stem - - # Create user-friendly display names from filename - display_name = config_key.replace("simulation_config_", "").replace("_", " ").title() - - configs[config_key] = display_name - - # If no configs found, provide a default - if not configs: - configs["simulation_config_32_circuit"] = "32-Circuit Residential Panel (Default)" - - return configs - - -def extract_serial_from_config(config_path: Path) -> str: - """Extract serial number from simulation config file. - - Args: - config_path: Path to the simulation config YAML file - - Returns: - Serial number from the config, or default if not found - - """ - try: - if config_path.exists(): - with config_path.open("r", encoding="utf-8") as f: - config_data = yaml.safe_load(f) - if isinstance(config_data, dict): - # Try to extract serial from various possible locations - if "serial_number" in config_data: - return str(config_data["serial_number"]) - if "panel" in config_data and isinstance(config_data["panel"], dict): - if "serial_number" in config_data["panel"]: - return str(config_data["panel"]["serial_number"]) - if "status" in config_data and isinstance(config_data["status"], dict): - if "serial_number" in config_data["status"]: - return str(config_data["status"]["serial_number"]) - except (FileNotFoundError, yaml.YAMLError, KeyError, ValueError): - pass - - # Fallback to a default - return "span-sim-001" - - -def get_simulation_config_path(config_key: str) -> Path: - """Get the path to a simulation config file. - - Args: - config_key: The config key (filename without extension) - - Returns: - Path to the simulation config file - - """ - current_file = Path(__file__) - config_dir = current_file.parent.parent / "simulation_configs" - return config_dir / f"{config_key}.yaml" - - -def validate_yaml_config(yaml_path: Path) -> dict[str, Any]: - """Validate and load a YAML configuration file. - - Args: - yaml_path: Path to the YAML file - - Returns: - Loaded YAML data as dictionary - - Raises: - FileNotFoundError: If file doesn't exist - yaml.YAMLError: If YAML is invalid - ValueError: If YAML doesn't contain expected structure - - """ - if not yaml_path.exists(): - raise FileNotFoundError(f"Configuration file not found: {yaml_path}") - - with yaml_path.open("r", encoding="utf-8") as f: - data = yaml.safe_load(f) - - if data is None: - return {} - - if not isinstance(data, dict): - raise TypeError("Configuration file must contain a YAML dictionary") - - return data diff --git a/custom_components/span_panel/config_flow_utils/validation.py b/custom_components/span_panel/config_flow_utils/validation.py deleted file mode 100644 index e2e2d593..00000000 --- a/custom_components/span_panel/config_flow_utils/validation.py +++ /dev/null @@ -1,311 +0,0 @@ -"""Validation utilities for Span Panel config flow.""" - -from __future__ import annotations - -from datetime import datetime -import logging - -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant -from homeassistant.util.network import is_ipv4_address -from span_panel_api import SpanPanelClient -from span_panel_api.exceptions import SpanPanelAuthError, SpanPanelConnectionError -from span_panel_api.phase_validation import ( - are_tabs_opposite_phase, - get_tab_phase, - validate_solar_tabs, -) - -from custom_components.span_panel.const import ( - CONFIG_API_RETRIES, - CONFIG_API_RETRY_BACKOFF_MULTIPLIER, - CONFIG_API_RETRY_TIMEOUT, - CONFIG_TIMEOUT, - COORDINATOR, - DOMAIN, - ISO_DATETIME_FORMAT, - TIME_ONLY_FORMATS, -) - -_LOGGER = logging.getLogger(__name__) - - -async def get_available_unmapped_tabs(hass: HomeAssistant, config_entry: ConfigEntry) -> list[int]: - """Get list of available unmapped tab numbers from panel data. - - Args: - hass: Home Assistant instance - config_entry: Configuration entry for this integration - - Returns: - List of unmapped tab numbers available for solar configuration - - """ - try: - # Get the coordinator from the integration's data - coordinator = hass.data[DOMAIN][config_entry.entry_id][COORDINATOR] - panel_data = coordinator.data - - if not panel_data or not hasattr(panel_data, "circuits"): - return [] - - # Get all tab numbers from circuits that start with "unmapped_tab_" - unmapped_tabs = [] - for circuit_id in panel_data.circuits: - if circuit_id.startswith("unmapped_tab_"): - try: - tab_number = int(circuit_id.replace("unmapped_tab_", "")) - unmapped_tabs.append(tab_number) - except ValueError: - continue - - return sorted(unmapped_tabs) - - except (KeyError, AttributeError) as e: - _LOGGER.warning("Could not get unmapped tabs from panel data: %s", e) - return [] - - -def validate_solar_tab_selection( - tab1: int, tab2: int, available_tabs: list[int] -) -> tuple[bool, str]: - """Validate solar tab selection for proper 240V configuration. - - Args: - tab1: First selected tab number - tab2: Second selected tab number - available_tabs: List of available unmapped tab numbers - - Returns: - tuple of (is_valid, error_message) where: - - is_valid: True if selection is valid for 240V solar - - error_message: Description of validation result or error - - """ - # Check if both tabs are provided - if tab1 == 0 or tab2 == 0: - return ( - False, - "Both solar legs must be selected. Single leg configuration is not supported for proper 240V measurement.", - ) - - # Check if tabs are the same - if tab1 == tab2: - return ( - False, - f"Solar legs cannot use the same tab ({tab1}). Two different tabs are required for 240V measurement.", - ) - - # Check if both tabs are available (unmapped) - if tab1 not in available_tabs: - return False, f"Tab {tab1} is not available or is already mapped to a circuit." - - if tab2 not in available_tabs: - return False, f"Tab {tab2} is not available or is already mapped to a circuit." - - # Use phase validation from the API package - is_valid, message = validate_solar_tabs(tab1, tab2, available_tabs) - - # If validation failed due to same phase, provide more detailed error - if not is_valid and "both on" in message: - try: - phase1 = get_tab_phase(tab1) - phase2 = get_tab_phase(tab2) - return False, ( - f"Invalid selection: Tab {tab1} ({phase1}) and Tab {tab2} ({phase2}) are both on the same phase. " - f"For proper 240V measurement, tabs must be on opposite phases (L1 + L2)." - ) - except ValueError: - pass - - return is_valid, message - - -def get_filtered_tab_options( - selected_tab: int, available_tabs: list[int], include_none: bool = True -) -> dict[int, str]: - """Get filtered tab options based on opposite phase requirement. - - Args: - selected_tab: Currently selected tab (0 for none) - available_tabs: List of all available unmapped tabs - include_none: Whether to include "None (Disabled)" option - - Returns: - Dictionary mapping tab numbers to display names, filtered to show only - tabs on the opposite phase of the selected tab (or all if no tab selected) - - """ - tab_options = {} - - # Always include "None (Disabled)" option if requested - if include_none: - tab_options[0] = "None (Disabled)" - - # If no tab is selected (0), show all available tabs with phase info - if selected_tab == 0: - for tab in available_tabs: - try: - phase = get_tab_phase(tab) - tab_options[tab] = f"Tab {tab} ({phase})" - except ValueError: - tab_options[tab] = f"Tab {tab}" - return tab_options - - # Filter to show only tabs on the opposite phase using the API function - for tab in available_tabs: - if are_tabs_opposite_phase(selected_tab, tab, available_tabs): - try: - phase = get_tab_phase(tab) - tab_options[tab] = f"Tab {tab} ({phase})" - except ValueError: - tab_options[tab] = f"Tab {tab}" - - return tab_options - - -async def validate_host( - hass: HomeAssistant, - host: str, - access_token: str | None = None, # nosec - use_ssl: bool = False, -) -> bool: - """Validate the host connection.""" - - # Use context manager for short-lived validation (recommended pattern) - # Use config settings for quick feedback - no retries and shorter timeout - async with SpanPanelClient( - host=host, - timeout=CONFIG_TIMEOUT, - use_ssl=use_ssl, - retries=CONFIG_API_RETRIES, - retry_timeout=CONFIG_API_RETRY_TIMEOUT, - retry_backoff_multiplier=CONFIG_API_RETRY_BACKOFF_MULTIPLIER, - ) as client: - if access_token: - client.set_access_token(access_token) - try: - # Test authenticated endpoint - await client.get_panel_state() - return True - except Exception: - return False - else: - try: - # Test unauthenticated endpoint - await client.get_status() - return True - except Exception: - return False - - -async def validate_auth_token( - hass: HomeAssistant, host: str, access_token: str, use_ssl: bool = False -) -> bool: - """Perform an authenticated call to confirm validity of provided token.""" - - # Use context manager for short-lived validation (recommended pattern) - # Use config settings for quick feedback - no retries and shorter timeout - async with SpanPanelClient( - host=host, - timeout=CONFIG_TIMEOUT, - use_ssl=use_ssl, - retries=CONFIG_API_RETRIES, - retry_timeout=CONFIG_API_RETRY_TIMEOUT, - retry_backoff_multiplier=CONFIG_API_RETRY_BACKOFF_MULTIPLIER, - ) as client: - client.set_access_token(access_token) - try: - # Test authenticated endpoint - await client.get_panel_state() - return True - except SpanPanelAuthError as e: - _LOGGER.warning("Auth token validation failed - invalid token: %s", e) - return False - except SpanPanelConnectionError as e: - _LOGGER.warning("Auth token validation failed - connection error: %s", e) - return False - except Exception as e: - _LOGGER.warning("Auth token validation failed - unexpected error: %s", e) - return False - - -def validate_ipv4_address(host: str) -> bool: - """Validate that the host is an IPv4 address.""" - return is_ipv4_address(host) - - -def validate_simulation_time(time_input: str) -> str: - """Validate and convert simulation time input. - - Supports: - - Time-only formats: "17:30", "5:30" (24-hour and 12-hour) - - Full ISO datetime: "2024-06-15T17:30:00" - - Returns: - ISO datetime string with current date if time-only, or original if full datetime - - Raises: - ValueError: If the time format is invalid - - """ - if not time_input.strip(): - return "" - - time_input = time_input.strip() - - # Check if it's a full ISO datetime first - try: - datetime.fromisoformat(time_input) - return time_input # Valid ISO datetime, return as-is - except ValueError: - pass # Not a full datetime, try time-only formats - - # Try time-only formats (HH:MM or H:MM) - try: - # Parse the time - if ":" in time_input: - parts = time_input.split(":") - if len(parts) == 2: - hour = int(parts[0]) - minute = int(parts[1]) - - # Validate hour and minute ranges - if 0 <= hour <= 23 and 0 <= minute <= 59: - # Convert to current date with the specified time - now = datetime.now() - time_only = now.replace(hour=hour, minute=minute, second=0, microsecond=0) - return time_only.isoformat() - - raise ValueError( - f"Invalid time format. Use {', '.join(TIME_ONLY_FORMATS)} or {ISO_DATETIME_FORMAT}" - ) - except (ValueError, IndexError) as e: - raise ValueError( - f"Invalid time format. Use {', '.join(TIME_ONLY_FORMATS)} or {ISO_DATETIME_FORMAT}" - ) from e - - -def validate_solar_configuration( - solar_enabled: bool, leg1: int, leg2: int, available_tabs: list[int] -) -> tuple[bool, str]: - """Validate complete solar configuration. - - Args: - solar_enabled: Whether solar is enabled - leg1: First leg tab number - leg2: Second leg tab number - available_tabs: List of available unmapped tabs - - Returns: - Tuple of (is_valid, error_message) - - """ - if not solar_enabled: - return True, "" - - # Only validate when we actually have available tabs information - if available_tabs: - return validate_solar_tab_selection(leg1, leg2, available_tabs) - - return True, "" diff --git a/custom_components/span_panel/config_flow_validation.py b/custom_components/span_panel/config_flow_validation.py new file mode 100644 index 00000000..c5f79775 --- /dev/null +++ b/custom_components/span_panel/config_flow_validation.py @@ -0,0 +1,147 @@ +"""Validation helpers for Span Panel config flow.""" + +from __future__ import annotations + +import asyncio +import ipaddress +import logging +from pathlib import Path +import socket +import ssl +import tempfile + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.httpx_client import get_async_client +from homeassistant.util.network import is_ipv4_address +from span_panel_api import ( + V2AuthResponse, + detect_api_version, + download_ca_cert, + register_v2, +) +from span_panel_api.exceptions import ( + SpanPanelAPIError, + SpanPanelConnectionError, + SpanPanelTimeoutError, +) + +_LOGGER = logging.getLogger(__name__) + + +async def validate_host( + hass: HomeAssistant, + host: str, + port: int = 80, +) -> bool: + """Validate the host connection by probing the panel's status endpoint.""" + client = get_async_client(hass, verify_ssl=False) + try: + result = await detect_api_version(host, port=port, httpx_client=client) + except ( + ValueError, + OSError, + SpanPanelAPIError, + SpanPanelConnectionError, + SpanPanelTimeoutError, + ): + return False + if result.probe_failed: + return False + return result.api_version == "v2" + + +async def validate_v2_passphrase( + hass: HomeAssistant, host: str, passphrase: str, port: int = 80 +) -> V2AuthResponse: + """Validate a v2 panel passphrase and return MQTT credentials. + + Raises: + SpanPanelAuthError: on invalid passphrase (401/403). + SpanPanelConnectionError: on network/timeout failures. + SpanPanelTimeoutError: on request timeout. + + """ + client = get_async_client(hass, verify_ssl=False) + return await register_v2(host, "Home Assistant", passphrase, port=port, httpx_client=client) + + +def is_fqdn(host: str) -> bool: + """Determine if host is a Fully Qualified Domain Name (not IP, not mDNS). + + Returns True for domain names like 'span.home.lan' or 'panel.example.com'. + Returns False for IP addresses, mDNS (.local) names, and single-label hostnames. + """ + if is_ipv4_address(host): + return False + try: + ipaddress.ip_address(host) + except ValueError: + pass + else: + return False + if host.endswith((".local", ".local.")): + return False + return "." in host + + +async def check_fqdn_tls_ready( + hass: HomeAssistant, fqdn: str, mqtts_port: int, http_port: int = 80 +) -> bool: + """Check if the MQTTS server certificate includes the FQDN in its SAN. + + Downloads the CA certificate from the panel via HTTP, then attempts + a TLS connection to the MQTTS port using the FQDN as server_hostname. + If the TLS handshake succeeds with hostname verification, the panel + has regenerated its certificate to include the FQDN. + """ + client = get_async_client(hass, verify_ssl=False) + try: + ca_pem = await download_ca_cert(fqdn, port=http_port, httpx_client=client) + except ( + OSError, + SpanPanelAPIError, + SpanPanelConnectionError, + SpanPanelTimeoutError, + ): + return False + + loop = asyncio.get_running_loop() + + def _check() -> bool: + ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + ctx.check_hostname = True + ctx.verify_mode = ssl.CERT_REQUIRED + + with tempfile.NamedTemporaryFile(mode="w", suffix=".pem", delete=False) as tmp: + tmp.write(ca_pem) + ca_path = Path(tmp.name) + try: + ctx.load_verify_locations(str(ca_path)) + with ( + socket.create_connection((fqdn, mqtts_port), timeout=5) as sock, + ctx.wrap_socket(sock, server_hostname=fqdn), + ): + return True + except (ssl.SSLCertVerificationError, ssl.SSLError, OSError, TimeoutError): + return False + finally: + ca_path.unlink(missing_ok=True) + + return await loop.run_in_executor(None, _check) + + +async def validate_v2_proximity(hass: HomeAssistant, host: str, port: int = 80) -> V2AuthResponse: + """Validate v2 panel proximity (door bypass) and return MQTT credentials. + + Calls register_v2 without a passphrase, which triggers door-bypass + registration. The panel accepts this when the user opens/closes the + door 3 times within the proximity window. + + Raises: + SpanPanelAuthError: if proximity was not proven (door not opened). + SpanPanelConnectionError: on network/timeout failures. + SpanPanelTimeoutError: on request timeout. + + """ + client = get_async_client(hass, verify_ssl=False) + return await register_v2(host, "Home Assistant", port=port, httpx_client=client) diff --git a/custom_components/span_panel/const.py b/custom_components/span_panel/const.py index 57b5d14b..f18e4e09 100644 --- a/custom_components/span_panel/const.py +++ b/custom_components/span_panel/const.py @@ -1,49 +1,26 @@ """Constants for the Span Panel integration.""" -from datetime import timedelta import enum from typing import Final DOMAIN: Final = "span_panel" -COORDINATOR = "coordinator" -NAME = "name" CONF_SERIAL_NUMBER = "serial_number" CONF_USE_SSL = "use_ssl" CONF_DEVICE_NAME = "device_name" -# Simulation configuration -CONF_SIMULATION_CONFIG = "simulation_config" -CONF_SIMULATION_START_TIME = "simulation_start_time" -CONF_SIMULATION_OFFLINE_MINUTES = "simulation_offline_minutes" - -# Time format constants for simulation -TIME_ONLY_FORMATS = ["HH:MM", "H:MM"] # 24-hour and 12-hour formats -ISO_DATETIME_FORMAT = "YYYY-MM-DDTHH:MM:SS" # Full ISO datetime format - -URL_STATUS = "http://{}/api/v1/status" -URL_SPACES = "http://{}/api/v1/spaces" -URL_CIRCUITS = "http://{}/api/v1/circuits" -URL_PANEL = "http://{}/api/v1/panel" -URL_REGISTER = "http://{}/api/v1/auth/register" -URL_STORAGE_BATTERY = "http://{}/api/v1/storage/soe" - -STORAGE_BATTERY_PERCENTAGE = "batteryPercentage" -CIRCUITS_NAME = "name" -CIRCUITS_RELAY = "relayState" -CIRCUITS_POWER = "instantPowerW" -CIRCUITS_ENERGY_PRODUCED = "producedEnergyWh" -CIRCUITS_ENERGY_CONSUMED = "consumedEnergyWh" -CIRCUITS_BREAKER_POSITIONS = "tabs" -CIRCUITS_PRIORITY = "priority" -CIRCUITS_IS_USER_CONTROLLABLE = "is_user_controllable" -CIRCUITS_IS_SHEDDABLE = "is_sheddable" -CIRCUITS_IS_NEVER_BACKUP = "is_never_backup" - -SPAN_CIRCUITS = "circuits" -SPAN_SOE = "soe" -SPAN_SYSTEM = "system" -PANEL_POWER = "instantGridPowerW" +# v2 API / MQTT configuration (stored in config entry data) +CONF_API_VERSION = "api_version" +CONF_EBUS_BROKER_HOST = "ebus_broker_host" +CONF_EBUS_BROKER_USERNAME = "ebus_broker_username" +CONF_EBUS_BROKER_PASSWORD = "ebus_broker_password" # nosec B105 +CONF_EBUS_BROKER_PORT = "ebus_broker_mqtts_port" +CONF_HOP_PASSPHRASE = "hop_passphrase" # nosec B105 +CONF_HTTP_PORT = "http_port" +CONF_PANEL_SERIAL = "panel_serial" +CONF_REGISTERED_FQDN = "registered_fqdn" + +# Binary sensor / status field keys (used in entity definitions) SYSTEM_DOOR_STATE = "doorState" SYSTEM_DOOR_STATE_CLOSED = "CLOSED" SYSTEM_DOOR_STATE_UNKNOWN = "UNKNOWN" @@ -53,13 +30,6 @@ SYSTEM_WIFI_LINK = "wlanLink" PANEL_STATUS = "panel_status" -STATUS_SOFTWARE_VER = "softwareVer" -DSM_GRID_STATE = "dsmGridState" -DSM_STATE = "dsmState" -CURRENT_RUN_CONFIG = "currentRunConfig" -MAIN_RELAY_STATE = "mainRelayState" - -PANEL_MAIN_RELAY_STATE_UNKNOWN_VALUE = "UNKNOWN" USE_DEVICE_PREFIX = "use_device_prefix" USE_CIRCUIT_NUMBERS = "use_circuit_numbers" @@ -70,8 +40,6 @@ # SPAN Panel State Constants # DSM (Demand Side Management) States -DSM_GRID_UP = "DSM_GRID_UP" -DSM_GRID_DOWN = "DSM_GRID_DOWN" DSM_ON_GRID = "DSM_ON_GRID" DSM_OFF_GRID = "DSM_OFF_GRID" @@ -87,28 +55,48 @@ # Net energy sensor configuration ENABLE_PANEL_NET_ENERGY_SENSORS = "enable_panel_net_energy_sensors" ENABLE_CIRCUIT_NET_ENERGY_SENSORS = "enable_circuit_net_energy_sensors" -ENABLE_SOLAR_NET_ENERGY_SENSORS = "enable_solar_net_energy_sensors" - -DEFAULT_SCAN_INTERVAL = timedelta(seconds=15) -# API timeout and retry configuration -API_TIMEOUT = 30 # Default timeout for normal operations -CONFIG_TIMEOUT = 15 # Shorter timeout for config operations to give quick feedback - -# Retry configuration constants -CONF_API_RETRIES = "api_retries" -CONF_API_RETRY_TIMEOUT = "api_retry_timeout" -CONF_API_RETRY_BACKOFF_MULTIPLIER = "api_retry_backoff_multiplier" - -# Default retry settings for normal operations -DEFAULT_API_RETRIES = 3 -DEFAULT_API_RETRY_TIMEOUT = 0.5 -DEFAULT_API_RETRY_BACKOFF_MULTIPLIER = 2.0 - - -# Config operation settings (no retries for quick feedback) -CONFIG_API_RETRIES = 0 -CONFIG_API_RETRY_TIMEOUT = 0.5 -CONFIG_API_RETRY_BACKOFF_MULTIPLIER = 2.0 +ENABLE_ENERGY_DIP_COMPENSATION = "enable_energy_dip_compensation" + +# Unmapped circuit sensor configuration +ENABLE_UNMAPPED_CIRCUIT_SENSORS = "enable_unmapped_circuit_sensors" + +# Current monitoring configuration +ENABLE_CURRENT_MONITORING = "enable_current_monitoring" +DEFAULT_CONTINUOUS_THRESHOLD_PCT = 80 +DEFAULT_SPIKE_THRESHOLD_PCT = 100 +DEFAULT_WINDOW_DURATION_M = 15 +DEFAULT_COOLDOWN_DURATION_M = 15 +DEFAULT_NOTIFICATION_TITLE_TEMPLATE = "SPAN: {name} {alert_type}" +DEFAULT_NOTIFICATION_MESSAGE_TEMPLATE = ( + "{name} at {current_a}A ({utilization_pct}% of {breaker_rating_a}A rating) at {local_time}" +) +DEFAULT_NOTIFICATION_PRIORITY = "default" +NOTIFICATION_PRIORITIES: Final[tuple[str, ...]] = ( + "default", + "passive", + "active", + "time-sensitive", + "critical", +) +EVENT_CURRENT_ALERT = "span_panel_current_alert" + +# Graph time horizon configuration +VALID_GRAPH_HORIZONS: Final[tuple[str, ...]] = ("5m", "1h", "1d", "1w", "1M") +DEFAULT_GRAPH_HORIZON = "5m" + +# Mains leg identifiers +MAINS_LEGS: Final[tuple[str, ...]] = ( + "upstream_l1", + "upstream_l2", + "downstream_l1", + "downstream_l2", +) + +# Panel sidebar settings (domain-level, shared across config entries) +PANEL_SHOW_SIDEBAR = "show_panel" +PANEL_ADMIN_ONLY = "panel_admin_only" + +DEFAULT_SNAPSHOT_INTERVAL: Final[float] = 5.0 class CircuitRelayState(enum.Enum): @@ -122,10 +110,10 @@ class CircuitRelayState(enum.Enum): class CircuitPriority(enum.Enum): """Enumeration representing the possible circuit priority levels.""" - MUST_HAVE = "Must Have" - NICE_TO_HAVE = "Nice To Have" - NON_ESSENTIAL = "Non-Essential" - UNKNOWN = "Unknown" + NEVER = "never" + SOC_THRESHOLD = "soc_threshold" + OFF_GRID = "off_grid" + UNKNOWN = "unknown" class EntityNamingPattern(enum.Enum): diff --git a/custom_components/span_panel/coordinator.py b/custom_components/span_panel/coordinator.py index bbcbaa44..0c08410d 100644 --- a/custom_components/span_panel/coordinator.py +++ b/custom_components/span_panel/coordinator.py @@ -1,96 +1,113 @@ -"""Span Panel Coordinator for managing data updates and entity migrations.""" +"""Span Panel Coordinator for managing data updates.""" from __future__ import annotations +from collections.abc import Callable from datetime import timedelta import logging from time import time as _epoch_time +from typing import TYPE_CHECKING, Protocol -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_SCAN_INTERVAL +if TYPE_CHECKING: + from . import SpanPanelConfigEntry + from .current_monitor import CurrentMonitor + from .graph_horizon import GraphHorizonManager + +from homeassistant.components.persistent_notification import async_create from homeassistant.core import HomeAssistant from homeassistant.exceptions import ( ConfigEntryAuthFailed, ConfigEntryNotReady, HomeAssistantError, ) +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from span_panel_api.exceptions import ( - SpanPanelAPIError, - SpanPanelConnectionError, - SpanPanelRetriableError, - SpanPanelServerError, - SpanPanelTimeoutError, -) +from span_panel_api import SpanMqttClient, SpanPanelSnapshot +from span_panel_api.exceptions import SpanPanelAuthError, SpanPanelStaleDataError + +from .const import DOMAIN +from .id_builder import build_circuit_unique_id +from .schema_validation import collect_sensor_definitions, validate_field_metadata + + +class SpanCircuitEnergySensorProtocol(Protocol): + """Protocol for circuit energy sensors that expose their dip offset.""" + + @property + def energy_offset(self) -> float: + """Cumulative dip compensation offset.""" + ... -from .const import ( - DEFAULT_SCAN_INTERVAL, - DOMAIN, - USE_CIRCUIT_NUMBERS, - USE_DEVICE_PREFIX, -) -from .entity_id_naming_patterns import EntityIdMigrationManager -from .exceptions import SpanPanelSimulationOfflineError -from .options import ENERGY_REPORTING_GRACE_PERIOD -from .span_panel import SpanPanel _LOGGER = logging.getLogger(__name__) +# Suppress the noisy "Manually updated span_panel data" DEBUG message that +# HA's DataUpdateCoordinator emits on every async_set_updated_data() call. +# In push/streaming mode this fires every ~1s and drowns out useful debug logs. + + +class _SuppressManualUpdateFilter(logging.Filter): + """Filter out the HA DataUpdateCoordinator 'Manually updated' noise.""" + + def filter(self, record: logging.LogRecord) -> bool: + return "Manually updated" not in record.getMessage() + + +_LOGGER.addFilter(_SuppressManualUpdateFilter()) + +# Fallback poll interval for MQTT streaming mode (push is the primary update path) +_STREAMING_FALLBACK_INTERVAL = timedelta(seconds=60) + -class SpanPanelCoordinator(DataUpdateCoordinator[SpanPanel]): - """Coordinator for managing Span Panel data updates and entity migrations.""" +class SpanPanelCoordinator(DataUpdateCoordinator[SpanPanelSnapshot]): + """Coordinator for managing Span Panel data updates.""" def __init__( self, hass: HomeAssistant, - span_panel: SpanPanel, - config_entry: ConfigEntry, + client: SpanMqttClient, + config_entry: SpanPanelConfigEntry, ) -> None: """Initialize the coordinator.""" - self.span_panel = span_panel - self.config_entry = config_entry - self._migration_manager = EntityIdMigrationManager(hass, config_entry.entry_id) - # Deterministic synthetic sensor set identifier, filled by synthetic setup - self.synthetic_sensor_set_id: str | None = None + self._client = client + self.config_entry: SpanPanelConfigEntry = config_entry # Track last tick for visibility into cadence self._last_tick_epoch: float | None = None # Flag to track if a reload was requested self._reload_requested = False # Flag to track if panel is offline/unreachable self._panel_offline = False - # Track last grace period value for comparison - self._last_grace_period = config_entry.options.get(ENERGY_REPORTING_GRACE_PERIOD, 15) - # Get scan interval from options, with fallback to default - raw_scan_interval = config_entry.options.get( - CONF_SCAN_INTERVAL, int(DEFAULT_SCAN_INTERVAL.total_seconds()) - ) + # Streaming state + self._unregister_streaming: Callable[[], None] | None = None + self._unregister_connection: Callable[[], None] | None = None - # Coerce scan interval to integer seconds, clamp to minimum of 5 - try: - # Accept strings, floats, and ints; e.g., "15", 15.0, 15 - scan_interval_seconds = int(float(raw_scan_interval)) - except (TypeError, ValueError): - scan_interval_seconds = int(DEFAULT_SCAN_INTERVAL.total_seconds()) + # Hardware capability tracking — detect when BESS/PV are commissioned + # and trigger a reload so the factory creates the appropriate sensors. + self._known_capabilities: frozenset[str] | None = None - if scan_interval_seconds < 5: - _LOGGER.debug( - "Configured scan interval %s is below minimum; clamping to 5 seconds", - scan_interval_seconds, - ) - scan_interval_seconds = 5 + # Schema validation — run once after first successful refresh + self._schema_validated = False - if str(raw_scan_interval) != str(scan_interval_seconds): - _LOGGER.debug( - "Coerced scan interval option from raw=%s to %s seconds", - raw_scan_interval, - scan_interval_seconds, - ) + # Energy dip compensation — sensors append events here during updates; + # drained and surfaced as a persistent notification after each cycle. + self._pending_dip_events: list[tuple[str, float, float]] = [] - # Log at INFO so it is visible without debug logging - _LOGGER.info( - "Span Panel coordinator: update interval set to %s seconds", - scan_interval_seconds, + # Circuit energy sensor registry — consumed/produced sensors register + # here so net energy sensors can read their dip offsets directly. + self._circuit_energy_sensors: dict[tuple[str, str], SpanCircuitEnergySensorProtocol] = {} + + # Current monitor — set by async_setup_entry when monitoring is enabled + self.current_monitor: CurrentMonitor | None = None + + # Graph horizon manager — set by async_setup_entry + self.graph_horizon_manager: GraphHorizonManager | None = None + + update_interval = _STREAMING_FALLBACK_INTERVAL + + _LOGGER.debug( + "Span Panel coordinator: poll interval %s seconds", + update_interval.total_seconds(), ) super().__init__( @@ -98,260 +115,420 @@ def __init__( _LOGGER, config_entry=config_entry, name=DOMAIN, - update_interval=timedelta(seconds=scan_interval_seconds), + update_interval=update_interval, ) # Ensure config_entry is properly set after super().__init__ self.config_entry = config_entry + @property + def client(self) -> SpanMqttClient: + """Return the underlying panel client for entity control.""" + return self._client + @property def panel_offline(self) -> bool: """Return True if the panel is currently offline/unreachable.""" return self._panel_offline - def get_synthetic_sensor_set_id(self) -> str | None: - """Return the synthetic sensor set id if set during synthetic setup.""" - return self.synthetic_sensor_set_id - def request_reload(self) -> None: """Request a reload of the integration.""" self._reload_requested = True - async def _async_update_data(self) -> SpanPanel: - """Fetch data from API endpoint.""" - try: - # Reset offline flag on successful update - self._panel_offline = False + def _mark_panel_online(self) -> None: + """Mark the panel online and log a recovery transition once.""" + if self._panel_offline: + _LOGGER.info("%s is back online", self.config_entry.title or "SPAN Panel") + self._panel_offline = False - # Performance timing - start of update cycle - cycle_start = _epoch_time() + def _mark_panel_offline(self, reason: Exception | str) -> None: + """Mark the panel offline and log the transition once. - # Track cadence locally for debugging purposes - self._last_tick_epoch = cycle_start + `reason` is rendered with %s — both Exception and str format + correctly. The broker-disconnect path (from the MQTT client + connection callback) passes a short string; the snapshot-poll + path passes a SpanPanelStaleDataError or unexpected Exception. + """ + if not self._panel_offline: + _LOGGER.info( + "%s is unavailable: %s", + self.config_entry.title or "SPAN Panel", + reason, + ) + self._panel_offline = True - # Time the panel data fetch - panel_fetch_start = _epoch_time() - await self.span_panel.update() - panel_fetch_duration = _epoch_time() - panel_fetch_start + # --- Energy dip compensation --- - cycle_total_duration = _epoch_time() - cycle_start + def report_energy_dip(self, entity_id: str, delta: float, cumulative_offset: float) -> None: + """Record an energy dip detected by a sensor during this update cycle. - # INFO level performance logging - _LOGGER.info( - "SPAN Panel update cycle completed - Total: %.3fs | Panel fetch: %.3fs", - cycle_total_duration, - panel_fetch_duration, + Called synchronously by sensors from _process_raw_value. No I/O — + just a list append. Events are drained in _run_post_update_tasks. + """ + self._pending_dip_events.append((entity_id, delta, cumulative_offset)) + + def register_circuit_energy_sensor( + self, circuit_id: str, energy_type: str, sensor: SpanCircuitEnergySensorProtocol + ) -> None: + """Register a consumed/produced energy sensor so net energy can read its dip offset.""" + self._circuit_energy_sensors[(circuit_id, energy_type)] = sensor + + def get_circuit_dip_offset(self, circuit_id: str, energy_type: str) -> float: + """Return the cumulative dip offset from the registered sensor, or 0.""" + sensor = self._circuit_energy_sensors.get((circuit_id, energy_type)) + if sensor is None: + return 0.0 + return sensor.energy_offset + + async def _fire_dip_notification(self) -> None: + """Create a persistent notification summarising energy dips this cycle.""" + if not self._pending_dip_events: + return + + events = self._pending_dip_events + self._pending_dip_events = [] + + title = "SPAN Panel: Energy Dip Detected" + preamble = ( + "The following energy sensors reported a decrease in their " + "counter value. Dip compensation has automatically applied " + "offsets — no action is required for new data." + ) + + lines: list[str] = [] + for entity_id, delta, offset in events: + lines.append( + f"- **{entity_id}**: dip {delta:.1f} Wh (cumulative offset {offset:.1f} Wh)" ) - # Handle reload request if one was made - if self._reload_requested: - self._reload_requested = False - self.hass.async_create_task(self._async_reload_task()) + body = preamble + "\n\n" + "\n".join(lines) - # Check for pending legacy migration - # Only attempt migration if integration data is properly set up in hass.data - if self.config_entry.entry_id in self.hass.data.get( - DOMAIN, {} - ) and self.config_entry.options.get("pending_legacy_migration", False): - _LOGGER.info( - "Found pending legacy migration flag in coordinator update, performing migration" - ) - await self._handle_pending_legacy_migration() - - # Check for pending naming pattern migration - # Only attempt migration if integration data is properly set up in hass.data - if self.config_entry.entry_id in self.hass.data.get(DOMAIN, {}): - _LOGGER.debug( - "Checking for pending_naming_migration flag: %s", - self.config_entry.options.get("pending_naming_migration", False), - ) - if self.config_entry.options.get("pending_naming_migration", False): - _LOGGER.info( - "Found pending naming migration flag in coordinator update, performing migration" - ) - _LOGGER.debug("Config entry options: %s", self.config_entry.options) - # Only proceed if we have the old flags stored (indicating a real user change) - if ( - "old_use_circuit_numbers" in self.config_entry.options - and "old_use_device_prefix" in self.config_entry.options - ): - await self._handle_pending_naming_migration() - else: - _LOGGER.warning( - "Found pending_naming_migration flag but no old flags stored - skipping migration" - ) - # Clean up the invalid flag - current_options = dict(self.config_entry.options) - current_options.pop("pending_naming_migration", None) - self.hass.config_entries.async_update_entry( - self.config_entry, options=current_options - ) - else: - _LOGGER.debug( - "Integration data not yet set up in hass.data - skipping migration checks" - ) + entry_id = self.config_entry.entry_id + async_create( + self.hass, + body, + title=title, + notification_id=f"span_energy_dip_{entry_id}", + ) - return self.span_panel + # --- Streaming --- - except ConfigEntryAuthFailed: - # Re-raise auth errors - these should trigger reauth - raise + async def async_setup_streaming(self) -> None: + """Set up push streaming and broker-connection state listening.""" + self._unregister_connection = self._client.register_connection_callback( + self._on_connection_change + ) + self._unregister_streaming = self._client.register_snapshot_callback(self._on_snapshot_push) + await self._client.start_streaming() + _LOGGER.info("MQTT push streaming started") - except Exception as err: - # Set offline flag for any error - self._panel_offline = True - - # Performance timing for error path - error_cycle_start = _epoch_time() - - # Log specific error types for debugging - if isinstance(err, SpanPanelSimulationOfflineError): - _LOGGER.debug("Span Panel simulation offline mode: %s", err) - elif isinstance(err, SpanPanelConnectionError): - _LOGGER.warning("Span Panel connection error: %s", err) - elif isinstance(err, SpanPanelTimeoutError): - _LOGGER.warning("Span Panel timeout: %s", err) - elif isinstance(err, SpanPanelServerError): - _LOGGER.warning("Span Panel server error: %s", err) - elif isinstance(err, SpanPanelRetriableError): - _LOGGER.warning("Span Panel retriable error: %s", err) - elif isinstance(err, SpanPanelAPIError): - _LOGGER.warning("Span Panel API error: %s", err) - else: - _LOGGER.warning("Unexpected Span Panel error: %s", err) - - error_cycle_duration = _epoch_time() - error_cycle_start - - # INFO level performance logging for error path + def _on_connection_change(self, connected: bool) -> None: + """Handle a broker connection state edge from the MQTT client. + + Called on the event loop when the bridge transitions between + connected and disconnected. Flips the panel-offline flag and + pushes an immediate listener update so sensors enter or exit + grace-period logic without waiting for the 60 s fallback poll. + + Listener fan-out is guarded by a real state change so a misbehaving + or future-version library that re-emits the same edge does not + trigger spurious entity re-renders. + """ + was_offline = self._panel_offline + if connected: + self._mark_panel_online() + else: + self._mark_panel_offline("MQTT broker disconnected") + if self._panel_offline != was_offline: + self.async_update_listeners() + + async def _on_snapshot_push(self, snapshot: SpanPanelSnapshot) -> None: + """Handle a pushed snapshot from MQTT streaming.""" + self._mark_panel_online() + self._check_capability_change(snapshot) + self.async_set_updated_data(snapshot) + await self._run_post_update_tasks(snapshot) + + async def async_shutdown(self) -> None: + """Shut down the coordinator and release resources.""" + if self._unregister_connection is not None: + self._unregister_connection() + self._unregister_connection = None + + if self._unregister_streaming is not None: + self._unregister_streaming() + self._unregister_streaming = None + + await self._client.stop_streaming() + await self._client.close() + + _LOGGER.info("Coordinator shutdown complete") + + # --- Schema validation --- + + def _run_schema_validation(self) -> None: + """Run schema field metadata validation once at startup. + + Compares the library's schema-derived field metadata against the + integration's sensor definitions to detect unit mismatches. Also + reports fields the integration doesn't map to any sensor. + """ + field_metadata: dict[str, dict[str, object]] | None = None + if isinstance(self._client, SpanMqttClient): + raw = self._client.field_metadata + if raw is not None: + field_metadata = { + k: {"unit": v.unit, "datatype": v.datatype} for k, v in raw.items() + } + + if field_metadata is None: + _LOGGER.debug("Schema validation skipped — no field metadata available") + return + + sensor_defs = collect_sensor_definitions() + validate_field_metadata(field_metadata, sensor_defs=sensor_defs) + + # --- Hardware capability detection --- + + @staticmethod + def _detect_capabilities(snapshot: SpanPanelSnapshot) -> frozenset[str]: + """Derive optional hardware capabilities present in the snapshot.""" + caps: set[str] = set() + if snapshot.battery.soe_percentage is not None: + caps.add("bess") + if snapshot.power_flow_pv is not None or any( + c.device_type == "pv" for c in snapshot.circuits.values() + ): + caps.add("pv") + if snapshot.power_flow_site is not None: + caps.add("power_flows") + if ( + any(c.device_type == "evse" for c in snapshot.circuits.values()) + or len(snapshot.evse) > 0 + ): + caps.add("evse") + return frozenset(caps) + + def _check_capability_change(self, snapshot: SpanPanelSnapshot) -> None: + """Check if hardware capabilities changed and request reload if expanded.""" + current = self._detect_capabilities(snapshot) + if self._known_capabilities is None: + # First snapshot — record baseline + self._known_capabilities = current + return + + new_caps = current - self._known_capabilities + if new_caps: _LOGGER.info( - "SPAN Panel update cycle (ERROR PATH) completed - Total: %.3fs", - error_cycle_duration, + "New hardware capabilities detected: %s — requesting reload", + ", ".join(sorted(new_caps)), ) + self._known_capabilities = current + self.request_reload() - # Return the last known data instead of raising UpdateFailed - # This keeps the coordinator updating so grace period logic can work - return self.span_panel + # --- Solar entity migration (v1 → v2) --- - async def _async_reload_task(self) -> None: - """Task to handle integration reload with proper error handling.""" - try: - _LOGGER.info("Reloading SPAN Panel integration") - await self.hass.async_block_till_done() - await self.hass.config_entries.async_reload(self.config_entry.entry_id) - _LOGGER.info("SPAN Panel integration reload completed successfully") + _SOLAR_SUFFIX_TO_DESCRIPTION_KEY: dict[str, str] = { + "_solar_current_power": "instantPowerW", + "_solar_produced_energy": "producedEnergyWh", + "_solar_consumed_energy": "consumedEnergyWh", + "_solar_net_energy": "netEnergyWh", + } - except ConfigEntryNotReady as err: - _LOGGER.warning("Config entry not ready during reload: %s", err) - except HomeAssistantError as err: - _LOGGER.error("Home Assistant error during reload: %s", err) - except Exception as err: - _LOGGER.exception("Unexpected error during reload: %s", err) + async def _handle_solar_migration(self, snapshot: SpanPanelSnapshot) -> None: + """Migrate v1 virtual solar entities to v2 PV circuit entities. - async def migrate_entity_ids( - self, old_flags: dict[str, bool], new_flags: dict[str, bool] - ) -> bool: - """Migrate entity IDs based on old and new configuration flags. + When solar_migration_pending is set in config entry data (by v3→v4 + config migration), this method finds the PV circuit in the MQTT + snapshot and rewrites entity registry unique_ids in-place so that + history and statistics are preserved. - This method delegates to the EntityIdMigrationManager to handle the actual migration logic. + Old pattern: span_{serial}_solar_current_power + New pattern: span_{serial}_{pv_uuid}_power + """ + pv_circuits = [c for c in snapshot.circuits.values() if c.device_type == "pv"] + + if len(pv_circuits) == 0: + _LOGGER.info("No PV circuits found — removing stale solar entities") + self._remove_stale_solar_entities() + self._clear_solar_migration_flag() + return + + if len(pv_circuits) > 1: + _LOGGER.warning( + "Found %d PV circuits — cannot auto-migrate solar entities. " + "Please reconfigure solar manually.", + len(pv_circuits), + ) + async_create( + self.hass, + "Multiple PV circuits detected on your SPAN Panel. " + "Automatic solar entity migration cannot proceed. " + "Please reconfigure solar settings in the integration options.", + title="SPAN Panel: Solar Migration Required", + notification_id=f"span_solar_migration_{self.config_entry.entry_id}", + ) + return - Args: - old_flags: Configuration flags before the change - new_flags: Configuration flags after the change + # Single PV circuit — proceed with unique_id rewrite + pv_circuit = pv_circuits[0] + pv_uuid = pv_circuit.circuit_id + serial = snapshot.serial_number + _LOGGER.info( + "Found single PV circuit %s — migrating solar entity unique IDs", + pv_uuid, + ) - Returns: - bool: True if migration succeeded, False otherwise + entity_registry = er.async_get(self.hass) + entries = er.async_entries_for_config_entry(entity_registry, self.config_entry.entry_id) + migrated_count = 0 - """ - return await self._migration_manager.migrate_entity_ids(old_flags, new_flags) + for entry in entries: + if not entry.unique_id: + continue + for old_suffix, desc_key in self._SOLAR_SUFFIX_TO_DESCRIPTION_KEY.items(): + if entry.unique_id.endswith(old_suffix): + new_unique_id = build_circuit_unique_id(serial, pv_uuid, desc_key) + _LOGGER.info( + "Migrating solar entity: %s → %s (entity_id=%s)", + entry.unique_id, + new_unique_id, + entry.entity_id, + ) + entity_registry.async_update_entity( + entry.entity_id, new_unique_id=new_unique_id + ) + migrated_count += 1 + break + + _LOGGER.info("Solar migration complete: %d entities migrated", migrated_count) + self._clear_solar_migration_flag() + + if migrated_count > 0: + # Reload so platform re-registers entities with updated unique IDs + self.hass.async_create_task( + self.hass.config_entries.async_reload(self.config_entry.entry_id) + ) + + def _remove_stale_solar_entities(self) -> None: + """Remove v1 virtual solar entities that have no v2 PV equivalent.""" + entity_registry = er.async_get(self.hass) + entries = er.async_entries_for_config_entry(entity_registry, self.config_entry.entry_id) + for entry in entries: + if not entry.unique_id: + continue + if any( + entry.unique_id.endswith(suffix) for suffix in self._SOLAR_SUFFIX_TO_DESCRIPTION_KEY + ): + _LOGGER.info( + "Removing stale solar entity: %s (unique_id=%s)", + entry.entity_id, + entry.unique_id, + ) + entity_registry.async_remove(entry.entity_id) - async def _handle_pending_legacy_migration(self) -> None: - """Handle pending legacy migration after integration startup. + def _clear_solar_migration_flag(self) -> None: + """Clear the solar_migration_pending flag from config entry data.""" + updated_data = dict(self.config_entry.data) + updated_data.pop("solar_migration_pending", None) + self.hass.config_entries.async_update_entry(self.config_entry, data=updated_data) - This function is called when a pending_legacy_migration flag is found in the - config entry data. It performs the migration and then cleans up the flag. - The migration happens during the coordinator update cycle. + # --- Post-update maintenance --- + + async def _run_post_update_tasks(self, snapshot: SpanPanelSnapshot) -> None: + """Run maintenance tasks after a snapshot update. + + Called from both the polling path (_async_update_data) and the streaming + path (_on_snapshot_push). The HA DataUpdateCoordinator resets its fallback + poll timer on every async_set_updated_data() call, so during active MQTT + streaming the polling path effectively never fires. This shared method + ensures reload requests are processed regardless of transport mode. """ - # Always remove the flag first to prevent infinite loops - _LOGGER.info("Removing pending_legacy_migration flag to prevent loops") - current_options = dict(self.config_entry.options) - current_options.pop("pending_legacy_migration", None) - self.hass.config_entries.async_update_entry(self.config_entry, options=current_options) + # One-shot schema validation after first successful refresh + if not self._schema_validated: + self._schema_validated = True + self._run_schema_validation() + + # Check for pending solar entity migration (v1 solar → v2 PV circuit) + if self.config_entry.data.get("solar_migration_pending", False): + await self._handle_solar_migration(snapshot) + # Fire persistent notification for any energy dips detected this cycle + await self._fire_dip_notification() + + # Delegate snapshot to current monitor if enabled + if self.current_monitor is not None: + self.current_monitor.process_snapshot(snapshot) + + # Handle reload request if one was made (e.g., name sync, capability change) + if self._reload_requested: + self._reload_requested = False + self.hass.async_create_task(self._async_reload_task()) + + # --- Data update --- + + async def _async_update_data(self) -> SpanPanelSnapshot: + """Fetch data from the panel client.""" try: - _LOGGER.info("Starting pending legacy migration") + # Performance timing + cycle_start = _epoch_time() + self._last_tick_epoch = cycle_start - # Capture the old flags (legacy state) - old_flags = {USE_CIRCUIT_NUMBERS: False, USE_DEVICE_PREFIX: False} + fetch_start = _epoch_time() + snapshot = await self._client.get_snapshot() + fetch_duration = _epoch_time() - fetch_start - # Get the new flags from the current config entry options - new_flags = { - USE_CIRCUIT_NUMBERS: self.config_entry.options.get(USE_CIRCUIT_NUMBERS, False), - USE_DEVICE_PREFIX: self.config_entry.options.get(USE_DEVICE_PREFIX, True), - } + cycle_total = _epoch_time() - cycle_start + _LOGGER.debug( + "SPAN Panel update cycle completed - Total: %.3fs | Fetch: %.3fs", + cycle_total, + fetch_duration, + ) - success = await self.migrate_entity_ids(old_flags, new_flags) + self._mark_panel_online() - if success: - _LOGGER.info("Pending legacy migration completed successfully") - _LOGGER.info("Scheduling final reload to display new entity IDs in UI") - # Schedule reload to pick up new entity IDs - self.hass.async_create_task( - self.hass.config_entries.async_reload(self.config_entry.entry_id) - ) - else: - _LOGGER.error("Pending legacy migration failed") + # Check for new hardware capabilities (BESS, PV, power-flows) + self._check_capability_change(snapshot) - except Exception as e: - _LOGGER.error("Pending legacy migration task failed: %s", e, exc_info=True) + await self._run_post_update_tasks(snapshot) - async def _handle_pending_naming_migration(self) -> None: - """Handle pending naming pattern migration after integration startup. + except SpanPanelAuthError as err: + raise ConfigEntryAuthFailed from err - This function is called when a pending_naming_migration flag is found in the - config entry data. It performs the migration and then cleans up the flag. - The migration happens during the coordinator update cycle. - """ - # Always remove the flag first to prevent infinite loops - _LOGGER.info("Removing pending_naming_migration flag to prevent loops") - current_options = dict(self.config_entry.options) - current_options.pop("pending_naming_migration", None) - self.hass.config_entries.async_update_entry(self.config_entry, options=current_options) + except ConfigEntryAuthFailed: + raise + except SpanPanelStaleDataError as err: + # Expected offline path — the library signals the client + # isn't live. Same handling as other offline errors. + self._mark_panel_offline(err) + if self.data is not None: + return self.data + raise + + except Exception as err: + # Unexpected error — log the transition but keep the + # coordinator ticking on last-known data for grace-period logic. + # On first refresh (self.data is None), re-raise so + # async_config_entry_first_refresh surfaces the error properly. + self._mark_panel_offline(err) + if self.data is not None: + return self.data + raise + else: + return snapshot + + async def _async_reload_task(self) -> None: + """Task to handle integration reload with proper error handling.""" try: - _LOGGER.info("Starting pending naming pattern migration") - - # Get the old flags that were stored during config flow processing - old_flags = { - USE_CIRCUIT_NUMBERS: self.config_entry.options.get( - "old_use_circuit_numbers", False - ), - USE_DEVICE_PREFIX: self.config_entry.options.get("old_use_device_prefix", False), - } - - # Get the new flags from the current config entry options - new_flags = { - USE_CIRCUIT_NUMBERS: self.config_entry.options.get(USE_CIRCUIT_NUMBERS, False), - USE_DEVICE_PREFIX: self.config_entry.options.get(USE_DEVICE_PREFIX, True), - } - - # Use the generalized migration method that handles old/new flag comparison - success = await self._migration_manager.migrate_entity_ids(old_flags, new_flags) - - if success: - _LOGGER.info("Pending naming pattern migration completed successfully") - # Clean up the old flags that were stored for migration - current_options = dict(self.config_entry.options) - current_options.pop("old_use_circuit_numbers", None) - current_options.pop("old_use_device_prefix", None) - self.hass.config_entries.async_update_entry( - self.config_entry, options=current_options - ) - # Schedule reload to pick up new entity IDs - self.hass.async_create_task( - self.hass.config_entries.async_reload(self.config_entry.entry_id) - ) - else: - _LOGGER.error("Pending naming pattern migration failed") + _LOGGER.info("Reloading SPAN Panel integration") + await self.hass.async_block_till_done() + await self.hass.config_entries.async_reload(self.config_entry.entry_id) + _LOGGER.info("SPAN Panel integration reload completed successfully") - except Exception as e: - _LOGGER.error("Pending naming pattern migration task failed: %s", e, exc_info=True) + except ConfigEntryNotReady as err: + _LOGGER.warning("Config entry not ready during reload: %s", err) + except HomeAssistantError as err: + _LOGGER.error("Home Assistant error during reload: %s", err) + except Exception: + _LOGGER.exception("Unexpected error during reload") diff --git a/custom_components/span_panel/current_monitor.py b/custom_components/span_panel/current_monitor.py new file mode 100644 index 00000000..dee074c5 --- /dev/null +++ b/custom_components/span_panel/current_monitor.py @@ -0,0 +1,550 @@ +"""Current monitoring for SPAN Panel circuits and mains legs. + +Detects spike (instantaneous) and continuous overload conditions by comparing +current readings against breaker ratings with configurable thresholds. +Dispatches notifications via event bus, notify services, and persistent +notifications. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass +from datetime import datetime +import logging +from typing import TYPE_CHECKING, Any + +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.storage import Store +from span_panel_api import SpanPanelSnapshot + +from .alert_dispatcher import dispatch_alert, format_notification +from .const import ( + DEFAULT_CONTINUOUS_THRESHOLD_PCT, + DEFAULT_COOLDOWN_DURATION_M, + DEFAULT_NOTIFICATION_MESSAGE_TEMPLATE, + DEFAULT_NOTIFICATION_PRIORITY, + DEFAULT_NOTIFICATION_TITLE_TEMPLATE, + DEFAULT_SPIKE_THRESHOLD_PCT, + DEFAULT_WINDOW_DURATION_M, + DOMAIN, +) +from .helpers import build_circuit_unique_id, build_panel_unique_id +from .id_builder import extract_circuit_uuid_from_unique_id +from .options import ( + CONTINUOUS_THRESHOLD_PCT, + COOLDOWN_DURATION_M, + NOTIFICATION_MESSAGE_TEMPLATE, + NOTIFICATION_PRIORITY, + NOTIFICATION_TITLE_TEMPLATE, + NOTIFY_TARGETS, + SPIKE_THRESHOLD_PCT, + WINDOW_DURATION_M, +) +from .threshold_evaluator import ( + MonitoringSettings, + check_continuous, + check_spike, + is_monitoring_disabled, + resolve_thresholds, +) + +if TYPE_CHECKING: + from homeassistant.config_entries import ConfigEntry + from homeassistant.core import HomeAssistant + +_LOGGER = logging.getLogger(__name__) + +# Maps mains leg identifiers to SpanPanelSnapshot attribute names +_MAINS_CURRENT_ATTRS: dict[str, str] = { + "upstream_l1": "upstream_l1_current_a", + "upstream_l2": "upstream_l2_current_a", + "downstream_l1": "downstream_l1_current_a", + "downstream_l2": "downstream_l2_current_a", +} + +_STORAGE_VERSION = 1 +_STORAGE_KEY_PREFIX = "span_panel_current_monitor" + + +@dataclass +class MonitoredPointState: + """Tracking state for a single monitored point (circuit or mains leg).""" + + last_current_a: float = 0.0 + over_threshold_since: datetime | None = None + last_spike_alert: datetime | None = None + last_continuous_alert: datetime | None = None + + +class CurrentMonitor: + """Monitors current draw against breaker ratings for overload detection.""" + + def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + """Initialize the CurrentMonitor.""" + self._hass = hass + self._entry = entry + self._circuit_states: dict[str, MonitoredPointState] = {} + self._mains_states: dict[str, MonitoredPointState] = {} + self._circuit_overrides: dict[str, MonitoringSettings] = {} + self._mains_overrides: dict[str, MonitoringSettings] = {} + self._global_settings: MonitoringSettings = {} + self._last_snapshot: SpanPanelSnapshot | None = None + self._store: Store[dict[str, Any]] = Store( + hass, + _STORAGE_VERSION, + f"{_STORAGE_KEY_PREFIX}.{entry.entry_id}", + ) + + # --- Public API --- + + def process_snapshot(self, snapshot: SpanPanelSnapshot) -> None: + """Evaluate thresholds for all circuits and mains legs.""" + self._last_snapshot = snapshot + self._evaluate_circuits(snapshot) + self._evaluate_mains(snapshot) + + def get_circuit_state(self, circuit_id: str) -> MonitoredPointState | None: + """Return tracking state for a circuit, or None if not monitored.""" + return self._circuit_states.get(circuit_id) + + def get_mains_state(self, leg: str) -> MonitoredPointState | None: + """Return tracking state for a mains leg, or None if not monitored.""" + return self._mains_states.get(leg) + + def set_circuit_override(self, circuit_id: str, overrides: MonitoringSettings) -> None: + """Set per-circuit threshold overrides.""" + existing = self._circuit_overrides.get(circuit_id, {}) + existing.update(overrides) + if self._is_redundant_override(existing): + self._circuit_overrides.pop(circuit_id, None) + else: + self._circuit_overrides[circuit_id] = existing + self._hass.async_create_task(self.async_save_overrides()) + + def clear_circuit_override(self, circuit_id: str) -> None: + """Remove per-circuit threshold overrides.""" + self._circuit_overrides.pop(circuit_id, None) + self._circuit_states.pop(circuit_id, None) + self._hass.async_create_task(self.async_save_overrides()) + + def set_mains_override(self, leg: str, overrides: MonitoringSettings) -> None: + """Set per-mains-leg threshold overrides.""" + existing = self._mains_overrides.get(leg, {}) + existing.update(overrides) + if self._is_redundant_override(existing): + self._mains_overrides.pop(leg, None) + else: + self._mains_overrides[leg] = existing + self._hass.async_create_task(self.async_save_overrides()) + + def _is_redundant_override(self, override: MonitoringSettings) -> bool: + """Check if an override matches global defaults (and can be removed). + + An override is redundant if monitoring is enabled (or not set, defaulting + to True) and all threshold values match the global settings. + """ + if override.get("monitoring_enabled") is False: + return False + g = self.get_global_settings() + threshold_keys = ( + CONTINUOUS_THRESHOLD_PCT, + SPIKE_THRESHOLD_PCT, + WINDOW_DURATION_M, + COOLDOWN_DURATION_M, + ) + for key in threshold_keys: + if key in override and override[key] != g[key]: + return False + # All present keys match globals (or aren't set), and monitoring is enabled + return True + + def clear_mains_override(self, leg: str) -> None: + """Remove per-mains-leg threshold overrides.""" + self._mains_overrides.pop(leg, None) + self._mains_states.pop(leg, None) + self._hass.async_create_task(self.async_save_overrides()) + + def get_global_settings(self) -> MonitoringSettings: + """Get the effective global monitoring settings. + + Returns stored global settings if available, otherwise falls back + to config entry options for backward compatibility during migration. + """ + opts: Mapping[str, Any] + if self._global_settings: + opts = self._global_settings + else: + opts = self._entry.options + return { + CONTINUOUS_THRESHOLD_PCT: opts.get( + CONTINUOUS_THRESHOLD_PCT, DEFAULT_CONTINUOUS_THRESHOLD_PCT + ), + SPIKE_THRESHOLD_PCT: opts.get(SPIKE_THRESHOLD_PCT, DEFAULT_SPIKE_THRESHOLD_PCT), + WINDOW_DURATION_M: opts.get(WINDOW_DURATION_M, DEFAULT_WINDOW_DURATION_M), + COOLDOWN_DURATION_M: opts.get(COOLDOWN_DURATION_M, DEFAULT_COOLDOWN_DURATION_M), + NOTIFY_TARGETS: opts.get(NOTIFY_TARGETS, ""), + NOTIFICATION_TITLE_TEMPLATE: opts.get( + NOTIFICATION_TITLE_TEMPLATE, DEFAULT_NOTIFICATION_TITLE_TEMPLATE + ), + NOTIFICATION_MESSAGE_TEMPLATE: opts.get( + NOTIFICATION_MESSAGE_TEMPLATE, DEFAULT_NOTIFICATION_MESSAGE_TEMPLATE + ), + NOTIFICATION_PRIORITY: opts.get(NOTIFICATION_PRIORITY, DEFAULT_NOTIFICATION_PRIORITY), + } + + def set_global_settings(self, settings: MonitoringSettings) -> None: + """Update global monitoring settings in storage.""" + valid_keys = { + CONTINUOUS_THRESHOLD_PCT, + SPIKE_THRESHOLD_PCT, + WINDOW_DURATION_M, + COOLDOWN_DURATION_M, + NOTIFY_TARGETS, + NOTIFICATION_TITLE_TEMPLATE, + NOTIFICATION_MESSAGE_TEMPLATE, + NOTIFICATION_PRIORITY, + } + for key, value in settings.items(): + if key in valid_keys: + self._global_settings[key] = value + + self._hass.async_create_task(self.async_save_overrides()) + + def _resolve_circuit_entity_id(self, circuit_id: str) -> str: + """Resolve circuit_id to a sensor entity_id for monitoring status keys. + + Tries the current sensor first, then falls back to power, matching + the frontend lookup order (circuit.entities.current ?? .power). + """ + snapshot = self._last_snapshot + if snapshot is None: + return circuit_id + serial = snapshot.serial_number + entity_reg = er.async_get(self._hass) + # Try current sensor first (matches frontend preference) + current_uid = build_circuit_unique_id(serial, circuit_id, "current") + entity_id = entity_reg.async_get_entity_id("sensor", DOMAIN, current_uid) + if entity_id is not None: + return entity_id + # Fall back to power sensor + power_uid = build_circuit_unique_id(serial, circuit_id, "instantPowerW") + entity_id = entity_reg.async_get_entity_id("sensor", DOMAIN, power_uid) + return entity_id if entity_id is not None else circuit_id + + def resolve_entity_to_circuit_id(self, entity_id: str) -> str: + """Resolve a power sensor entity_id to its internal circuit_id. + + Accepts either an entity_id (sensor.span_panel_kitchen_power) or + a raw circuit_id (UUID) for backwards compatibility. + """ + entity_reg = er.async_get(self._hass) + entry = entity_reg.async_get(entity_id) + if entry is not None and entry.unique_id: + uuid = extract_circuit_uuid_from_unique_id(entry.unique_id) + if uuid is not None: + return uuid + # Fall through: assume it's already a circuit_id + return entity_id + + def resolve_entity_to_mains_leg(self, entity_id: str) -> str: + """Resolve a current sensor entity_id to its internal mains leg name. + + Accepts either an entity_id (sensor.span_panel_upstream_l1_current) + or a raw leg name (upstream_l1) for backwards compatibility. + """ + # Check if it's already a known leg name + if entity_id in _MAINS_CURRENT_ATTRS: + return entity_id + entity_reg = er.async_get(self._hass) + entry = entity_reg.async_get(entity_id) + if entry is not None and entry.unique_id: + # unique_id format: span_{serial}_{leg}_current + # e.g., span_sp3-242424-001_upstream_l1_current + for leg in _MAINS_CURRENT_ATTRS: + if f"_{leg}_current" in entry.unique_id: + return leg + return entity_id + + def _resolve_mains_entity_id(self, leg: str) -> str: + """Resolve mains leg to its current sensor entity_id, or fall back to leg name.""" + snapshot = self._last_snapshot + if snapshot is None: + return leg + serial = snapshot.serial_number + # The sensor key matches the leg name + "_current" (e.g., "upstream_l1_current") + unique_id = build_panel_unique_id(serial, f"{leg}_current") + entity_reg = er.async_get(self._hass) + entity_id = entity_reg.async_get_entity_id("sensor", DOMAIN, unique_id) + return entity_id if entity_id is not None else leg + + def get_monitoring_status(self) -> dict[str, Any]: + """Return current monitoring state for all tracked points.""" + snapshot = self._last_snapshot + main_rating = ( + float(snapshot.main_breaker_rating_a) + if snapshot and snapshot.main_breaker_rating_a + else None + ) + + circuits: dict[str, dict[str, Any]] = {} + # Include all circuits from the snapshot, not just those with active state. + # Circuits with relays off may have no state yet but should still appear. + all_circuit_ids = set(self._circuit_states.keys()) + if snapshot: + all_circuit_ids |= { + cid for cid in snapshot.circuits if not cid.startswith("unmapped_tab_") + } + + for cid in all_circuit_ids: + state = self._circuit_states.get(cid) + circuit = snapshot.circuits.get(cid) if snapshot else None + rating = ( + float(circuit.breaker_rating_a) if circuit and circuit.breaker_rating_a else None + ) + last_current = state.last_current_a if state else 0.0 + utilization = round(last_current / rating * 100, 1) if rating else None + cont_pct, spike_pct, window_m, cooldown_m = resolve_thresholds( + self._circuit_overrides.get(cid, {}), self.get_global_settings() + ) + entity_id = self._resolve_circuit_entity_id(cid) + override = self._circuit_overrides.get(cid, {}) + has_override = bool(override) + monitoring_enabled = override.get("monitoring_enabled", True) + + circuits[entity_id] = { + "name": circuit.name if circuit else cid, + "last_current_a": last_current, + "breaker_rating_a": rating, + "utilization_pct": utilization, + "continuous_threshold_pct": cont_pct, + "spike_threshold_pct": spike_pct, + "window_duration_m": window_m, + "cooldown_duration_m": cooldown_m, + "has_override": has_override, + "monitoring_enabled": monitoring_enabled, + "over_threshold_since": state.over_threshold_since.isoformat() + if state and state.over_threshold_since + else None, + "last_spike_alert": state.last_spike_alert.isoformat() + if state and state.last_spike_alert + else None, + "last_continuous_alert": state.last_continuous_alert.isoformat() + if state and state.last_continuous_alert + else None, + } + + # Present mains as a single entry using the higher of the two upstream legs. + # The main breaker is a single 240V breaker; internally we still evaluate + # per-leg, but the UI shows one combined "Mains Breaker" point. + mains: dict[str, dict[str, Any]] = {} + l1_state = self._mains_states.get("upstream_l1") + l2_state = self._mains_states.get("upstream_l2") + l1_current = l1_state.last_current_a if l1_state else 0.0 + l2_current = l2_state.last_current_a if l2_state else 0.0 + peak_current = max(l1_current, l2_current) + utilization = round(peak_current / main_rating * 100, 1) if main_rating else None + + # Use upstream_l1 thresholds as the representative (they're the same unless overridden) + cont_pct, spike_pct, window_m, cooldown_m = resolve_thresholds( + self._mains_overrides.get("upstream_l1", {}), self.get_global_settings() + ) + override = self._mains_overrides.get("upstream_l1", {}) + has_override = bool(override) + monitoring_enabled = override.get("monitoring_enabled", True) + + # Resolve to the current_power entity for the mains entry key + mains_entity_id = self._resolve_mains_entity_id("upstream_l1") + mains[mains_entity_id] = { + "name": "Mains Breaker", + "last_current_a": peak_current, + "breaker_rating_a": main_rating, + "utilization_pct": utilization, + "continuous_threshold_pct": cont_pct, + "spike_threshold_pct": spike_pct, + "window_duration_m": window_m, + "cooldown_duration_m": cooldown_m, + "has_override": has_override, + "monitoring_enabled": monitoring_enabled, + "over_threshold_since": None, + "last_spike_alert": None, + "last_continuous_alert": None, + } + + return {"circuits": circuits, "mains": mains} + + async def async_start(self) -> None: + """Start the monitor — load persisted overrides.""" + await self.async_load_overrides() + _LOGGER.info("Current monitor started") + + def async_stop(self) -> None: + """Stop the monitor — clear all tracking state.""" + self._circuit_states.clear() + self._mains_states.clear() + _LOGGER.info("Current monitor stopped") + + async def async_save_disabled(self) -> None: + """Mark monitoring as disabled in storage, preserving settings.""" + data = await self._store.async_load() or {} + data["enabled"] = False + await self._store.async_save(data) + + async def async_save_overrides(self) -> None: + """Persist circuit overrides, mains overrides, and global settings to storage.""" + data: dict[str, Any] = { + "enabled": True, + "circuit_overrides": self._circuit_overrides, + "mains_overrides": self._mains_overrides, + } + if self._global_settings: + data["global"] = self._global_settings + await self._store.async_save(data) + + async def async_load_overrides(self) -> None: + """Load circuit overrides, mains overrides, and global settings from storage.""" + data = await self._store.async_load() + if data is None: + return + self._circuit_overrides = data.get("circuit_overrides", {}) + self._mains_overrides = data.get("mains_overrides", {}) + self._global_settings = data.get("global", {}) + + @staticmethod + async def async_is_enabled(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Check if monitoring was previously enabled by reading storage.""" + store: Store[dict[str, Any]] = Store( + hass, + _STORAGE_VERSION, + f"{_STORAGE_KEY_PREFIX}.{entry.entry_id}", + ) + data = await store.async_load() + return bool(data and data.get("enabled", False)) + + # --- Threshold resolution (delegated to threshold_evaluator) --- + + def _resolve_circuit_thresholds(self, circuit_id: str) -> tuple[int, int, int, int]: + """Return (continuous_pct, spike_pct, window_m, cooldown_m) for a circuit.""" + return resolve_thresholds( + self._circuit_overrides.get(circuit_id, {}), self.get_global_settings() + ) + + def _resolve_mains_thresholds(self, leg: str) -> tuple[int, int, int, int]: + """Return (continuous_pct, spike_pct, window_m, cooldown_m) for a mains leg.""" + return resolve_thresholds(self._mains_overrides.get(leg, {}), self.get_global_settings()) + + # --- Circuit evaluation --- + + def _evaluate_circuits(self, snapshot: SpanPanelSnapshot) -> None: + """Evaluate thresholds for all circuits in the snapshot.""" + for circuit_id, circuit in snapshot.circuits.items(): + if circuit.current_a is None or circuit.breaker_rating_a is None: + continue + if is_monitoring_disabled(self._circuit_overrides.get(circuit_id, {})): + continue + + state = self._circuit_states.setdefault(circuit_id, MonitoredPointState()) + current = abs(circuit.current_a) + rating = circuit.breaker_rating_a + state.last_current_a = current + + cont_pct, spike_pct, window_m, cooldown_m = resolve_thresholds( + self._circuit_overrides.get(circuit_id, {}), self.get_global_settings() + ) + + alert = check_spike(state, current, rating, spike_pct, cooldown_m) + if alert is not None: + dispatch_alert( + self._hass, + self.get_global_settings(), + alert_type=alert.alert_type, + alert_name=circuit.name or circuit_id, + alert_id=circuit_id, + alert_source="circuit", + current_a=alert.current_a, + breaker_rating_a=alert.breaker_rating_a, + threshold_pct=alert.threshold_pct, + utilization_pct=alert.utilization_pct, + panel_serial=snapshot.serial_number, + ) + + alert = check_continuous(state, current, rating, cont_pct, window_m, cooldown_m) + if alert is not None: + dispatch_alert( + self._hass, + self.get_global_settings(), + alert_type=alert.alert_type, + alert_name=circuit.name or circuit_id, + alert_id=circuit_id, + alert_source="circuit", + current_a=alert.current_a, + breaker_rating_a=alert.breaker_rating_a, + threshold_pct=alert.threshold_pct, + utilization_pct=alert.utilization_pct, + panel_serial=snapshot.serial_number, + window_duration_s=alert.window_duration_s, + over_threshold_since=alert.over_threshold_since, + ) + + # --- Mains evaluation --- + + def _evaluate_mains(self, snapshot: SpanPanelSnapshot) -> None: + """Evaluate thresholds for all mains legs.""" + if snapshot.main_breaker_rating_a is None: + return + + rating = float(snapshot.main_breaker_rating_a) + + for leg, attr in _MAINS_CURRENT_ATTRS.items(): + current_val = getattr(snapshot, attr, None) + if current_val is None: + continue + if is_monitoring_disabled(self._mains_overrides.get(leg, {})): + continue + + state = self._mains_states.setdefault(leg, MonitoredPointState()) + current = abs(current_val) + state.last_current_a = current + + cont_pct, spike_pct, window_m, cooldown_m = resolve_thresholds( + self._mains_overrides.get(leg, {}), self.get_global_settings() + ) + + leg_label = leg.replace("_", " ").title() + + alert = check_spike(state, current, rating, spike_pct, cooldown_m) + if alert is not None: + dispatch_alert( + self._hass, + self.get_global_settings(), + alert_type=alert.alert_type, + alert_name=f"Mains {leg_label}", + alert_id=leg, + alert_source="mains", + current_a=alert.current_a, + breaker_rating_a=alert.breaker_rating_a, + threshold_pct=alert.threshold_pct, + utilization_pct=alert.utilization_pct, + panel_serial=snapshot.serial_number, + ) + + alert = check_continuous(state, current, rating, cont_pct, window_m, cooldown_m) + if alert is not None: + dispatch_alert( + self._hass, + self.get_global_settings(), + alert_type=alert.alert_type, + alert_name=f"Mains {leg_label}", + alert_id=leg, + alert_source="mains", + current_a=alert.current_a, + breaker_rating_a=alert.breaker_rating_a, + threshold_pct=alert.threshold_pct, + utilization_pct=alert.utilization_pct, + panel_serial=snapshot.serial_number, + window_duration_s=alert.window_duration_s, + over_threshold_since=alert.over_threshold_since, + ) + + # Backward-compatible static alias kept for existing callers/tests. + _format_notification = staticmethod(format_notification) diff --git a/custom_components/span_panel/diagnostics.py b/custom_components/span_panel/diagnostics.py new file mode 100644 index 00000000..0c46cc6e --- /dev/null +++ b/custom_components/span_panel/diagnostics.py @@ -0,0 +1,96 @@ +"""Diagnostics support for the Span Panel integration.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.core import HomeAssistant + +from . import SpanPanelConfigEntry +from .const import ( + CONF_EBUS_BROKER_PASSWORD, + CONF_EBUS_BROKER_USERNAME, + CONF_HOP_PASSPHRASE, +) + +TO_REDACT = { + CONF_ACCESS_TOKEN, + CONF_EBUS_BROKER_PASSWORD, + CONF_EBUS_BROKER_USERNAME, + CONF_HOP_PASSPHRASE, + "password", + "username", +} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, + entry: SpanPanelConfigEntry, +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinator = entry.runtime_data.coordinator + snapshot = coordinator.data + + panel_data: dict[str, Any] = { + "serial_number": snapshot.serial_number, + "firmware_version": snapshot.firmware_version, + "panel_size": snapshot.panel_size, + } + + if snapshot.wifi_ssid is not None: + panel_data["wifi_ssid"] = snapshot.wifi_ssid + if snapshot.eth0_link is not None: + panel_data["eth0_link"] = snapshot.eth0_link + if snapshot.wlan_link is not None: + panel_data["wlan_link"] = snapshot.wlan_link + + circuit_data: dict[str, dict[str, Any]] = {} + for circuit_id, circuit in snapshot.circuits.items(): + circuit_data[circuit_id] = { + "name": circuit.name, + "relay_state": circuit.relay_state, + "relay_state_target": circuit.relay_state_target, + "priority": circuit.priority, + "priority_target": circuit.priority_target, + "is_user_controllable": circuit.is_user_controllable, + "instant_power_w": circuit.instant_power_w, + "produced_energy_wh": circuit.produced_energy_wh, + "consumed_energy_wh": circuit.consumed_energy_wh, + } + if hasattr(circuit, "device_type"): + circuit_data[circuit_id]["device_type"] = circuit.device_type + if hasattr(circuit, "tabs"): + circuit_data[circuit_id]["tabs"] = circuit.tabs + + evse_data: dict[str, dict[str, Any]] = {} + if snapshot.evse: + for evse_id, evse in snapshot.evse.items(): + evse_data[evse_id] = { + "node_id": evse.node_id, + "feed_circuit_id": evse.feed_circuit_id, + "status": evse.status, + "lock_state": evse.lock_state, + "advertised_current_a": evse.advertised_current_a, + } + + battery_data: dict[str, Any] = {} + if snapshot.battery: + battery_data = { + "connected": snapshot.battery.connected, + "soe_percentage": snapshot.battery.soe_percentage, + "soe_kwh": snapshot.battery.soe_kwh, + } + + return { + "config_entry": async_redact_data(entry.as_dict(), TO_REDACT), + "panel": panel_data, + "circuits": circuit_data, + "evse": evse_data, + "battery": battery_data, + "coordinator": { + "panel_offline": coordinator.panel_offline, + "last_update_success": coordinator.last_update_success, + }, + } diff --git a/custom_components/span_panel/energy_dip.py b/custom_components/span_panel/energy_dip.py new file mode 100644 index 00000000..515b7b38 --- /dev/null +++ b/custom_components/span_panel/energy_dip.py @@ -0,0 +1,71 @@ +"""Energy dip detection and compensation logic for Span Panel energy sensors. + +Pure functions for detecting energy value dips (typically caused by panel firmware +resets) and computing the compensated value to maintain monotonic TOTAL_INCREASING +sensor readings. +""" + +from __future__ import annotations + +from typing import Any + + +def process_energy_dip( + raw_value: float, + last_panel_reading: float | None, + current_offset: float, +) -> tuple[float, float | None, float]: + """Detect an energy dip and return updated offset, optional dip delta, and compensated value. + + A "dip" occurs when the panel reports a raw energy value that is at least 1.0 + lower than the previous reading (e.g., after a firmware reset). When detected, + the difference is added to the cumulative offset so downstream sensors keep a + monotonically increasing total. + + Args: + raw_value: The current raw energy reading from the panel. + last_panel_reading: The previous raw reading, or None if this is the first. + current_offset: The cumulative compensation offset so far. + + Returns: + A 3-tuple of (new_offset, dip_delta_or_none, compensated_value). + + """ + if last_panel_reading is not None and last_panel_reading - raw_value >= 1.0: + dip = last_panel_reading - raw_value + new_offset = current_offset + dip + return (new_offset, dip, raw_value + new_offset) + + return (current_offset, None, raw_value + current_offset) + + +def build_dip_attributes( + energy_offset: float, + last_dip_delta: float | None, + is_total_increasing: bool, + dip_enabled: bool, +) -> dict[str, Any]: + """Build extra_state_attributes dict for energy dip compensation diagnostics. + + Returns an empty dict when dip compensation is disabled or the sensor is not + TOTAL_INCREASING. + + Args: + energy_offset: Cumulative dip compensation offset. + last_dip_delta: Size of the most recent dip, or None if none observed. + is_total_increasing: Whether the sensor uses TOTAL_INCREASING state class. + dip_enabled: Whether energy dip compensation is enabled in options. + + Returns: + A dict of attribute key/value pairs (may be empty). + + """ + if not dip_enabled or not is_total_increasing: + return {} + + attrs: dict[str, Any] = {} + if energy_offset > 0: + attrs["energy_offset"] = round(energy_offset, 1) + if last_dip_delta is not None: + attrs["last_dip_delta"] = round(last_dip_delta, 1) + return attrs diff --git a/custom_components/span_panel/entity.py b/custom_components/span_panel/entity.py new file mode 100644 index 00000000..afc2590f --- /dev/null +++ b/custom_components/span_panel/entity.py @@ -0,0 +1,30 @@ +"""Base entity for Span Panel integration.""" + +from __future__ import annotations + +from homeassistant.const import CONF_HOST +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity +from span_panel_api import SpanPanelSnapshot + +from .const import CONF_DEVICE_NAME +from .coordinator import SpanPanelCoordinator +from .util import snapshot_to_device_info + + +class SpanPanelEntity(CoordinatorEntity[SpanPanelCoordinator]): + """Base entity for all Span Panel platforms.""" + + _attr_has_entity_name = True + + @staticmethod + def _build_device_info( + coordinator: SpanPanelCoordinator, + snapshot: SpanPanelSnapshot, + ) -> DeviceInfo: + """Construct device info from coordinator and snapshot.""" + device_name = coordinator.config_entry.data.get( + CONF_DEVICE_NAME, coordinator.config_entry.title + ) + host = coordinator.config_entry.data.get(CONF_HOST) + return snapshot_to_device_info(snapshot, device_name, host=host) diff --git a/custom_components/span_panel/entity_id_naming_patterns.py b/custom_components/span_panel/entity_id_naming_patterns.py deleted file mode 100644 index 0775d3ec..00000000 --- a/custom_components/span_panel/entity_id_naming_patterns.py +++ /dev/null @@ -1,989 +0,0 @@ -"""Entity ID naming pattern migration utilities for Span Panel integration.""" - -from __future__ import annotations - -import logging - -from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.util import slugify - -from .const import COORDINATOR, DOMAIN, USE_CIRCUIT_NUMBERS, USE_DEVICE_PREFIX -from .span_panel_circuit import SpanPanelCircuit - -_LOGGER = logging.getLogger(__name__) - - -class EntityIdMigrationManager: - """Manages entity ID migrations when naming patterns change.""" - - def __init__(self, hass: HomeAssistant, config_entry_id: str) -> None: - """Initialize the migration manager.""" - self.hass = hass - self.config_entry_id = config_entry_id - - def _get_device_name_from_registry(self, active_config_entry_id: str) -> str | None: - """Get the device name from the device registry. - - This gets the actual device name as shown in the UI, which may be different - from the config entry data if the user has renamed the device. - - Args: - active_config_entry_id: The config entry ID to find the device for - - Returns: - Device name from registry or None if not found - - """ - try: - device_registry = dr.async_get(self.hass) - - # Get all devices for this config entry - devices = dr.async_entries_for_config_entry(device_registry, active_config_entry_id) - - if not devices: - _LOGGER.warning("No devices found for config entry: %s", active_config_entry_id) - return None - - # For SPAN panels, there should typically be one main device - # Get the first device (main panel device) - main_device = devices[0] - - # Use name_by_user if available (user-customized name), otherwise fall back to name - device_name = main_device.name_by_user or main_device.name - - _LOGGER.debug( - "Retrieved device name from registry: %s (name_by_user: %s, name: %s)", - device_name, - main_device.name_by_user, - main_device.name, - ) - return device_name - - except Exception as e: - _LOGGER.debug( - "Failed to get device name for config entry %s: %s", active_config_entry_id, str(e) - ) - return None - - def _get_circuit_name_by_id(self, circuit_id: str) -> str | None: - """Get circuit friendly name by circuit ID only.""" - try: - coordinator_data = self.hass.data[DOMAIN][self.config_entry_id] - coordinator = coordinator_data[COORDINATOR] - span_panel = coordinator.data - - if circuit_id not in span_panel.circuits: - return None - - circuit_data: SpanPanelCircuit = span_panel.circuits[circuit_id] - return circuit_data.name - except Exception: - return None - - async def migrate_entity_ids( - self, old_flags: dict[str, bool], new_flags: dict[str, bool] - ) -> bool: - """Migrate entity IDs when naming patterns change. - - Args: - old_flags: Previous configuration flags - {USE_CIRCUIT_NUMBERS: bool, USE_DEVICE_PREFIX: bool} - new_flags: New configuration flags - {USE_CIRCUIT_NUMBERS: bool, USE_DEVICE_PREFIX: bool} - - Handles multiple types of migrations: - 1. Legacy migration (no device prefix -> device prefix) - 2. Naming pattern changes (friendly names <-> circuit numbers) - 3. Combined migrations (legacy + naming pattern changes) - - Returns: - True if migration was successful, False otherwise - - """ - _LOGGER.info( - "Starting entity ID migration: old_flags=%s, new_flags=%s", - old_flags, - new_flags, - ) - - # Determine what type of migration is needed - old_use_device_prefix = old_flags.get(USE_DEVICE_PREFIX, False) - new_use_device_prefix = new_flags.get(USE_DEVICE_PREFIX, False) - old_use_circuit_numbers = old_flags.get(USE_CIRCUIT_NUMBERS, False) - new_use_circuit_numbers = new_flags.get(USE_CIRCUIT_NUMBERS, False) - - # Check if legacy migration is needed (no device prefix -> device prefix) - needs_legacy_migration = not old_use_device_prefix and new_use_device_prefix - - # Check if naming pattern migration is needed (circuit numbers change) - needs_naming_migration = old_use_circuit_numbers != new_use_circuit_numbers - - if needs_legacy_migration and needs_naming_migration: - # Combined migration: legacy + naming pattern change - _LOGGER.info("Performing combined migration: legacy + naming pattern change") - legacy_success = await self._migrate_legacy_to_prefix(old_flags, new_flags) - if legacy_success: - # After legacy migration, do naming pattern migration - return await self.migrate_entity_ids_with_flags( - new_use_circuit_numbers, new_use_device_prefix - ) - return False - elif needs_legacy_migration: - # Legacy migration only - _LOGGER.info("Performing legacy migration: no device prefix -> device prefix") - return await self._migrate_legacy_to_prefix(old_flags, new_flags) - elif needs_naming_migration: - # Naming pattern migration only - _LOGGER.info( - "Performing naming pattern migration: circuit numbers %s -> %s", - old_use_circuit_numbers, - new_use_circuit_numbers, - ) - return await self.migrate_entity_ids_with_flags( - new_use_circuit_numbers, new_use_device_prefix - ) - else: - # No migration needed - _LOGGER.info("No migration needed - flags unchanged") - return True - - async def _migrate_legacy_to_prefix( - self, old_flags: dict[str, bool], new_flags: dict[str, bool] - ) -> bool: - """Migrate from legacy naming (no device prefix) to device prefix + friendly names. - - This migration includes ALL sensors (panel-level and circuit-level) since legacy - installations need comprehensive migration to the new naming structure. - - Args: - old_flags: {USE_CIRCUIT_NUMBERS: False, USE_DEVICE_PREFIX: False} - new_flags: {USE_CIRCUIT_NUMBERS: False, USE_DEVICE_PREFIX: True} - - Returns: - True if migration was successful, False otherwise - - """ - _LOGGER.info("Performing legacy to device prefix migration") - - try: - _LOGGER.info("Starting legacy migration for config entry: %s", self.config_entry_id) - - # Get entity registry - registry = er.async_get(self.hass) - - # Find the active config entry ID in hass.data (might be different from stored ID due to reloads) - domain_data = self.hass.data.get(DOMAIN, {}) - if not domain_data: - _LOGGER.error("No %s data found in hass.data - integration not loaded", DOMAIN) - return False - - # Verify the config entry ID exists in the loaded data - if self.config_entry_id not in domain_data: - _LOGGER.error( - "Config entry ID %s not found in loaded domain data", self.config_entry_id - ) - available_entries = list(domain_data.keys()) - _LOGGER.debug("Available config entry IDs: %s", available_entries) - return False - - active_config_entry_id = self.config_entry_id - _LOGGER.debug("Using config entry ID: %s", active_config_entry_id) - - # Get device name from device registry (this is the name shown in UI) - device_name = self._get_device_name_from_registry(active_config_entry_id) - if not device_name: - _LOGGER.error( - "Could not get device name from registry - migration aborted to prevent incorrect entity renaming" - ) - return False - - sanitized_device_name = slugify(device_name) - _LOGGER.info( - "Using device name for migration: %s (sanitized: %s)", - device_name, - sanitized_device_name, - ) - - # Use the active config entry ID we found - effective_config_entry_id = active_config_entry_id - - # Get entities for this config entry using HA helper - _LOGGER.debug( - "Attempting to get entities for config_entry_id: %s", effective_config_entry_id - ) - - # Check if config entry exists first - config_entry = self.hass.config_entries.async_get_entry(effective_config_entry_id) - if config_entry is None: - _LOGGER.error( - "Config entry %s not found in config entries registry - migration aborted", - effective_config_entry_id, - ) - available_entries = [ - entry.entry_id for entry in self.hass.config_entries.async_entries() - ] - _LOGGER.debug("Available config entry IDs: %s", available_entries) - return False - - try: - config_entry_entities = er.async_entries_for_config_entry( - registry, effective_config_entry_id - ) - except KeyError: - _LOGGER.error( - "Config entry ID %s not found in entity registry - migration aborted", - effective_config_entry_id, - ) - return False - - _LOGGER.info( - "Found %d entities for config_entry_id: %s", - len(config_entry_entities), - effective_config_entry_id, - ) - - # Filter entities that need renaming (don't already have device prefix) - entities_to_migrate = [] - for entity in config_entry_entities: - object_id = entity.entity_id.split(".", 1)[1] - _LOGGER.debug( - "Checking entity %s: object_id='%s', prefix='%s_', starts_with=%s", - entity.entity_id, - object_id, - sanitized_device_name, - object_id.startswith(f"{sanitized_device_name}_"), - ) - if not object_id.startswith(f"{sanitized_device_name}_"): - entities_to_migrate.append(entity) - _LOGGER.debug("Found entity to migrate: %s", entity.entity_id) - else: - _LOGGER.debug("Skipping entity (already has prefix): %s", entity.entity_id) - - if not entities_to_migrate: - _LOGGER.warning( - "No entities found to migrate for config entry: %s", active_config_entry_id - ) - return True - - _LOGGER.info("Found %d entities to migrate", len(entities_to_migrate)) - - # Remove duplicates from the migration list - seen_entity_ids = set() - unique_entities_to_migrate = [] - for entity in entities_to_migrate: - if entity.entity_id not in seen_entity_ids: - seen_entity_ids.add(entity.entity_id) - unique_entities_to_migrate.append(entity) - else: - _LOGGER.debug("Removing duplicate entity: %s", entity.entity_id) - - entities_to_migrate = unique_entities_to_migrate - _LOGGER.info( - "After deduplication: %d unique entities to migrate", len(entities_to_migrate) - ) - - # Migrate each entity (remove from list after processing to avoid duplicates) - migrated_count = 0 - - while entities_to_migrate: - entity = entities_to_migrate.pop(0) # Take first entity and remove it from list - current_entity_id = entity.entity_id - platform, object_id = current_entity_id.split(".", 1) - - # Safety check: skip if entity already has the device prefix - if object_id.startswith(f"{sanitized_device_name}_"): - _LOGGER.debug("Skipping entity (already has prefix): %s", current_entity_id) - continue - - # Generate new entity ID with device prefix - new_object_id = f"{sanitized_device_name}_{object_id}" - new_entity_id = f"{platform}.{new_object_id}" - - _LOGGER.info( - "Entity migration: %s -> %s", - current_entity_id, - new_entity_id, - ) - - # Update the entity registry (statistics should be transferred automatically in HA 2023.4+) - registry.async_update_entity(current_entity_id, new_entity_id=new_entity_id) - migrated_count += 1 - - _LOGGER.info("Migrated %d entities", migrated_count) - - # Migration completed - reload will be handled by integration startup - - return True - - except Exception as e: - _LOGGER.error("Legacy migration failed: %s", e) - return False - - async def migrate_entity_ids_with_flags( - self, use_circuit_numbers: bool, use_device_prefix: bool - ) -> bool: - """Migrate entity IDs based on provided naming flags. - - This method migrates circuit, switch, and select entities to use the specified - naming pattern while preserving unique IDs for statistics continuity. - - Args: - use_circuit_numbers: Whether to use circuit numbers in entity IDs - use_device_prefix: Whether to include device prefix in entity IDs - - Returns: - True if migration was successful, False otherwise - - """ - _LOGGER.info( - "Starting entity ID migration with flags: use_circuit_numbers=%s, use_device_prefix=%s", - use_circuit_numbers, - use_device_prefix, - ) - - try: - # Get entity registry - registry = er.async_get(self.hass) - - # Find the active config entry ID in hass.data (might be different from stored ID due to reloads) - domain_data = self.hass.data.get(DOMAIN, {}) - if not domain_data: - _LOGGER.error("No %s data found in hass.data - integration not loaded", DOMAIN) - return False - - # Verify the config entry ID exists in the loaded data - if self.config_entry_id not in domain_data: - _LOGGER.error( - "Config entry ID %s not found in loaded domain data", self.config_entry_id - ) - available_entries = list(domain_data.keys()) - _LOGGER.debug("Available config entry IDs: %s", available_entries) - return False - - active_config_entry_id = self.config_entry_id - _LOGGER.debug("Using config entry ID: %s", active_config_entry_id) - - # Get device name from device registry (this is the name shown in UI) - device_name = self._get_device_name_from_registry(active_config_entry_id) - if not device_name: - _LOGGER.error( - "Could not get device name from registry - migration aborted to prevent incorrect entity renaming" - ) - return False - - sanitized_device_name = slugify(device_name) - _LOGGER.info( - "Using device name for migration: %s (sanitized: %s)", - device_name, - sanitized_device_name, - ) - - # Get entities for this config entry - config_entry_entities = er.async_entries_for_config_entry( - registry, active_config_entry_id - ) - - _LOGGER.info( - "Found %d entities for config_entry_id: %s", - len(config_entry_entities), - active_config_entry_id, - ) - - # Filter entities that need migration (circuits, switches, selects) - entities_to_migrate = [] - for entity in config_entry_entities: - if self._should_migrate_entity(entity, sanitized_device_name): - entities_to_migrate.append(entity) - _LOGGER.debug("Found entity to migrate: %s", entity.entity_id) - - if not entities_to_migrate: - _LOGGER.warning( - "No entities found to migrate for config entry: %s", active_config_entry_id - ) - return True - - _LOGGER.info("Found %d entities to migrate", len(entities_to_migrate)) - - # Migrate each entity - migrated_count = 0 - for entity in entities_to_migrate: - new_entity_id = self._construct_new_entity_id( - entity, use_circuit_numbers, use_device_prefix, sanitized_device_name - ) - - if new_entity_id and new_entity_id != entity.entity_id: - _LOGGER.info( - "Entity migration: %s -> %s", - entity.entity_id, - new_entity_id, - ) - registry.async_update_entity(entity.entity_id, new_entity_id=new_entity_id) - migrated_count += 1 - else: - _LOGGER.debug("Skipping entity (no change needed): %s", entity.entity_id) - - _LOGGER.info("Migrated %d entities", migrated_count) - return True - - except Exception as e: - _LOGGER.error("Entity ID migration with flags failed: %s", e) - return False - - def _should_migrate_entity(self, entity: er.RegistryEntry, sanitized_device_name: str) -> bool: - """Determine if an entity should be migrated based on its unique ID pattern. - - Uses process of elimination to identify circuit entities that can be renamed: - - Excludes panel-level entities (DSM state, door state, software version, power/energy, solar, etc.) - - Excludes unmapped circuits (backing data for synthetics) - - Allows circuit entities, switches, and selects to be migrated - - Args: - entity: The entity registry entry - sanitized_device_name: The sanitized device name for prefix checking - - Returns: - True if the entity should be migrated, False otherwise - - """ - unique_id = entity.unique_id - if not unique_id: - return False - - # Parse unique ID to determine entity type - # Pattern: span_{serial}_{circuit_id}_{suffix} or span_{serial}_{suffix} - parts = unique_id.split("_", 2) # Split into max 3 parts - if len(parts) < 3 or parts[0] != "span": - return False - - # Exclude unmapped circuits (they should not be migrated) - if "unmapped_tab" in unique_id: - return False - - # Exclude panel-level entities (they should not be migrated) - if self._is_panel_level_entity(unique_id): - return False - - # At this point, we have entities that are not panel-level or unmapped - # These are circuit entities, switches, and selects that can be migrated - return True - - def _is_panel_level_entity(self, unique_id: str) -> bool: - """Check if the entity is a panel-level entity based on its unique_id pattern. - - Panel-level entities have unique IDs like: - - span_{serial}_{panel_suffix} (no circuit_id) - - Where panel_suffix includes suffixes for: - - Panel status: dsm_state, dsm_grid_state, current_run_config, main_relay_state - - Hardware status: software_version, doorState, eth0Link, wlanLink, wwanLink, panel_status - - Panel power/energy: current_power, feed_through_power, main_meter_*_energy, etc. - - Battery: battery_level, battery_percentage, storage_battery_percentage - - Args: - unique_id: The unique ID to check - - Returns: - True if this is a panel-level entity, False otherwise - - """ - # Panel-level suffixes from various sensor definitions and binary sensors - panel_level_suffixes = { - # Panel data status sensors (from PANEL_DATA_STATUS_SENSORS) - "dsm_state", - "dsm_grid_state", - "current_run_config", - "main_relay_state", - # Hardware status sensors (from STATUS_SENSORS) - "software_version", - # Binary sensor suffixes (from BINARY_SENSORS) - "doorState", - "eth0Link", - "wlanLink", - "wwanLink", - "panel_status", - # Panel power sensors (from PANEL_POWER_SENSORS) - "current_power", # instantGridPowerW - "feed_through_power", # feedthroughPowerW - # Panel energy sensors (from PANEL_ENERGY_SENSORS) - "main_meter_produced_energy", # mainMeterEnergyProducedWh - "main_meter_consumed_energy", # mainMeterEnergyConsumedWh - "main_meter_net_energy", # mainMeterNetEnergyWh - "feed_through_produced_energy", # feedthroughEnergyProducedWh - "feed_through_consumed_energy", # feedthroughEnergyConsumedWh - "feed_through_net_energy", # feedthroughNetEnergyWh - # Battery sensors - "battery_level", - "battery_percentage", - "storage_battery_percentage", - # Solar sensors (from SOLAR_SENSORS) - "solar_current_power", - "solar_produced_energy", - "solar_consumed_energy", - "solar_net_energy", - # Other panel-level sensors can be added here as needed - "panel_status", - } - - # Check if the unique_id ends with any panel-level suffix - # Panel entities have pattern: span_{serial}_{suffix} - # Circuit entities have pattern: span_{serial}_{circuit_id}_{suffix} - return any(unique_id.endswith(f"_{suffix}") for suffix in panel_level_suffixes) - - def _construct_new_entity_id( - self, - entity: er.RegistryEntry, - use_circuit_numbers: bool, - use_device_prefix: bool, - sanitized_device_name: str, - ) -> str | None: - """Construct new entity ID based on entity domain and naming flags. - - Uses the entity's domain (platform) to determine the proper construction method: - - switch domain -> use switch construction (adds "relay" suffix) - - select domain -> use select construction (preserves select suffix) - - sensor domain -> use circuit construction (preserves sensor suffix) - - Args: - entity: The entity registry entry - use_circuit_numbers: Whether to use circuit numbers - use_device_prefix: Whether to include device prefix - sanitized_device_name: The sanitized device name - - Returns: - New entity ID or None if construction fails - - """ - try: - unique_id = entity.unique_id - if not unique_id: - return None - - # Parse unique ID to extract circuit info - # Pattern: span_{serial}_{circuit_id}_{suffix} or span_{serial}_relay_{circuit_id} - parts = unique_id.split("_", 2) - if len(parts) < 3 or parts[0] != "span": - return None - - remaining = parts[2] - - # Route based on entity domain (platform) for proper entity ID construction - if entity.domain == "switch": - # Switch entities: extract circuit_id from pattern like "relay_circuit_id" - if remaining.startswith("relay_"): - circuit_id = remaining[6:] # Remove "relay_" prefix - else: - # Fallback: assume remaining is the circuit_id - circuit_id = remaining - return self._construct_switch_entity_id( - entity.domain, - circuit_id, - use_circuit_numbers, - use_device_prefix, - sanitized_device_name, - entity, - ) - - elif entity.domain == "select": - # Select entities: pattern is span_{serial}_select_{circuit_id} or span_{serial}_{circuit_id}_{select_suffix} - if remaining.startswith("select_"): - # Pattern: span_{serial}_select_{circuit_id} - circuit_id = remaining[7:] # Remove "select_" prefix - suffix = "priority" - return self._construct_circuit_select_entity_id( - entity.domain, - circuit_id, - suffix, - use_circuit_numbers, - use_device_prefix, - sanitized_device_name, - entity, - ) - elif "_" in remaining: - # Pattern: span_{serial}_{circuit_id}_{select_suffix} - circuit_id = remaining.split("_")[0] - suffix = remaining.split("_", 1)[1] - return self._construct_circuit_select_entity_id( - entity.domain, - circuit_id, - suffix, - use_circuit_numbers, - use_device_prefix, - sanitized_device_name, - entity, - ) - else: - # Simple select pattern - treat as circuit select - circuit_id = remaining - suffix = "priority" - return self._construct_circuit_select_entity_id( - entity.domain, - circuit_id, - suffix, - use_circuit_numbers, - use_device_prefix, - sanitized_device_name, - entity, - ) - - else: - # Sensor and other entities: pattern is span_{serial}_{circuit_id}_{suffix} - if "_" in remaining: - circuit_id = remaining.split("_")[0] - suffix = remaining.split("_", 1)[1] - else: - # Simple pattern without suffix - circuit_id = remaining - suffix = "" - - return self._construct_circuit_entity_id( - entity.domain, - circuit_id, - suffix, - use_circuit_numbers, - use_device_prefix, - sanitized_device_name, - entity, - ) - - except Exception as e: - _LOGGER.error("Failed to construct new entity ID for %s: %s", entity.entity_id, e) - return None - - def _construct_circuit_entity_id( - self, - platform: str, - circuit_id: str, - suffix: str, - use_circuit_numbers: bool, - use_device_prefix: bool, - sanitized_device_name: str, - entity: er.RegistryEntry, - ) -> str: - """Construct entity ID for circuit entities. - - Args: - platform: The platform name (sensor, switch, select) - circuit_id: The circuit ID - suffix: The entity suffix - use_circuit_numbers: Whether to use circuit numbers - use_device_prefix: Whether to include device prefix - sanitized_device_name: The sanitized device name - entity: The entity registry entry to get tabs attribute - - Returns: - Constructed entity ID - - """ - parts = [] - - if use_device_prefix: - parts.append(sanitized_device_name) - - if use_circuit_numbers: - # Get actual circuit numbers from coordinator data - tabs = self._get_circuit_tabs_from_coordinator(circuit_id) - if tabs: - if len(tabs) == 2: - # 240V circuit - use both tab numbers - sorted_tabs = sorted(tabs) - parts.append(f"circuit_{sorted_tabs[0]}_{sorted_tabs[1]}") - elif len(tabs) == 1: - # 120V circuit - use single tab number - parts.append(f"circuit_{tabs[0]}") - else: - # Fallback to circuit_id - parts.append(f"circuit_{circuit_id}") - else: - # Fallback to circuit_id - parts.append(f"circuit_{circuit_id}") - else: - # Use circuit friendly name - get from entity state or coordinator - circuit_name = self._get_circuit_friendly_name(entity, circuit_id) - if circuit_name: - parts.append(slugify(circuit_name)) - else: - # Fallback to circuit_id if we can't get the friendly name - parts.append(circuit_id) - - if suffix and not parts[-1].endswith(f"_{suffix}"): - parts.append(suffix) - - return f"{platform}.{'_'.join(parts)}" - - def _construct_circuit_select_entity_id( - self, - platform: str, - circuit_id: str, - suffix: str, - use_circuit_numbers: bool, - use_device_prefix: bool, - sanitized_device_name: str, - entity: er.RegistryEntry, - ) -> str: - """Construct entity ID for circuit-related select entities. - - Args: - platform: The platform name - circuit_id: The circuit ID - suffix: The entity suffix (e.g., "circuit_priority") - use_circuit_numbers: Whether to use circuit numbers - use_device_prefix: Whether to include device prefix - sanitized_device_name: The sanitized device name - entity: The entity registry entry to get tabs attribute - - Returns: - Constructed entity ID - - """ - parts = [] - - if use_device_prefix: - parts.append(sanitized_device_name) - - if use_circuit_numbers: - # Get actual circuit numbers from coordinator data - tabs = self._get_circuit_tabs_from_coordinator(circuit_id) - if tabs: - if len(tabs) == 2: - # 240V circuit - use both tab numbers - sorted_tabs = sorted(tabs) - parts.append(f"circuit_{sorted_tabs[0]}_{sorted_tabs[1]}") - elif len(tabs) == 1: - # 120V circuit - use single tab number - parts.append(f"circuit_{tabs[0]}") - else: - # Fallback to circuit_id - parts.append(f"circuit_{circuit_id}") - else: - # Fallback to circuit_id - parts.append(f"circuit_{circuit_id}") - else: - # Use circuit friendly name - circuit_name = self._get_circuit_friendly_name(entity, circuit_id) - if circuit_name: - parts.append(slugify(circuit_name)) - else: - # Fallback to circuit_id if we can't get the friendly name - parts.append(circuit_id) - - # Add the select type (e.g., "priority") - if suffix and not parts[-1].endswith(f"_{suffix}"): - parts.append(suffix) - - return f"{platform}.{'_'.join(parts)}" - - def _construct_switch_entity_id( - self, - platform: str, - circuit_id: str, - use_circuit_numbers: bool, - use_device_prefix: bool, - sanitized_device_name: str, - entity: er.RegistryEntry, - ) -> str: - """Construct entity ID for switch entities. - - Args: - platform: The platform name - circuit_id: The circuit ID - use_circuit_numbers: Whether to use circuit numbers - use_device_prefix: Whether to include device prefix - sanitized_device_name: The sanitized device name - entity: The entity registry entry to get tabs attribute - - Returns: - Constructed entity ID - - """ - parts = [] - - if use_device_prefix: - parts.append(sanitized_device_name) - - if use_circuit_numbers: - # Get actual circuit numbers from coordinator data - tabs = self._get_circuit_tabs_from_coordinator(circuit_id) - if tabs: - if len(tabs) == 2: - # 240V circuit - use both tab numbers - sorted_tabs = sorted(tabs) - parts.append(f"circuit_{sorted_tabs[0]}_{sorted_tabs[1]}") - elif len(tabs) == 1: - # 120V circuit - use single tab number - parts.append(f"circuit_{tabs[0]}") - else: - # Fallback to circuit_id - parts.append(f"circuit_{circuit_id}") - else: - # Fallback to circuit_id - parts.append(f"circuit_{circuit_id}") - else: - # Use circuit friendly name - circuit_name = self._get_circuit_friendly_name(entity, circuit_id) - if circuit_name: - parts.append(slugify(circuit_name)) - else: - # Fallback to circuit_id if we can't get the friendly name - parts.append(circuit_id) - - parts.append("relay") - - return f"{platform}.{'_'.join(parts)}" - - def _construct_select_entity_id( - self, - platform: str, - select_id: str, - use_circuit_numbers: bool, - use_device_prefix: bool, - sanitized_device_name: str, - ) -> str: - """Construct entity ID for select entities. - - Note: This method is deprecated and no longer used since we route by entity domain. - Kept for compatibility but _construct_circuit_select_entity_id is now used instead. - - Args: - platform: The platform name - select_id: The select ID (e.g., "circuit_id_priority_mode") - use_circuit_numbers: Whether to use circuit numbers - use_device_prefix: Whether to include device prefix - sanitized_device_name: The sanitized device name - - Returns: - Constructed entity ID - - """ - # This is a fallback method - normally we route through _construct_circuit_select_entity_id - parts = [] - - if use_device_prefix: - parts.append(sanitized_device_name) - - # Extract circuit_id and select_type from select_id - if "_" in select_id: - circuit_id = select_id.split("_")[0] - select_type = select_id.split("_", 1)[1] - else: - circuit_id = select_id - select_type = "priority" - - if use_circuit_numbers: - tabs = self._get_circuit_tabs_from_coordinator(circuit_id) - if tabs: - if len(tabs) == 2: - sorted_tabs = sorted(tabs) - parts.append(f"circuit_{sorted_tabs[0]}_{sorted_tabs[1]}") - elif len(tabs) == 1: - parts.append(f"circuit_{tabs[0]}") - else: - parts.append(f"circuit_{circuit_id}") - else: - parts.append(f"circuit_{circuit_id}") - else: - circuit_name = self._get_circuit_name_by_id(circuit_id) - if circuit_name: - parts.append(slugify(circuit_name)) - else: - parts.append(circuit_id) - - if select_type and not parts[-1].endswith(f"_{select_type}"): - parts.append(select_type) - - return f"{platform}.{'_'.join(parts)}" - - def _get_circuit_tabs_from_coordinator(self, circuit_id: str) -> list[int] | None: - """Get circuit tabs from coordinator data. - - Args: - circuit_id: The circuit ID - - Returns: - List of tab numbers or None if not found - - """ - try: - # Find the active config entry ID in hass.data (might be different from stored ID due to reloads) - domain_data = self.hass.data.get(DOMAIN, {}) - if not domain_data: - _LOGGER.warning( - "No %s data found in hass.data - returning None for circuit tabs", DOMAIN - ) - return None - - # Verify the config entry ID exists in the loaded data - if self.config_entry_id not in domain_data: - _LOGGER.warning( - "Config entry ID %s not found in loaded domain data - returning None for circuit tabs", - self.config_entry_id, - ) - available_entries = list(domain_data.keys()) - _LOGGER.debug("Available config entry IDs: %s", available_entries) - return None - - active_config_entry_id = self.config_entry_id - - # Get circuit tabs from coordinator data - coordinator_data = self.hass.data[DOMAIN][active_config_entry_id] - coordinator = coordinator_data[COORDINATOR] - span_panel = coordinator.data - - # Look up circuit in span_panel data - circuit: SpanPanelCircuit | None = span_panel.circuits.get(circuit_id) - if circuit and circuit.tabs: - return circuit.tabs - - return None - - except Exception as e: - _LOGGER.debug("Failed to get circuit tabs for %s: %s", circuit_id, e) - return None - - def _get_circuit_friendly_name(self, entity: er.RegistryEntry, circuit_id: str) -> str | None: - """Get the circuit friendly name from entity state or coordinator data. - - Args: - entity: The entity registry entry - circuit_id: The circuit ID - - Returns: - Circuit friendly name or None if not found - - """ - try: - # Find the active config entry ID in hass.data (might be different from stored ID due to reloads) - domain_data = self.hass.data.get(DOMAIN, {}) - if not domain_data: - _LOGGER.warning( - "No %s data found in hass.data - returning None for circuit name", DOMAIN - ) - return None - - # Verify the config entry ID exists in the loaded data - if self.config_entry_id not in domain_data: - _LOGGER.warning( - "Config entry ID %s not found in loaded domain data - returning None for circuit name", - self.config_entry_id, - ) - available_entries = list(domain_data.keys()) - _LOGGER.debug("Available config entry IDs: %s", available_entries) - return None - - active_config_entry_id = self.config_entry_id - - # Get circuit name from coordinator data - coordinator_data = self.hass.data[DOMAIN][active_config_entry_id] - coordinator = coordinator_data[COORDINATOR] - span_panel = coordinator.data - - # Look up circuit in span_panel data - circuit: SpanPanelCircuit | None = span_panel.circuits.get(circuit_id) - if circuit and circuit.name: - return circuit.name - - return None - - except Exception as e: - _LOGGER.debug("Failed to get circuit friendly name for %s: %s", circuit_id, e) - return None diff --git a/custom_components/span_panel/entity_resolver.py b/custom_components/span_panel/entity_resolver.py new file mode 100644 index 00000000..7cd2032d --- /dev/null +++ b/custom_components/span_panel/entity_resolver.py @@ -0,0 +1,449 @@ +"""Entity resolver functions for Span Panel integration. + +This module contains functions that depend on the coordinator, entity registry, +or config entry options to resolve entity IDs and unique IDs. These are the +"entry-aware" wrappers around the pure ID builders in id_builder.py. +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from homeassistant.helpers import entity_registry as er +from homeassistant.util import slugify +from span_panel_api import SpanCircuitSnapshot, SpanEvseSnapshot, SpanPanelSnapshot + +from .const import DOMAIN, USE_CIRCUIT_NUMBERS, USE_DEVICE_PREFIX +from .id_builder import ( + build_bess_unique_id, + build_binary_sensor_unique_id, + build_circuit_unique_id, + build_evse_unique_id, + build_panel_unique_id, + build_select_unique_id, + build_switch_unique_id, + construct_synthetic_unique_id, +) +from .util import snapshot_to_device_info + +if TYPE_CHECKING: + from .coordinator import SpanPanelCoordinator + +_LOGGER = logging.getLogger(__name__) + + +def resolve_evse_display_suffix( + evse: SpanEvseSnapshot, + snapshot: SpanPanelSnapshot, + use_circuit_numbers: bool, +) -> str | None: + """Resolve the display suffix for an EVSE device name. + + Friendly names mode: returns the fed circuit's panel name (e.g., "Garage"). + Circuit numbers mode: returns the EVSE serial number (e.g., "SN-EVSE-001"). + Returns None when no meaningful suffix is available (prevents empty parens). + """ + if use_circuit_numbers: + serial: str | None = evse.serial_number + return serial + fed_circuit = snapshot.circuits.get(evse.feed_circuit_id) + if fed_circuit and fed_circuit.name: + name: str = fed_circuit.name + return name + return None + + +def _get_device_identifier_for_unique_ids( + coordinator: SpanPanelCoordinator, + snapshot: SpanPanelSnapshot, + device_name: str | None = None, +) -> str: + """Return the panel serial used as the device segment in unique_ids.""" + serial: str = snapshot.serial_number + return serial + + +def construct_panel_unique_id_for_entry( + coordinator: SpanPanelCoordinator, + snapshot: SpanPanelSnapshot, + description_key: str, + device_name: str | None = None, +) -> str: + """Build panel unique_id using the panel serial from the snapshot.""" + identifier = _get_device_identifier_for_unique_ids(coordinator, snapshot, device_name) + return build_panel_unique_id(identifier, description_key) + + +def construct_circuit_unique_id_for_entry( + coordinator: SpanPanelCoordinator, + snapshot: SpanPanelSnapshot, + circuit_id: str, + description_key: str, + device_name: str | None = None, +) -> str: + """Build circuit unique_id using the panel serial from the snapshot.""" + identifier = _get_device_identifier_for_unique_ids(coordinator, snapshot, device_name) + return build_circuit_unique_id(identifier, circuit_id, description_key) + + +def build_switch_unique_id_for_entry( + coordinator: SpanPanelCoordinator, + snapshot: SpanPanelSnapshot, + circuit_id: str, + device_name: str | None = None, +) -> str: + """Build switch unique_id using the panel serial from the snapshot.""" + identifier = _get_device_identifier_for_unique_ids(coordinator, snapshot, device_name) + return build_switch_unique_id(identifier, circuit_id) + + +def build_select_unique_id_for_entry( + coordinator: SpanPanelCoordinator, + snapshot: SpanPanelSnapshot, + select_id: str, + device_name: str | None = None, +) -> str: + """Build select unique_id using the panel serial from the snapshot.""" + identifier = _get_device_identifier_for_unique_ids(coordinator, snapshot, device_name) + return build_select_unique_id(identifier, select_id) + + +def build_binary_sensor_unique_id_for_entry( + coordinator: SpanPanelCoordinator, + snapshot: SpanPanelSnapshot, + description_key: str, + device_name: str | None = None, +) -> str: + """Build binary_sensor unique_id using the panel serial from the snapshot.""" + identifier = _get_device_identifier_for_unique_ids(coordinator, snapshot, device_name) + return build_binary_sensor_unique_id(identifier, description_key) + + +def construct_synthetic_unique_id_for_entry( + coordinator: SpanPanelCoordinator, + snapshot: SpanPanelSnapshot, + sensor_name: str, + device_name: str | None = None, +) -> str: + """Build synthetic sensor unique_id using the panel serial from the snapshot.""" + identifier = _get_device_identifier_for_unique_ids(coordinator, snapshot, device_name) + return construct_synthetic_unique_id(identifier, sensor_name) + + +def build_evse_unique_id_for_entry( + coordinator: SpanPanelCoordinator, + snapshot: SpanPanelSnapshot, + evse_id: str, + description_key: str, + device_name: str | None = None, +) -> str: + """Build EVSE unique_id using the panel serial from the snapshot.""" + identifier = _get_device_identifier_for_unique_ids(coordinator, snapshot, device_name) + return build_evse_unique_id(identifier, evse_id, description_key) + + +def build_bess_unique_id_for_entry( + coordinator: SpanPanelCoordinator, + snapshot: SpanPanelSnapshot, + description_key: str, + device_name: str | None = None, +) -> str: + """Build BESS unique_id using the panel serial from the snapshot.""" + identifier = _get_device_identifier_for_unique_ids(coordinator, snapshot, device_name) + return build_bess_unique_id(identifier, description_key) + + +def get_device_identifier_for_entry( + coordinator: SpanPanelCoordinator, + snapshot: SpanPanelSnapshot, + device_name: str | None = None, +) -> str: + """Public helper to get the per-entry device identifier used in unique_ids and storage.""" + return _get_device_identifier_for_unique_ids(coordinator, snapshot, device_name) + + +def construct_multi_circuit_entity_id( + coordinator: SpanPanelCoordinator, + snapshot: SpanPanelSnapshot, + platform: str, + suffix: str, + circuit_numbers: list[int], + friendly_name: str | None = None, + unique_id: str | None = None, +) -> str | None: + """Construct entity ID for multi-circuit sensors (like solar inverters). + + Args: + coordinator: The coordinator instance + snapshot: The panel snapshot data + platform: Platform name ("sensor", "switch", "select") + suffix: Entity-specific suffix ("power", "energy_produced", etc.) + circuit_numbers: List of circuit numbers this sensor combines + friendly_name: Descriptive name for this sensor (required if unique_id is None) + unique_id: The unique ID for this entity (None to skip registry lookup) + + Returns: + Constructed entity ID string or None if device info unavailable + + """ + # Check registry first only if unique_id is provided + if unique_id is not None: + entity_registry = er.async_get(coordinator.hass) + existing_entity_id = entity_registry.async_get_entity_id(platform, DOMAIN, unique_id) + + _LOGGER.debug( + "Multi-circuit helper registry lookup (switches/selects) - unique_id=%s, found_entity_id=%s", + unique_id, + existing_entity_id, + ) + + if existing_entity_id: + return existing_entity_id + # During migration, unique_id lookup should always succeed + raise ValueError( + f"Registry lookup failed for unique_id '{unique_id}' during migration. Entity should exist in registry." + ) + _LOGGER.debug( + "Multi-circuit helper (switches/selects) - no unique_id provided, skipping registry lookup" + ) + + # Get device name from config entry data + device_name = coordinator.config_entry.data.get("device_name", coordinator.config_entry.title) + if not device_name: + return None + + use_circuit_numbers = coordinator.config_entry.options.get(USE_CIRCUIT_NUMBERS, False) + + # If no unique_id provided, friendly_name is required when not using circuit numbers + if unique_id is None and not use_circuit_numbers and not friendly_name: + _LOGGER.error( + "Friendly_name is required when unique_id is None and not using circuit numbers for multi-circuit entity" + ) + return None + + if use_circuit_numbers: + # Use circuit number pattern: sensor.span_panel_circuit_30_32_power + if circuit_numbers: + sorted_circuits = sorted([num for num in circuit_numbers if num > 0]) + else: + sorted_circuits = [] + if sorted_circuits: + if len(sorted_circuits) == 1: + circuit_part = f"circuit_{sorted_circuits[0]}" + else: + circuit_list = "_".join(str(num) for num in sorted_circuits) + circuit_part = f"circuit_{circuit_list}" + else: + raise ValueError( + f"Circuit-based naming is enabled but no valid circuit numbers provided. " + f"Got circuit_numbers={circuit_numbers}. Multi-circuit entities require valid circuit numbers when USE_CIRCUIT_NUMBERS is True." + ) + else: + # Use friendly name pattern: sensor.span_panel_solar_inverter_power + circuit_part = slugify(friendly_name) + + # Build the entity ID. + # `False` default preserves legacy installs (no device prefix). Single-circuit + # entities default to True — see construct_single_circuit_entity_id below — + # because they never had a prefix-less form historically; multi-circuit + # synthetics did. + use_device_prefix = coordinator.config_entry.options.get(USE_DEVICE_PREFIX, False) + parts = [] + + if use_device_prefix: + if device_name: + # Sanitize device name for entity ID use + sanitized_device_name = slugify(device_name) + parts.append(sanitized_device_name) + + parts.append(circuit_part) + + # Add suffix if not already in circuit_part + if suffix and not circuit_part.endswith(f"_{suffix}"): + parts.append(suffix) + + return f"{platform}.{'_'.join(parts)}" + + +def construct_single_circuit_entity_id( + coordinator: SpanPanelCoordinator, + snapshot: SpanPanelSnapshot, + platform: str, + suffix: str, + circuit_data: SpanCircuitSnapshot, + unique_id: str | None = None, + device_name: str | None = None, +) -> str | None: + """Construct entity ID for single-circuit sensors. + + Args: + coordinator: The coordinator instance + snapshot: The panel snapshot data + platform: Platform name ("sensor", "switch", "select") + suffix: Entity-specific suffix ("power", "energy_produced", etc.) + circuit_data: Circuit data object + unique_id: The unique ID for this entity (None to skip registry lookup) + device_name: Device name for entity ID construction (None to use from config entry) + + Returns: + Constructed entity ID string or None if device info unavailable + + """ + # Check registry first only if unique_id is provided + if unique_id is not None: + entity_registry = er.async_get(coordinator.hass) + existing_entity_id = entity_registry.async_get_entity_id(platform, DOMAIN, unique_id) + + _LOGGER.debug( + "Circuit helper registry lookup - unique_id=%s, found_entity_id=%s", + unique_id, + existing_entity_id, + ) + + if existing_entity_id: + return existing_entity_id + # FATAL ERROR: Expected unique_id not found in registry + raise ValueError( + f"REGISTRY LOOKUP ERROR: Expected unique_id '{unique_id}' not found in registry. " + f"This indicates a migration or configuration mismatch." + ) + _LOGGER.debug("Circuit helper - no unique_id provided, skipping registry lookup") + + # Get device info + device_info = snapshot_to_device_info(snapshot, device_name) + if not device_info or not device_info.get("name"): + return None + + use_circuit_numbers = coordinator.config_entry.options.get(USE_CIRCUIT_NUMBERS, False) + + if use_circuit_numbers: + # Check if this is a 240V circuit (2 tabs) or 120V circuit (1 tab) + if circuit_data.tabs and len(circuit_data.tabs) == 2: + # 240V circuit - use both tab numbers + sorted_tabs = sorted(circuit_data.tabs) + circuit_part = f"circuit_{sorted_tabs[0]}_{sorted_tabs[1]}" + elif circuit_data.tabs and len(circuit_data.tabs) == 1: + # 120V circuit - use single tab number + circuit_part = f"circuit_{circuit_data.tabs[0]}" + else: + # No tabs available — use the API circuit_id as fallback + circuit_part = ( + f"circuit_{circuit_data.circuit_id}" + if circuit_data.circuit_id + else "circuit_unknown" + ) + # Use friendly name pattern: sensor.span_panel_solar_east_power + elif circuit_data.name: + circuit_part = slugify(circuit_data.name) + else: + circuit_part = "single_circuit" + + # Build the entity ID (only for non-voltage-specific cases) + use_device_prefix = coordinator.config_entry.options.get(USE_DEVICE_PREFIX, True) + parts = [] + + if use_device_prefix: + device_name = device_info.get("name") + if device_name: + # Sanitize device name for entity ID use + sanitized_device_name = slugify(device_name) + parts.append(sanitized_device_name) + + parts.append(circuit_part) + + # Add suffix if not already in circuit_part + if suffix and not circuit_part.endswith(f"_{suffix}"): + parts.append(suffix) + + return f"{platform}.{'_'.join(parts)}" + + +def construct_unmapped_entity_id( + snapshot: SpanPanelSnapshot, + circuit_id: str, + suffix: str, + device_name: str | None = None, +) -> str: + """Construct entity ID for unmapped tab with consistent modern naming. + + Args: + snapshot: The panel snapshot data + circuit_id: Circuit ID (e.g., "unmapped_tab_32") + suffix: Sensor suffix (e.g., "power", "energy_produced") + device_name: The device name to use for entity ID construction + + Returns: + Entity ID string like "sensor.span_panel_unmapped_tab_32_power" + + """ + # Always use device prefix for unmapped entities + # circuit_id is "unmapped_tab_32", add device prefix and suffix to create + # "sensor.span_panel_unmapped_tab_32_power" + device_info = snapshot_to_device_info(snapshot, device_name) + device_name_raw = device_info.get("name") + _LOGGER.debug( + "construct_unmapped_entity_id: circuit_id=%s, suffix=%s, device_name_raw=%s", + circuit_id, + suffix, + device_name_raw, + ) + if device_name_raw: + # Sanitize device name for entity ID use + sanitized_device_name = slugify(device_name_raw) + result = f"sensor.{sanitized_device_name}_{circuit_id}_{suffix}" + _LOGGER.debug("construct_unmapped_entity_id result with device: %s", result) + return result + result = f"sensor.{circuit_id}_{suffix}" + _LOGGER.debug("construct_unmapped_entity_id result without device: %s", result) + return result + + +def get_unmapped_circuit_entity_id( + snapshot: SpanPanelSnapshot, + tab_number: int, + suffix: str, + device_name: str | None = None, +) -> str | None: + """Get entity ID for an unmapped circuit based on tab number. + + This helper function constructs the entity ID for native unmapped circuit sensors + that should already exist in Home Assistant. It's useful for synthetic sensors + that need to reference these native entities in formulas. + + Args: + snapshot: The panel snapshot data + tab_number: The tab number (e.g., 30, 32) + suffix: The sensor suffix (e.g., "power", "energy_produced", "energy_consumed") + device_name: The device name to use for entity ID construction + + Returns: + Entity ID string like "sensor.span_panel_unmapped_tab_30_power" + or None if the circuit doesn't exist + + Examples: + get_unmapped_circuit_entity_id(snapshot, 30, "power") + # Returns: "sensor.span_panel_unmapped_tab_30_power" + + get_unmapped_circuit_entity_id(snapshot, 32, "energy_produced") + # Returns: "sensor.span_panel_unmapped_tab_32_energy_produced" + + """ + circuit_id = f"unmapped_tab_{tab_number}" + + # Verify the circuit exists in the panel data + if circuit_id not in snapshot.circuits: + _LOGGER.debug("Unmapped circuit %s not found in circuits list", circuit_id) + return None + + result_entity_id = construct_unmapped_entity_id(snapshot, circuit_id, suffix, device_name) + _LOGGER.debug("Generated unmapped entity ID: %s", result_entity_id) + return result_entity_id + + +def construct_unmapped_friendly_name( + circuit_number: int | str, sensor_description_name: str +) -> str: + """Construct friendly name for unmapped circuit sensors.""" + # Format: "Unmapped Tab 32 Consumed Energy" + return f"Unmapped Tab {circuit_number} {sensor_description_name}" diff --git a/custom_components/span_panel/entity_summary.py b/custom_components/span_panel/entity_summary.py deleted file mode 100644 index 26e22f96..00000000 --- a/custom_components/span_panel/entity_summary.py +++ /dev/null @@ -1,135 +0,0 @@ -"""Entity summary logging for Span Panel integration.""" - -from __future__ import annotations - -import logging - -from homeassistant.config_entries import ConfigEntry - -from .coordinator import SpanPanelCoordinator -from .options import BATTERY_ENABLE, INVERTER_ENABLE -from .sensor_definitions import ( - PANEL_DATA_STATUS_SENSORS, - STATUS_SENSORS, - UNMAPPED_SENSORS, -) - -_LOGGER = logging.getLogger(__name__) - - -def log_entity_summary(coordinator: SpanPanelCoordinator, config_entry: ConfigEntry) -> None: - """Log a comprehensive summary of entities that will be created. - - Uses debug level for detailed info, info level for basic summary. - - Args: - coordinator: The SpanPanelCoordinator instance - config_entry: The config entry with options - - """ - # Check if any logging is enabled for the main span_panel module - main_logger = logging.getLogger("custom_components.span_panel") - use_debug_level = main_logger.isEnabledFor(logging.DEBUG) - use_info_level = main_logger.isEnabledFor(logging.INFO) - - if not (use_debug_level or use_info_level): - return - - span_panel_data = coordinator.data - total_circuits = len(span_panel_data.circuits) - - # Count controllable circuits and identify non-controllable ones - controllable_circuits = sum( - 1 for circuit in span_panel_data.circuits.values() if circuit.is_user_controllable - ) - non_controllable_circuits = total_circuits - controllable_circuits - - # Identify non-controllable circuits for debugging - non_controllable_circuit_names = [ - f"{circuit.name} (ID: {circuit.circuit_id})" - for circuit in span_panel_data.circuits.values() - if not circuit.is_user_controllable - ] - - solar_enabled = config_entry.options.get(INVERTER_ENABLE, False) - battery_enabled = config_entry.options.get(BATTERY_ENABLE, False) - - # Native sensors only - synthetic sensors now handled by template system - unmapped_sensors = total_circuits * len(UNMAPPED_SENSORS) # Invisible backing sensors - panel_status_sensors = len(PANEL_DATA_STATUS_SENSORS) # Panel status only - status_sensors = len(STATUS_SENSORS) # Hardware status - - # Native battery sensor (conditionally created) - native_battery_sensors = 1 if battery_enabled else 0 # Battery level (native sensor) - - # Synthetic sensors (now handled by template system - counts are estimates) - synthetic_circuit_sensors = ( - total_circuits * 3 if total_circuits > 0 else 0 - ) # Power, Produced, Consumed per circuit - synthetic_panel_sensors = 6 # Panel power sensors (current power, feedthrough, energy sensors) - synthetic_solar_sensors = 3 if solar_enabled else 0 # Power, Produced Energy, Consumed Energy - - total_native_sensors = ( - unmapped_sensors + panel_status_sensors + status_sensors + native_battery_sensors - ) - total_synthetic_sensors = ( - synthetic_circuit_sensors + synthetic_panel_sensors + synthetic_solar_sensors - ) - total_sensors = total_native_sensors + total_synthetic_sensors - total_switches = controllable_circuits # Only controllable circuits get switches - total_selects = controllable_circuits # Only controllable circuits get selects - - # Choose logging level based on what's enabled - log_func = main_logger.debug if use_debug_level else main_logger.info - - log_func("=== SPAN PANEL ENTITY SUMMARY ===") - log_func( - "Total circuits: %d (%d controllable, %d non-controllable)", - total_circuits, - controllable_circuits, - non_controllable_circuits, - ) - - # Show non-controllable circuit info at both info and debug levels - if non_controllable_circuit_names: - # Force info level to ensure this shows up - main_logger.info( - "Non-controllable circuits: %s", - ", ".join(non_controllable_circuit_names), - ) - else: - main_logger.info("Non-controllable circuits: None") - - log_func("=== NATIVE SENSORS ===") - log_func( - "Unmapped circuit sensors: %d (%d circuits x %d sensors per circuit) - invisible backing data", - unmapped_sensors, - total_circuits, - len(UNMAPPED_SENSORS), - ) - log_func("Panel status sensors: %d", panel_status_sensors) - log_func("Hardware status sensors: %d", status_sensors) - if battery_enabled: - log_func("Battery sensors: %d (native sensor)", native_battery_sensors) - else: - log_func("Battery sensors: 0 (battery disabled)") - log_func("Total native sensors: %d", total_native_sensors) - - log_func("=== SYNTHETIC SENSORS (Template-based) ===") - log_func("Circuit synthetic sensors: %d", synthetic_circuit_sensors) - log_func("Panel synthetic sensors: %d", synthetic_panel_sensors) - if solar_enabled: - log_func("Solar synthetic sensors: %d", synthetic_solar_sensors) - else: - log_func("Solar synthetic sensors: 0 (solar disabled)") - log_func("Total synthetic sensors: %d", total_synthetic_sensors) - log_func("Circuit switches: %d (controllable circuits only)", total_switches) - log_func("Circuit selects: %d (controllable circuits only)", total_selects) - log_func( - "Total entities: %d sensors + %d switches + %d selects = %d", - total_sensors, - total_switches, - total_selects, - total_sensors + total_switches + total_selects, - ) - log_func("=== END ENTITY SUMMARY ===") diff --git a/custom_components/span_panel/exceptions.py b/custom_components/span_panel/exceptions.py index 08264928..5377110f 100644 --- a/custom_components/span_panel/exceptions.py +++ b/custom_components/span_panel/exceptions.py @@ -1,9 +1 @@ """Exceptions for Span Panel integration.""" - - -class SpanPanelReturnedEmptyData(Exception): - """Exception raised when the Span Panel API returns empty or missing data.""" - - -class SpanPanelSimulationOfflineError(Exception): - """Exception raised when the panel is intentionally offline in simulation mode.""" diff --git a/custom_components/span_panel/frontend.py b/custom_components/span_panel/frontend.py new file mode 100644 index 00000000..14d669db --- /dev/null +++ b/custom_components/span_panel/frontend.py @@ -0,0 +1,266 @@ +"""Frontend registration helpers for the Span Panel integration. + +Handles sidebar panel registration, Lovelace card JS resource management, +static path serving, and panel settings storage. +""" + +from __future__ import annotations + +import asyncio +import hashlib +import logging +import os +from typing import Any, Literal + +from homeassistant.components.http import StaticPathConfig +from homeassistant.core import HomeAssistant +from homeassistant.helpers.storage import Store + +from .const import PANEL_ADMIN_ONLY, PANEL_SHOW_SIDEBAR + +_LOGGER = logging.getLogger(__name__) + +PANEL_URL = "/span_panel_frontend" +PANEL_FRONTEND_DIR = os.path.join(os.path.dirname(__file__), "frontend", "dist") +CARD_FILENAME = "span-panel-card.js" + +_PANEL_SETTINGS_STORAGE_VERSION = 1 +_PANEL_SETTINGS_STORAGE_KEY = "span_panel_settings" + + +def _frontend_file_hash(filename: str) -> str: + """Compute a short hash of a frontend dist file for cache busting. + + This is a synchronous helper—call via ``await hass.async_add_executor_job`` + to avoid blocking the event loop. + """ + path = os.path.join(PANEL_FRONTEND_DIR, filename) + try: + with open(path, "rb") as fh: # noqa: ASYNC230 + return hashlib.md5(fh.read(), usedforsecurity=False).hexdigest()[:8] + except FileNotFoundError: + return "0" + + +async def async_load_panel_settings(hass: HomeAssistant) -> dict[str, Any]: + """Load domain-level panel settings from storage.""" + store: Store[dict[str, Any]] = Store( + hass, _PANEL_SETTINGS_STORAGE_VERSION, _PANEL_SETTINGS_STORAGE_KEY + ) + data = await store.async_load() + return data or {} + + +async def async_save_panel_settings(hass: HomeAssistant, settings: dict[str, Any]) -> None: + """Save domain-level panel settings to storage.""" + store: Store[dict[str, Any]] = Store( + hass, _PANEL_SETTINGS_STORAGE_VERSION, _PANEL_SETTINGS_STORAGE_KEY + ) + await store.async_save(settings) + + +FavoriteKind = Literal["circuits", "sub_devices"] + +_FAVORITE_KINDS: tuple[FavoriteKind, ...] = ("circuits", "sub_devices") + + +def _empty_panel_favorites() -> dict[str, list[str]]: + return {kind: [] for kind in _FAVORITE_KINDS} + + +def _normalize_favorites_blob(raw: Any) -> dict[str, dict[str, list[str]]]: + """Normalize raw stored favorites into the canonical nested shape. + + Tolerates the legacy ``{panel_id: [uuid, ...]}`` shape (read transparently + as circuits-only). Logs a warning on dropped/malformed entries so storage + corruption is visible during debugging. + """ + result: dict[str, dict[str, list[str]]] = {} + if not isinstance(raw, dict): + return result + for panel_id, value in raw.items(): + if not isinstance(panel_id, str): + _LOGGER.warning( + "span_panel_settings: dropping favorites entry with non-string panel id %r", + panel_id, + ) + continue + # Legacy shape: panel_id maps to a flat list of circuit uuids. + if isinstance(value, list): + circuits = [u for u in value if isinstance(u, str) and u] + if circuits: + result[panel_id] = {"circuits": circuits, "sub_devices": []} + continue + if not isinstance(value, dict): + _LOGGER.warning( + "span_panel_settings: dropping favorites entry %s with unsupported value type %s", + panel_id, + type(value).__name__, + ) + continue + circuits_raw = value.get("circuits", []) + sub_devices_raw = value.get("sub_devices", []) + circuits = ( + [u for u in circuits_raw if isinstance(u, str) and u] + if isinstance(circuits_raw, list) + else [] + ) + sub_devices = ( + [u for u in sub_devices_raw if isinstance(u, str) and u] + if isinstance(sub_devices_raw, list) + else [] + ) + if circuits or sub_devices: + result[panel_id] = {"circuits": circuits, "sub_devices": sub_devices} + return result + + +# Module-level lock serializing favorites read-then-write so concurrent +# add/remove calls don't clobber each other. HA's Store serializes writes +# but not the surrounding load → mutate → save sequence. +_FAVORITES_LOCK = asyncio.Lock() + + +async def async_get_favorites( + hass: HomeAssistant, +) -> dict[str, dict[str, list[str]]]: + """Return the stored favorites map. + + Shape: ``{panel_device_id: {"circuits": [uuid, ...], "sub_devices": [devid, ...]}}``. + + Empty/missing storage returns an empty dict. Old single-list per-panel + shape ``{panel_id: [uuid, ...]}`` is read transparently as circuits-only + so favorites that were stored before sub-device support are preserved. + """ + settings = await async_load_panel_settings(hass) + return _normalize_favorites_blob(settings.get("favorites")) + + +async def async_set_favorite( + hass: HomeAssistant, + panel_device_id: str, + kind: FavoriteKind, + target_id: str, + favorited: bool, +) -> dict[str, dict[str, list[str]]]: + """Add or remove a circuit or sub-device from the favorites map. + + ``kind`` is either ``"circuits"`` or ``"sub_devices"``. ``target_id`` is + the circuit uuid or sub-device HA device id, respectively. Deduplicates + on add; drops empty lists and empty panel entries on remove. Returns + the updated full favorites map. + + Holds a module-level lock around load → mutate → save so concurrent + callers can't race; also persists the normalized shape so legacy data + is migrated in place on the first write touching it. + """ + if kind not in _FAVORITE_KINDS: + raise ValueError(f"Unknown favorite kind: {kind!r}") + + async with _FAVORITES_LOCK: + settings = await async_load_panel_settings(hass) + favorites = _normalize_favorites_blob(settings.get("favorites")) + + panel_entry = favorites.get(panel_device_id) or _empty_panel_favorites() + current = list(panel_entry.get(kind, [])) + if favorited: + if target_id not in current: + current.append(target_id) + else: + if target_id in current: + current.remove(target_id) + + panel_entry[kind] = current + if any(panel_entry[k] for k in _FAVORITE_KINDS): + favorites[panel_device_id] = panel_entry + else: + favorites.pop(panel_device_id, None) + + settings["favorites"] = favorites + await async_save_panel_settings(hass, settings) + return favorites + + +async def _async_ensure_lovelace_resource(hass: HomeAssistant, url: str) -> None: + """Ensure the card JS is registered as a Lovelace resource. + + Reads the lovelace_resources storage directly and adds an entry for our + card URL if one isn't already present. This avoids the MIME-type issues + that occur when serving from /local/ in dev mode. + """ + store: Store[dict[str, list[dict[str, str]]]] = Store(hass, 1, "lovelace_resources") + data = await store.async_load() + if data is None: + data = {"items": []} + + items: list[dict[str, str]] = data.get("items", []) + + # Strip query strings when comparing so a cache-bust bump doesn't duplicate + base_url = url.split("?")[0] + for item in items: + if item.get("url", "").split("?")[0] == base_url: + # Update the URL in case the cache tag changed + if item["url"] != url: + item["url"] = url + await store.async_save(data) + return + + items.append( + { + "id": hashlib.md5(base_url.encode(), usedforsecurity=False).hexdigest(), + "url": url, + "type": "module", + } + ) + data["items"] = items + await store.async_save(data) + + +async def async_apply_panel_registration(hass: HomeAssistant) -> None: + """Register or remove the sidebar panel based on stored settings. + + Uses deferred imports from the parent package so that test patches + applied to ``custom_components.span_panel.*`` take effect. + """ + # Deferred imports: tests patch these names on the package namespace + # (custom_components.span_panel.X). Resolving through ``from .`` at + # call time ensures patched values are picked up. + from . import ( # pylint: disable=import-outside-toplevel + _async_ensure_lovelace_resource as _ensure_lovelace, + async_load_panel_settings as _load_settings, + async_register_panel as _register_panel, + async_remove_panel as _remove_panel, + ) + + settings = await _load_settings(hass) + show = settings.get(PANEL_SHOW_SIDEBAR, True) + admin_only = settings.get(PANEL_ADMIN_ONLY, False) + + # Always register static paths so the card JS is reachable even when + # the sidebar panel is hidden. + await hass.http.async_register_static_paths( + [StaticPathConfig(PANEL_URL, PANEL_FRONTEND_DIR, cache_headers=True)] + ) + + # Auto-register the Lovelace card as a resource so users don't need a + # manual entry (also avoids MIME-type issues with /local/ in dev mode). + card_cache_tag = await hass.async_add_executor_job(_frontend_file_hash, CARD_FILENAME) + card_url = f"{PANEL_URL}/{CARD_FILENAME}?v={card_cache_tag}" + await _ensure_lovelace(hass, card_url) + + if show: + # Remove first to allow re-registration with updated require_admin + _remove_panel(hass, "span-panel", warn_if_unknown=False) + cache_tag = await hass.async_add_executor_job(_frontend_file_hash, "span-panel.js") + await _register_panel( + hass, + webcomponent_name="span-panel", + frontend_url_path="span-panel", + sidebar_title="Span Panel", + sidebar_icon="mdi:lightning-bolt", + module_url=f"{PANEL_URL}/span-panel.js?v={cache_tag}", + require_admin=admin_only, + config={}, + ) + else: + _remove_panel(hass, "span-panel", warn_if_unknown=False) diff --git a/custom_components/span_panel/frontend/dist/span-panel-card.js b/custom_components/span_panel/frontend/dist/span-panel-card.js new file mode 100644 index 00000000..b5d260dc --- /dev/null +++ b/custom_components/span_panel/frontend/dist/span-panel-card.js @@ -0,0 +1,230 @@ +let t="en";const e={en:{"tab.panel":"Panel","tab.by_panel":"By Panel","tab.by_activity":"By Activity","tab.by_area":"By Area","tab.monitoring":"Monitoring","tab.settings":"Settings","list.search_placeholder":"Search circuits...","list.unassigned_area":"Unassigned","list.no_results":"No circuits found","monitoring.heading":"Monitoring","monitoring.global_settings":"Global Settings","monitoring.enabled":"Enabled","monitoring.continuous":"Continuous (%)","monitoring.spike":"Spike (%)","monitoring.window":"Window (min)","monitoring.cooldown":"Cooldown (min)","monitoring.monitored_points":"Monitored Points","monitoring.col.name":"Name","monitoring.col.continuous":"Continuous","monitoring.col.spike":"Spike","monitoring.col.window":"Window","monitoring.col.cooldown":"Cooldown","monitoring.all_none":"All / None","monitoring.reset":"Reset","notification.heading":"Notification Settings","notification.targets":"Notify Targets","notification.none_selected":"None selected","notification.no_targets":"No notify targets found","notification.all_targets":"All","notification.event_bus_target":"Event Bus (HA event bus)","notification.priority":"Priority","notification.priority.default":"Default","notification.priority.passive":"Passive","notification.priority.active":"Active","notification.priority.time_sensitive":"Time-sensitive","notification.priority.critical":"Critical","notification.hint.critical":"Overrides silent/DND","notification.hint.time_sensitive":"Breaks through Focus","notification.hint.passive":"Delivers silently","notification.hint.active":"Standard delivery","notification.title_template":"Title Template","notification.message_template":"Message Template","notification.placeholders":"Placeholders:","notification.event_bus_help":"Event Bus fires event type","notification.event_bus_payload":"with payload:","notification.test_label":"Test Notification","notification.test_button":"Send Test","notification.test_sending":"Sending...","notification.test_sent":"Test notification sent","error.prefix":"Error:","error.failed_save":"Failed to save","error.failed":"Failed","error.panel_offline":"SPAN Panel unreachable","error.panel_reconnected":"SPAN Panel reconnected","error.panel_offline_named":"{name} unreachable","error.panel_reconnected_named":"{name} reconnected","error.discovery_failed":"Unable to connect to SPAN Panel","error.relay_failed":"Unable to toggle relay","error.shedding_failed":"Unable to update shedding priority","error.threshold_failed":"Unable to save threshold","error.graph_horizon_failed":"Unable to update graph time horizon","error.favorites_fetch_failed":"Unable to load favorites","error.favorites_toggle_failed":"Unable to update favorite","error.history_failed":"Unable to load historical data","error.monitoring_failed":"Unable to load monitoring status","error.graph_settings_failed":"Unable to load graph settings","error.areas_failed":"Area assignments may be out of sync","error.retry":"Retry","card.connecting":"Connecting to SPAN Panel...","settings.heading":"Settings","settings.description":"General integration settings (entity naming, device prefix, circuit numbers) are managed through the integration's options flow.","settings.open_link":"Open SPAN Panel Integration Settings","horizon.5m":"5 Minutes","horizon.1h":"1 Hour","horizon.1d":"1 Day","horizon.1w":"1 Week","horizon.1M":"1 Month","settings.graph_horizon_heading":"Graph Time Horizon","settings.graph_horizon_description":"Default time window for all circuit graphs. Individual circuits can override this in their settings panel.","settings.global_default":"Global Default","settings.default_scale":"Default Scale","settings.circuit_graph_scales":"Circuit Graph Scales","settings.col.circuit":"Circuit","settings.col.scale":"Scale","sidepanel.graph_horizon":"Graph Time Horizon","header.default_name":"SPAN Panel","header.monitoring_settings":"Panel monitoring settings","header.graph_settings":"Graph time horizon settings","header.site":"Site","header.grid":"Grid","header.upstream":"Upstream","header.downstream":"Downstream","header.solar":"Solar","header.battery":"Battery","header.toggle_units":"Toggle Watts / Amps","header.enable_switches":"Enable Switches","header.switches_enabled":"Switches Enabled","grid.unknown":"Unknown","grid.configure":"Configure circuit","grid.configure_subdevice":"Configure device","grid.on":"On","grid.off":"Off","subdevice.ev_charger":"EV Charger","subdevice.battery":"Battery","subdevice.fallback":"Sub-device","subdevice.soc":"SoC","subdevice.soe":"SoE","subdevice.power":"Power","sidepanel.graph_settings":"Graph Settings","sidepanel.global_defaults":"Global defaults for all circuits","sidepanel.favorites_subtitle":"Favorites","sidepanel.global_default":"Global Default","sidepanel.list_view_columns":"List View Columns","sidepanel.columns":"Columns","sidepanel.circuit_scales":"Circuit Graph Scales","sidepanel.subdevice_scales":"Sub-Device Graph Scales","sidepanel.reset_to_global":"Reset to global default","sidepanel.relay":"Relay","sidepanel.breaker":"Breaker","sidepanel.shedding_priority":"Shedding Priority","sidepanel.priority_label":"Priority","sidepanel.monitoring":"Monitoring","sidepanel.global":"Global","sidepanel.custom":"Custom","sidepanel.continuous_pct":"Continuous %","sidepanel.spike_pct":"Spike %","sidepanel.window_duration":"Window duration","sidepanel.cooldown":"Cooldown","sidepanel.favorite":"Favorite","sidepanel.save_to_favorites":"Save to favorites","panel.favorites":"Favorites","status.monitoring":"Monitoring","status.circuits":"circuits","status.mains":"mains","status.warning":"warning","status.warnings":"warnings","status.alert":"alert","status.alerts":"alerts","status.override":"override","status.overrides":"overrides","card.no_device":"Open the card editor and select your SPAN Panel device.","card.device_not_found":"Panel device not found. Check device_id in card config.","card.topology_error":"Topology response missing panel_size and no circuits found. Update the SPAN Panel integration.","card.panel_size_error":"Could not determine panel_size. No circuits found and no panel_size attribute. Update the SPAN Panel integration.","editor.panel_label":"SPAN Panel","editor.select_panel":"Select a panel...","editor.chart_window":"Chart time window","editor.days":"days","editor.hours":"hours","editor.minutes":"minutes","editor.chart_metric":"Chart metric","editor.visible_sections":"Visible sections","editor.panel_circuits":"Panel circuits","editor.battery_bess":"Battery (BESS)","editor.ev_charger_evse":"EV Charger (EVSE)","editor.tab_style":"Tab Style","editor.tab_style_text":"Text","editor.tab_style_icon":"Icon","metric.power":"Power","metric.current":"Current","metric.soc":"State of Charge","metric.soe":"State of Energy","shedding.always_on":"Critical","shedding.never":"Non-sheddable","shedding.soc_threshold":"SoC Threshold","shedding.off_grid":"Sheddable","shedding.unknown":"Unknown","shedding.select.never":"Stays on in an outage","shedding.select.soc_threshold":"Stays on until battery threshold","shedding.select.off_grid":"Turns off in an outage"},es:{"tab.panel":"Panel","tab.by_panel":"Por Panel","tab.by_activity":"Por Actividad","tab.by_area":"Por Área","tab.monitoring":"Monitoreo","tab.settings":"Configuración","list.search_placeholder":"Buscar circuitos...","list.unassigned_area":"Sin asignar","list.no_results":"No se encontraron circuitos","monitoring.heading":"Monitoreo","monitoring.global_settings":"Configuración Global","monitoring.enabled":"Activado","monitoring.continuous":"Continuo (%)","monitoring.spike":"Pico (%)","monitoring.window":"Ventana (min)","monitoring.cooldown":"Enfriamiento (min)","monitoring.monitored_points":"Puntos Monitoreados","monitoring.col.name":"Nombre","monitoring.col.continuous":"Continuo","monitoring.col.spike":"Pico","monitoring.col.window":"Ventana","monitoring.col.cooldown":"Enfriamiento","monitoring.all_none":"Todos / Ninguno","monitoring.reset":"Restablecer","notification.heading":"Configuración de Notificaciones","notification.targets":"Destinos de Notificación","notification.none_selected":"Ninguno seleccionado","notification.no_targets":"No se encontraron destinos de notificación","notification.all_targets":"Todos","notification.event_bus_target":"Bus de Eventos (bus de eventos de HA)","notification.priority":"Prioridad","notification.priority.default":"Predeterminado","notification.priority.passive":"Pasivo","notification.priority.active":"Activo","notification.priority.time_sensitive":"Urgente","notification.priority.critical":"Crítico","notification.hint.critical":"Anula silencio/No molestar","notification.hint.time_sensitive":"Atraviesa el modo Concentración","notification.hint.passive":"Entrega silenciosa","notification.hint.active":"Entrega estándar","notification.title_template":"Plantilla de Título","notification.message_template":"Plantilla de Mensaje","notification.placeholders":"Variables:","notification.event_bus_help":"El Bus de Eventos dispara el tipo de evento","notification.event_bus_payload":"con datos:","notification.test_label":"Notificación de prueba","notification.test_button":"Enviar prueba","notification.test_sending":"Enviando...","notification.test_sent":"Notificación de prueba enviada","error.prefix":"Error:","error.failed_save":"Error al guardar","error.failed":"Falló","error.panel_offline":"SPAN Panel inaccesible","error.panel_reconnected":"SPAN Panel reconectado","error.panel_offline_named":"{name} inaccesible","error.panel_reconnected_named":"{name} reconectado","error.discovery_failed":"No se puede conectar al SPAN Panel","error.relay_failed":"No se pudo cambiar el relé","error.shedding_failed":"No se pudo actualizar la prioridad de desconexión","error.threshold_failed":"No se pudo guardar el umbral","error.graph_horizon_failed":"No se pudo actualizar el horizonte temporal del gráfico","error.favorites_fetch_failed":"No se pudieron cargar los favoritos","error.favorites_toggle_failed":"No se pudo actualizar el favorito","error.history_failed":"No se pudieron cargar los datos históricos","error.monitoring_failed":"No se pudo cargar el estado de monitoreo","error.graph_settings_failed":"No se pudo cargar la configuración del gráfico","error.areas_failed":"Las asignaciones de áreas pueden estar desincronizadas","error.retry":"Reintentar","card.connecting":"Conectando al SPAN Panel...","settings.heading":"Configuración","settings.description":"La configuración general de la integración (nombres de entidades, prefijo de dispositivo, números de circuito) se administra a través del flujo de opciones de la integración.","settings.open_link":"Abrir Configuración de Integración SPAN Panel","horizon.5m":"5 Minutes","horizon.1h":"1 Hour","horizon.1d":"1 Day","horizon.1w":"1 Week","horizon.1M":"1 Month","settings.graph_horizon_heading":"Graph Time Horizon","settings.graph_horizon_description":"Default time window for all circuit graphs. Individual circuits can override this in their settings panel.","settings.global_default":"Global Default","settings.default_scale":"Default Scale","settings.circuit_graph_scales":"Circuit Graph Scales","settings.col.circuit":"Circuit","settings.col.scale":"Scale","sidepanel.graph_horizon":"Graph Time Horizon","header.default_name":"SPAN Panel","header.monitoring_settings":"Configuración de monitoreo del panel","header.graph_settings":"Configuración del horizonte temporal del gráfico","header.site":"Sitio","header.grid":"Red","header.upstream":"Aguas arriba","header.downstream":"Aguas abajo","header.solar":"Solar","header.battery":"Batería","header.toggle_units":"Alternar Watts / Amperios","header.enable_switches":"Habilitar Interruptores","header.switches_enabled":"Interruptores Habilitados","grid.unknown":"Desconocido","grid.configure":"Configurar circuito","grid.configure_subdevice":"Configurar dispositivo","grid.on":"Enc","grid.off":"Apag","subdevice.ev_charger":"Cargador EV","subdevice.battery":"Batería","subdevice.fallback":"Sub-dispositivo","subdevice.soc":"SoC","subdevice.soe":"SoE","subdevice.power":"Potencia","sidepanel.graph_settings":"Configuración de Gráficos","sidepanel.global_defaults":"Valores predeterminados globales para todos los circuitos","sidepanel.favorites_subtitle":"Favoritos","sidepanel.global_default":"Predeterminado Global","sidepanel.list_view_columns":"Columnas de la lista","sidepanel.columns":"Columnas","sidepanel.circuit_scales":"Escalas de Gráficos de Circuitos","sidepanel.subdevice_scales":"Escalas de Gráficos de Sub-Dispositivos","sidepanel.reset_to_global":"Restablecer al valor global","sidepanel.relay":"Relé","sidepanel.breaker":"Interruptor","sidepanel.shedding_priority":"Prioridad de Desconexción","sidepanel.priority_label":"Prioridad","sidepanel.monitoring":"Monitoreo","sidepanel.global":"Global","sidepanel.custom":"Personalizado","sidepanel.continuous_pct":"Continuo %","sidepanel.spike_pct":"Pico %","sidepanel.window_duration":"Duración de ventana","sidepanel.cooldown":"Enfriamiento","sidepanel.favorite":"Favorito","sidepanel.save_to_favorites":"Guardar en favoritos","panel.favorites":"Favoritos","status.monitoring":"Monitoreo","status.circuits":"circuitos","status.mains":"alimentación","status.warning":"advertencia","status.warnings":"advertencias","status.alert":"alerta","status.alerts":"alertas","status.override":"anulación","status.overrides":"anulaciones","card.no_device":"Abra el editor de tarjeta y seleccione su dispositivo SPAN Panel.","card.device_not_found":"Dispositivo de panel no encontrado. Verifique device_id en la configuración de la tarjeta.","card.topology_error":"La respuesta de topología no contiene panel_size y no se encontraron circuitos. Actualice la integración SPAN Panel.","card.panel_size_error":"No se pudo determinar panel_size. No se encontraron circuitos ni atributo panel_size. Actualice la integración SPAN Panel.","editor.panel_label":"SPAN Panel","editor.select_panel":"Seleccione un panel...","editor.chart_window":"Ventana de tiempo del gráfico","editor.days":"días","editor.hours":"horas","editor.minutes":"minutos","editor.chart_metric":"Métrica del gráfico","editor.visible_sections":"Secciones visibles","editor.panel_circuits":"Circuitos del panel","editor.battery_bess":"Batería (BESS)","editor.ev_charger_evse":"Cargador EV (EVSE)","editor.tab_style":"Estilo de pestañas","editor.tab_style_text":"Texto","editor.tab_style_icon":"Ícono","metric.power":"Potencia","metric.current":"Corriente","metric.soc":"Estado de Carga","metric.soe":"Estado de Energía","shedding.always_on":"Crítico","shedding.never":"No desconectable","shedding.soc_threshold":"Umbral SoC","shedding.off_grid":"Desconectable","shedding.unknown":"Desconocido","shedding.select.never":"Permanece encendido en un corte","shedding.select.soc_threshold":"Encendido hasta umbral de batería","shedding.select.off_grid":"Se apaga en un corte"},fr:{"tab.panel":"Panneau","tab.by_panel":"Par Panneau","tab.by_activity":"Par Activité","tab.by_area":"Par Zone","tab.monitoring":"Surveillance","tab.settings":"Paramètres","list.search_placeholder":"Rechercher des circuits...","list.unassigned_area":"Non attribué","list.no_results":"Aucun circuit trouvé","monitoring.heading":"Surveillance","monitoring.global_settings":"Paramètres Globaux","monitoring.enabled":"Activé","monitoring.continuous":"Continu (%)","monitoring.spike":"Pic (%)","monitoring.window":"Fenêtre (min)","monitoring.cooldown":"Refroidissement (min)","monitoring.monitored_points":"Points Surveillés","monitoring.col.name":"Nom","monitoring.col.continuous":"Continu","monitoring.col.spike":"Pic","monitoring.col.window":"Fenêtre","monitoring.col.cooldown":"Refroidissement","monitoring.all_none":"Tous / Aucun","monitoring.reset":"Réinitialiser","notification.heading":"Paramètres de Notification","notification.targets":"Cibles de Notification","notification.none_selected":"Aucune sélection","notification.no_targets":"Aucune cible de notification trouvée","notification.all_targets":"Tous","notification.event_bus_target":"Bus d'événements (bus d'événements HA)","notification.priority":"Priorité","notification.priority.default":"Par défaut","notification.priority.passive":"Passif","notification.priority.active":"Actif","notification.priority.time_sensitive":"Urgent","notification.priority.critical":"Critique","notification.hint.critical":"Outrepasse silencieux/NPD","notification.hint.time_sensitive":"Traverse le mode Concentration","notification.hint.passive":"Livraison silencieuse","notification.hint.active":"Livraison standard","notification.title_template":"Modèle de Titre","notification.message_template":"Modèle de Message","notification.placeholders":"Variables :","notification.event_bus_help":"Le Bus d'événements déclenche le type d'événement","notification.event_bus_payload":"avec les données :","notification.test_label":"Notification de test","notification.test_button":"Envoyer un test","notification.test_sending":"Envoi...","notification.test_sent":"Notification de test envoyée","error.prefix":"Erreur :","error.failed_save":"Échec de la sauvegarde","error.failed":"Échoué","error.panel_offline":"SPAN Panel inaccessible","error.panel_reconnected":"SPAN Panel reconnecté","error.panel_offline_named":"{name} inaccessible","error.panel_reconnected_named":"{name} reconnecté","error.discovery_failed":"Impossible de se connecter au SPAN Panel","error.relay_failed":"Impossible de basculer le relais","error.shedding_failed":"Impossible de mettre à jour la priorité de délestage","error.threshold_failed":"Impossible d'enregistrer le seuil","error.graph_horizon_failed":"Impossible de mettre à jour l'horizon temporel du graphique","error.favorites_fetch_failed":"Impossible de charger les favoris","error.favorites_toggle_failed":"Impossible de mettre à jour le favori","error.history_failed":"Impossible de charger les données historiques","error.monitoring_failed":"Impossible de charger l'état de surveillance","error.graph_settings_failed":"Impossible de charger les paramètres du graphique","error.areas_failed":"Les affectations de zones peuvent être désynchronisées","error.retry":"Réessayer","card.connecting":"Connexion au SPAN Panel...","settings.heading":"Paramètres","settings.description":"Les paramètres généraux de l'intégration (noms d'entités, préfixe de l'appareil, numéros de circuit) sont gérés via le flux d'options de l'intégration.","settings.open_link":"Ouvrir les Paramètres d'Intégration SPAN Panel","horizon.5m":"5 Minutes","horizon.1h":"1 Hour","horizon.1d":"1 Day","horizon.1w":"1 Week","horizon.1M":"1 Month","settings.graph_horizon_heading":"Graph Time Horizon","settings.graph_horizon_description":"Default time window for all circuit graphs. Individual circuits can override this in their settings panel.","settings.global_default":"Global Default","settings.default_scale":"Default Scale","settings.circuit_graph_scales":"Circuit Graph Scales","settings.col.circuit":"Circuit","settings.col.scale":"Scale","sidepanel.graph_horizon":"Graph Time Horizon","header.default_name":"SPAN Panel","header.monitoring_settings":"Paramètres de surveillance du panneau","header.graph_settings":"Paramètres d'horizon temporel du graphique","header.site":"Site","header.grid":"Réseau","header.upstream":"Amont","header.downstream":"Aval","header.solar":"Solaire","header.battery":"Batterie","header.toggle_units":"Basculer Watts / Ampères","header.enable_switches":"Activer les interrupteurs","header.switches_enabled":"Interrupteurs activés","grid.unknown":"Inconnu","grid.configure":"Configurer le circuit","grid.configure_subdevice":"Configurer l'appareil","grid.on":"On","grid.off":"Off","subdevice.ev_charger":"Chargeur VE","subdevice.battery":"Batterie","subdevice.fallback":"Sous-appareil","subdevice.soc":"SoC","subdevice.soe":"SoE","subdevice.power":"Puissance","sidepanel.graph_settings":"Paramètres des Graphiques","sidepanel.global_defaults":"Valeurs par défaut globales pour tous les circuits","sidepanel.favorites_subtitle":"Favoris","sidepanel.global_default":"Défaut Global","sidepanel.list_view_columns":"Colonnes de la liste","sidepanel.columns":"Colonnes","sidepanel.circuit_scales":"Échelles des Graphiques de Circuits","sidepanel.subdevice_scales":"Échelles des Graphiques de Sous-Appareils","sidepanel.reset_to_global":"Réinitialiser à la valeur globale","sidepanel.relay":"Relais","sidepanel.breaker":"Disjoncteur","sidepanel.shedding_priority":"Priorité de Délestage","sidepanel.priority_label":"Priorité","sidepanel.monitoring":"Surveillance","sidepanel.global":"Global","sidepanel.custom":"Personnalisé","sidepanel.continuous_pct":"Continu %","sidepanel.spike_pct":"Pic %","sidepanel.window_duration":"Durée de fenêtre","sidepanel.cooldown":"Refroidissement","sidepanel.favorite":"Favori","sidepanel.save_to_favorites":"Enregistrer dans les favoris","panel.favorites":"Favoris","status.monitoring":"Surveillance","status.circuits":"circuits","status.mains":"alimentation","status.warning":"avertissement","status.warnings":"avertissements","status.alert":"alerte","status.alerts":"alertes","status.override":"remplacement","status.overrides":"remplacements","card.no_device":"Ouvrez l'éditeur de carte et sélectionnez votre appareil SPAN Panel.","card.device_not_found":"Appareil de panneau introuvable. Vérifiez device_id dans la configuration de la carte.","card.topology_error":"La réponse de topologie ne contient pas panel_size et aucun circuit trouvé. Mettez à jour l'intégration SPAN Panel.","card.panel_size_error":"Impossible de déterminer panel_size. Aucun circuit trouvé et aucun attribut panel_size. Mettez à jour l'intégration SPAN Panel.","editor.panel_label":"SPAN Panel","editor.select_panel":"Sélectionnez un panneau...","editor.chart_window":"Fenêtre de temps du graphique","editor.days":"jours","editor.hours":"heures","editor.minutes":"minutes","editor.chart_metric":"Métrique du graphique","editor.visible_sections":"Sections visibles","editor.panel_circuits":"Circuits du panneau","editor.battery_bess":"Batterie (BESS)","editor.ev_charger_evse":"Chargeur VE (EVSE)","editor.tab_style":"Style des onglets","editor.tab_style_text":"Texte","editor.tab_style_icon":"Icône","metric.power":"Puissance","metric.current":"Courant","metric.soc":"État de Charge","metric.soe":"État d'Énergie","shedding.always_on":"Critique","shedding.never":"Non délestable","shedding.soc_threshold":"Seuil SoC","shedding.off_grid":"Délestable","shedding.unknown":"Inconnu","shedding.select.never":"Reste allumé en cas de coupure","shedding.select.soc_threshold":"Allumé jusqu'au seuil batterie","shedding.select.off_grid":"S'éteint en cas de coupure"},ja:{"tab.panel":"パネル","tab.by_panel":"パネル別","tab.by_activity":"活動別","tab.by_area":"エリア別","tab.monitoring":"モニタリング","tab.settings":"設定","list.search_placeholder":"回路を検索...","list.unassigned_area":"未割り当て","list.no_results":"回路が見つかりません","monitoring.heading":"モニタリング","monitoring.global_settings":"グローバル設定","monitoring.enabled":"有効","monitoring.continuous":"継続 (%)","monitoring.spike":"スパイク (%)","monitoring.window":"ウィンドウ (分)","monitoring.cooldown":"クールダウン (分)","monitoring.monitored_points":"監視ポイント","monitoring.col.name":"名前","monitoring.col.continuous":"継続","monitoring.col.spike":"スパイク","monitoring.col.window":"ウィンドウ","monitoring.col.cooldown":"クールダウン","monitoring.all_none":"全選択 / 全解除","monitoring.reset":"リセット","notification.heading":"通知設定","notification.targets":"通知先","notification.none_selected":"未選択","notification.no_targets":"通知先が見つかりません","notification.all_targets":"すべて","notification.event_bus_target":"イベントバス (HAイベントバス)","notification.priority":"優先度","notification.priority.default":"デフォルト","notification.priority.passive":"パッシブ","notification.priority.active":"アクティブ","notification.priority.time_sensitive":"緊急","notification.priority.critical":"重大","notification.hint.critical":"サイレント/おやすみモードを無視","notification.hint.time_sensitive":"集中モードを突破","notification.hint.passive":"サイレント配信","notification.hint.active":"標準配信","notification.title_template":"タイトルテンプレート","notification.message_template":"メッセージテンプレート","notification.placeholders":"プレースホルダー:","notification.event_bus_help":"イベントバスが発行するイベントタイプ","notification.event_bus_payload":"ペイロード:","notification.test_label":"テスト通知","notification.test_button":"テスト送信","notification.test_sending":"送信中...","notification.test_sent":"テスト通知を送信しました","error.prefix":"エラー:","error.failed_save":"保存に失敗","error.failed":"失敗","error.panel_offline":"SPANパネルに接続できません","error.panel_reconnected":"SPANパネルが再接続されました","error.panel_offline_named":"{name}に接続できません","error.panel_reconnected_named":"{name}が再接続されました","error.discovery_failed":"SPANパネルへの接続に失敗しました","error.relay_failed":"リレーの切り替えに失敗しました","error.shedding_failed":"シェディング優先度の更新に失敗しました","error.threshold_failed":"しきい値の保存に失敗しました","error.graph_horizon_failed":"グラフの時間範囲の更新に失敗しました","error.favorites_fetch_failed":"お気に入りの読み込みに失敗しました","error.favorites_toggle_failed":"お気に入りの更新に失敗しました","error.history_failed":"履歴データの読み込みに失敗しました","error.monitoring_failed":"監視ステータスの読み込みに失敗しました","error.graph_settings_failed":"グラフ設定の読み込みに失敗しました","error.areas_failed":"エリア割り当てが同期されていない可能性があります","error.retry":"再試行","card.connecting":"SPANパネルに接続中...","settings.heading":"設定","settings.description":"統合の一般設定(エンティティ名、デバイスプレフィックス、回路番号)は統合のオプションフローで管理されます。","settings.open_link":"SPAN Panel統合設定を開く","horizon.5m":"5 Minutes","horizon.1h":"1 Hour","horizon.1d":"1 Day","horizon.1w":"1 Week","horizon.1M":"1 Month","settings.graph_horizon_heading":"Graph Time Horizon","settings.graph_horizon_description":"Default time window for all circuit graphs. Individual circuits can override this in their settings panel.","settings.global_default":"Global Default","settings.default_scale":"Default Scale","settings.circuit_graph_scales":"Circuit Graph Scales","settings.col.circuit":"Circuit","settings.col.scale":"Scale","sidepanel.graph_horizon":"Graph Time Horizon","header.default_name":"SPAN Panel","header.monitoring_settings":"パネルモニタリング設定","header.graph_settings":"グラフ時間範囲設定","header.site":"サイト","header.grid":"グリッド","header.upstream":"上流","header.downstream":"下流","header.solar":"ソーラー","header.battery":"バッテリー","header.toggle_units":"ワット/アンペア切り替え","header.enable_switches":"スイッチを有効化","header.switches_enabled":"スイッチ有効","grid.unknown":"不明","grid.configure":"回路を設定","grid.configure_subdevice":"デバイスを設定","grid.on":"オン","grid.off":"オフ","subdevice.ev_charger":"EV充電器","subdevice.battery":"バッテリー","subdevice.fallback":"サブデバイス","subdevice.soc":"SoC","subdevice.soe":"SoE","subdevice.power":"電力","sidepanel.graph_settings":"グラフ設定","sidepanel.global_defaults":"全回路のグローバルデフォルト","sidepanel.favorites_subtitle":"お気に入り","sidepanel.global_default":"グローバルデフォルト","sidepanel.list_view_columns":"リスト表示の列数","sidepanel.columns":"列","sidepanel.circuit_scales":"回路グラフスケール","sidepanel.subdevice_scales":"サブデバイスグラフスケール","sidepanel.reset_to_global":"グローバルにリセット","sidepanel.relay":"リレー","sidepanel.breaker":"ブレーカー","sidepanel.shedding_priority":"シェディング優先度","sidepanel.priority_label":"優先度","sidepanel.monitoring":"モニタリング","sidepanel.global":"グローバル","sidepanel.custom":"カスタム","sidepanel.continuous_pct":"継続 %","sidepanel.spike_pct":"スパイク %","sidepanel.window_duration":"ウィンドウ時間","sidepanel.cooldown":"クールダウン","sidepanel.favorite":"お気に入り","sidepanel.save_to_favorites":"お気に入りに保存","panel.favorites":"お気に入り","status.monitoring":"モニタリング","status.circuits":"回路","status.mains":"主電源","status.warning":"警告","status.warnings":"警告","status.alert":"アラート","status.alerts":"アラート","status.override":"上書き","status.overrides":"上書き","card.no_device":"カードエディタを開いてSPAN Panelデバイスを選択してください。","card.device_not_found":"パネルデバイスが見つかりません。カード設定のdevice_idを確認してください。","card.topology_error":"トポロジー応答にpanel_sizeがなく、回路が見つかりません。SPAN Panel統合を更新してください。","card.panel_size_error":"panel_sizeを判定できません。回路がpanel_size属性が見つかりません。SPAN Panel統合を更新してください。","editor.panel_label":"SPAN Panel","editor.select_panel":"パネルを選択...","editor.chart_window":"グラフ時間ウィンドウ","editor.days":"日","editor.hours":"時間","editor.minutes":"分","editor.chart_metric":"グラフ指標","editor.visible_sections":"表示セクション","editor.panel_circuits":"パネル回路","editor.battery_bess":"バッテリー (BESS)","editor.ev_charger_evse":"EV充電器 (EVSE)","editor.tab_style":"タブスタイル","editor.tab_style_text":"テキスト","editor.tab_style_icon":"アイコン","metric.power":"電力","metric.current":"電流","metric.soc":"充電状態","metric.soe":"エネルギー状態","shedding.always_on":"重要","shedding.never":"切断不可","shedding.soc_threshold":"SoCしきい値","shedding.off_grid":"切断可能","shedding.unknown":"不明","shedding.select.never":"停電時もオンを維持","shedding.select.soc_threshold":"バッテリーしきい値までオン","shedding.select.off_grid":"停電時にオフ"},pt:{"tab.panel":"Painel","tab.by_panel":"Por Painel","tab.by_activity":"Por Atividade","tab.by_area":"Por Área","tab.monitoring":"Monitoramento","tab.settings":"Configurações","list.search_placeholder":"Pesquisar circuitos...","list.unassigned_area":"Não atribuído","list.no_results":"Nenhum circuito encontrado","monitoring.heading":"Monitoramento","monitoring.global_settings":"Configurações Globais","monitoring.enabled":"Ativado","monitoring.continuous":"Contínuo (%)","monitoring.spike":"Pico (%)","monitoring.window":"Janela (min)","monitoring.cooldown":"Resfriamento (min)","monitoring.monitored_points":"Pontos Monitorados","monitoring.col.name":"Nome","monitoring.col.continuous":"Contínuo","monitoring.col.spike":"Pico","monitoring.col.window":"Janela","monitoring.col.cooldown":"Resfriamento","monitoring.all_none":"Todos / Nenhum","monitoring.reset":"Redefinir","notification.heading":"Configurações de Notificação","notification.targets":"Destinos de Notificação","notification.none_selected":"Nenhum selecionado","notification.no_targets":"Nenhum destino de notificação encontrado","notification.all_targets":"Todos","notification.event_bus_target":"Barramento de Eventos (barramento de eventos do HA)","notification.priority":"Prioridade","notification.priority.default":"Padrão","notification.priority.passive":"Passivo","notification.priority.active":"Ativo","notification.priority.time_sensitive":"Urgente","notification.priority.critical":"Crítico","notification.hint.critical":"Substitui silencioso/Não perturbar","notification.hint.time_sensitive":"Atravessa o modo Foco","notification.hint.passive":"Entrega silenciosa","notification.hint.active":"Entrega padrão","notification.title_template":"Modelo de Título","notification.message_template":"Modelo de Mensagem","notification.placeholders":"Variáveis:","notification.event_bus_help":"O Barramento de Eventos dispara o tipo de evento","notification.event_bus_payload":"com dados:","notification.test_label":"Notificação de teste","notification.test_button":"Enviar teste","notification.test_sending":"Enviando...","notification.test_sent":"Notificação de teste enviada","error.prefix":"Erro:","error.failed_save":"Falha ao salvar","error.failed":"Falhou","error.panel_offline":"SPAN Panel inacessível","error.panel_reconnected":"SPAN Panel reconectado","error.panel_offline_named":"{name} inacessível","error.panel_reconnected_named":"{name} reconectado","error.discovery_failed":"Não foi possível conectar ao SPAN Panel","error.relay_failed":"Não foi possível alternar o relé","error.shedding_failed":"Não foi possível atualizar a prioridade de desligamento","error.threshold_failed":"Não foi possível salvar o limite","error.graph_horizon_failed":"Não foi possível atualizar o horizonte temporal do gráfico","error.favorites_fetch_failed":"Não foi possível carregar os favoritos","error.favorites_toggle_failed":"Não foi possível atualizar o favorito","error.history_failed":"Não foi possível carregar os dados históricos","error.monitoring_failed":"Não foi possível carregar o status de monitoramento","error.graph_settings_failed":"Não foi possível carregar as configurações do gráfico","error.areas_failed":"As atribuições de áreas podem estar fora de sincronização","error.retry":"Tentar novamente","card.connecting":"Conectando ao SPAN Panel...","settings.heading":"Configurações","settings.description":"As configurações gerais da integração (nomes de entidades, prefixo do dispositivo, números de circuito) são gerenciadas através do fluxo de opções da integração.","settings.open_link":"Abrir Configurações de Integração SPAN Panel","horizon.5m":"5 Minutes","horizon.1h":"1 Hour","horizon.1d":"1 Day","horizon.1w":"1 Week","horizon.1M":"1 Month","settings.graph_horizon_heading":"Graph Time Horizon","settings.graph_horizon_description":"Default time window for all circuit graphs. Individual circuits can override this in their settings panel.","settings.global_default":"Global Default","settings.default_scale":"Default Scale","settings.circuit_graph_scales":"Circuit Graph Scales","settings.col.circuit":"Circuit","settings.col.scale":"Scale","sidepanel.graph_horizon":"Graph Time Horizon","header.default_name":"SPAN Panel","header.monitoring_settings":"Configurações de monitoramento do painel","header.graph_settings":"Configurações do horizonte temporal do gráfico","header.site":"Local","header.grid":"Rede","header.upstream":"Montante","header.downstream":"Jusante","header.solar":"Solar","header.battery":"Bateria","header.toggle_units":"Alternar Watts / Amperes","header.enable_switches":"Ativar Interruptores","header.switches_enabled":"Interruptores Ativados","grid.unknown":"Desconhecido","grid.configure":"Configurar circuito","grid.configure_subdevice":"Configurar dispositivo","grid.on":"Lig","grid.off":"Des","subdevice.ev_charger":"Carregador VE","subdevice.battery":"Bateria","subdevice.fallback":"Sub-dispositivo","subdevice.soc":"SoC","subdevice.soe":"SoE","subdevice.power":"Potência","sidepanel.graph_settings":"Configurações de Gráficos","sidepanel.global_defaults":"Padrões globais para todos os circuitos","sidepanel.favorites_subtitle":"Favoritos","sidepanel.global_default":"Padrão Global","sidepanel.list_view_columns":"Colunas da Lista","sidepanel.columns":"Colunas","sidepanel.circuit_scales":"Escalas de Gráficos de Circuitos","sidepanel.subdevice_scales":"Escalas de Gráficos de Sub-Dispositivos","sidepanel.reset_to_global":"Redefinir para o padrão global","sidepanel.relay":"Relé","sidepanel.breaker":"Disjuntor","sidepanel.shedding_priority":"Prioridade de Desligamento","sidepanel.priority_label":"Prioridade","sidepanel.monitoring":"Monitoramento","sidepanel.global":"Global","sidepanel.custom":"Personalizado","sidepanel.continuous_pct":"Contínuo %","sidepanel.spike_pct":"Pico %","sidepanel.window_duration":"Duração da janela","sidepanel.cooldown":"Resfriamento","sidepanel.favorite":"Favorito","sidepanel.save_to_favorites":"Salvar nos favoritos","panel.favorites":"Favoritos","status.monitoring":"Monitoramento","status.circuits":"circuitos","status.mains":"alimentação","status.warning":"aviso","status.warnings":"avisos","status.alert":"alerta","status.alerts":"alertas","status.override":"substituição","status.overrides":"substituições","card.no_device":"Abra o editor do cartão e selecione seu dispositivo SPAN Panel.","card.device_not_found":"Dispositivo do painel não encontrado. Verifique device_id na configuração do cartão.","card.topology_error":"A resposta de topologia não contém panel_size e nenhum circuito encontrado. Atualize a integração SPAN Panel.","card.panel_size_error":"Não foi possível determinar panel_size. Nenhum circuito encontrado e nenhum atributo panel_size. Atualize a integração SPAN Panel.","editor.panel_label":"SPAN Panel","editor.select_panel":"Selecione um painel...","editor.chart_window":"Janela de tempo do gráfico","editor.days":"dias","editor.hours":"horas","editor.minutes":"minutos","editor.chart_metric":"Métrica do gráfico","editor.visible_sections":"Seções visíveis","editor.panel_circuits":"Circuitos do painel","editor.battery_bess":"Bateria (BESS)","editor.ev_charger_evse":"Carregador VE (EVSE)","editor.tab_style":"Estilo das abas","editor.tab_style_text":"Texto","editor.tab_style_icon":"Ícone","metric.power":"Potência","metric.current":"Corrente","metric.soc":"Estado de Carga","metric.soe":"Estado de Energia","shedding.always_on":"Crítico","shedding.never":"Não desligável","shedding.soc_threshold":"Limite SoC","shedding.off_grid":"Desligável","shedding.unknown":"Desconhecido","shedding.select.never":"Permanece ligado em uma queda","shedding.select.soc_threshold":"Ligado até limite da bateria","shedding.select.off_grid":"Desliga em uma queda"}};function n(n){t=n&&e[n]?n:"en"}function i(n){return e[t]?.[n]??e.en?.[n]??n}function r(n,i){return(e[t]?.[n]??e.en?.[n]??n).replace(/\{(\w+)\}/g,(t,e)=>Object.prototype.hasOwnProperty.call(i,e)?i[e]:`{${e}}`)}const o="power",a="5m",s={"5m":{ms:3e5,refreshMs:1e3,useRealtime:!0},"1h":{ms:36e5,refreshMs:3e4,useRealtime:!1},"1d":{ms:864e5,refreshMs:6e4,useRealtime:!1},"1w":{ms:6048e5,refreshMs:6e4,useRealtime:!1},"1M":{ms:2592e6,refreshMs:6e4,useRealtime:!1}},l="span_panel",c="CLOSED",u="pv",h="bess",d="evse",p="sub_",f=500,g={power:{entityRole:"power",label:()=>i("metric.power"),unit:t=>Math.abs(t)>=1e3?"kW":"W",format:t=>{const e=Math.abs(t);return e>=1e3?(e/1e3).toFixed(1):e<10&&e>0?e.toFixed(1):String(Math.round(e))}},current:{entityRole:"current",label:()=>i("metric.current"),unit:()=>"A",format:t=>Math.abs(t).toFixed(1)}},v={soc:{entityRole:"soc",label:()=>i("metric.soc"),unit:()=>"%",format:t=>String(Math.round(t)),fixedMin:0,fixedMax:100},soe:{entityRole:"soe",label:()=>i("metric.soe"),unit:()=>"kWh",format:t=>t.toFixed(1)},power:g.power},m={always_on:{icon:"mdi:battery",icon2:"mdi:router-wireless",color:"#4caf50",label:()=>i("shedding.always_on")},never:{icon:"mdi:battery",color:"#4caf50",label:()=>i("shedding.never")},soc_threshold:{icon:"mdi:battery-alert-variant-outline",color:"#9c27b0",label:()=>i("shedding.soc_threshold"),textLabel:"SoC"},off_grid:{icon:"mdi:transmission-tower",color:"#ff9800",label:()=>i("shedding.off_grid")},unknown:{icon:"mdi:help-circle-outline",color:"#888",label:()=>i("shedding.unknown")}};var y=function(t,e){return y=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var n in e)Object.prototype.hasOwnProperty.call(e,n)&&(t[n]=e[n])},y(t,e)};function _(t,e){if("function"!=typeof e&&null!==e)throw new TypeError("Class extends value "+String(e)+" is not a constructor or null");function n(){this.constructor=t}y(t,e),t.prototype=null===e?Object.create(e):(n.prototype=e.prototype,new n)}function x(t,e,n,i){var r,o=arguments.length,a=o<3?e:null===i?i=Object.getOwnPropertyDescriptor(e,n):i;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)a=Reflect.decorate(t,e,n,i);else for(var s=t.length-1;s>=0;s--)(r=t[s])&&(a=(o<3?r(a):o>3?r(e,n,a):r(e,n))||a);return o>3&&a&&Object.defineProperty(e,n,a),a}"function"==typeof SuppressedError&&SuppressedError; +/** + * @license + * Copyright 2019 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +const b=globalThis,w=b.ShadowRoot&&(void 0===b.ShadyCSS||b.ShadyCSS.nativeShadow)&&"adoptedStyleSheets"in Document.prototype&&"replace"in CSSStyleSheet.prototype,S=Symbol(),C=new WeakMap;let M=class{constructor(t,e,n){if(this._$cssResult$=!0,n!==S)throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead.");this.cssText=t,this.t=e}get styleSheet(){let t=this.o;const e=this.t;if(w&&void 0===t){const n=void 0!==e&&1===e.length;n&&(t=C.get(e)),void 0===t&&((this.o=t=new CSSStyleSheet).replaceSync(this.cssText),n&&C.set(e,t))}return t}toString(){return this.cssText}};const k=t=>new M("string"==typeof t?t:t+"",void 0,S),T=(t,...e)=>{const n=1===t.length?t[0]:e.reduce((e,n,i)=>e+(t=>{if(!0===t._$cssResult$)return t.cssText;if("number"==typeof t)return t;throw Error("Value passed to 'css' function must be a 'css' function result: "+t+". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security.")})(n)+t[i+1],t[0]);return new M(n,t,S)},D=w?t=>t:t=>t instanceof CSSStyleSheet?(t=>{let e="";for(const n of t.cssRules)e+=n.cssText;return k(e)})(t):t,{is:I,defineProperty:A,getOwnPropertyDescriptor:P,getOwnPropertyNames:L,getOwnPropertySymbols:E,getPrototypeOf:z}=Object,O=globalThis,N=O.trustedTypes,R=N?N.emptyScript:"",H=O.reactiveElementPolyfillSupport,B=(t,e)=>t,F={toAttribute(t,e){switch(e){case Boolean:t=t?R:null;break;case Object:case Array:t=null==t?t:JSON.stringify(t)}return t},fromAttribute(t,e){let n=t;switch(e){case Boolean:n=null!==t;break;case Number:n=null===t?null:Number(t);break;case Object:case Array:try{n=JSON.parse(t)}catch(t){n=null}}return n}},$=(t,e)=>!I(t,e),V={attribute:!0,type:String,converter:F,reflect:!1,useDefault:!1,hasChanged:$}; +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */Symbol.metadata??=Symbol("metadata"),O.litPropertyMetadata??=new WeakMap;let W=class extends HTMLElement{static addInitializer(t){this._$Ei(),(this.l??=[]).push(t)}static get observedAttributes(){return this.finalize(),this._$Eh&&[...this._$Eh.keys()]}static createProperty(t,e=V){if(e.state&&(e.attribute=!1),this._$Ei(),this.prototype.hasOwnProperty(t)&&((e=Object.create(e)).wrapped=!0),this.elementProperties.set(t,e),!e.noAccessor){const n=Symbol(),i=this.getPropertyDescriptor(t,n,e);void 0!==i&&A(this.prototype,t,i)}}static getPropertyDescriptor(t,e,n){const{get:i,set:r}=P(this.prototype,t)??{get(){return this[e]},set(t){this[e]=t}};return{get:i,set(e){const o=i?.call(this);r?.call(this,e),this.requestUpdate(t,o,n)},configurable:!0,enumerable:!0}}static getPropertyOptions(t){return this.elementProperties.get(t)??V}static _$Ei(){if(this.hasOwnProperty(B("elementProperties")))return;const t=z(this);t.finalize(),void 0!==t.l&&(this.l=[...t.l]),this.elementProperties=new Map(t.elementProperties)}static finalize(){if(this.hasOwnProperty(B("finalized")))return;if(this.finalized=!0,this._$Ei(),this.hasOwnProperty(B("properties"))){const t=this.properties,e=[...L(t),...E(t)];for(const n of e)this.createProperty(n,t[n])}const t=this[Symbol.metadata];if(null!==t){const e=litPropertyMetadata.get(t);if(void 0!==e)for(const[t,n]of e)this.elementProperties.set(t,n)}this._$Eh=new Map;for(const[t,e]of this.elementProperties){const n=this._$Eu(t,e);void 0!==n&&this._$Eh.set(n,t)}this.elementStyles=this.finalizeStyles(this.styles)}static finalizeStyles(t){const e=[];if(Array.isArray(t)){const n=new Set(t.flat(1/0).reverse());for(const t of n)e.unshift(D(t))}else void 0!==t&&e.push(D(t));return e}static _$Eu(t,e){const n=e.attribute;return!1===n?void 0:"string"==typeof n?n:"string"==typeof t?t.toLowerCase():void 0}constructor(){super(),this._$Ep=void 0,this.isUpdatePending=!1,this.hasUpdated=!1,this._$Em=null,this._$Ev()}_$Ev(){this._$ES=new Promise(t=>this.enableUpdating=t),this._$AL=new Map,this._$E_(),this.requestUpdate(),this.constructor.l?.forEach(t=>t(this))}addController(t){(this._$EO??=new Set).add(t),void 0!==this.renderRoot&&this.isConnected&&t.hostConnected?.()}removeController(t){this._$EO?.delete(t)}_$E_(){const t=new Map,e=this.constructor.elementProperties;for(const n of e.keys())this.hasOwnProperty(n)&&(t.set(n,this[n]),delete this[n]);t.size>0&&(this._$Ep=t)}createRenderRoot(){const t=this.shadowRoot??this.attachShadow(this.constructor.shadowRootOptions);return((t,e)=>{if(w)t.adoptedStyleSheets=e.map(t=>t instanceof CSSStyleSheet?t:t.styleSheet);else for(const n of e){const e=document.createElement("style"),i=b.litNonce;void 0!==i&&e.setAttribute("nonce",i),e.textContent=n.cssText,t.appendChild(e)}})(t,this.constructor.elementStyles),t}connectedCallback(){this.renderRoot??=this.createRenderRoot(),this.enableUpdating(!0),this._$EO?.forEach(t=>t.hostConnected?.())}enableUpdating(t){}disconnectedCallback(){this._$EO?.forEach(t=>t.hostDisconnected?.())}attributeChangedCallback(t,e,n){this._$AK(t,n)}_$ET(t,e){const n=this.constructor.elementProperties.get(t),i=this.constructor._$Eu(t,n);if(void 0!==i&&!0===n.reflect){const r=(void 0!==n.converter?.toAttribute?n.converter:F).toAttribute(e,n.type);this._$Em=t,null==r?this.removeAttribute(i):this.setAttribute(i,r),this._$Em=null}}_$AK(t,e){const n=this.constructor,i=n._$Eh.get(t);if(void 0!==i&&this._$Em!==i){const t=n.getPropertyOptions(i),r="function"==typeof t.converter?{fromAttribute:t.converter}:void 0!==t.converter?.fromAttribute?t.converter:F;this._$Em=i;const o=r.fromAttribute(e,t.type);this[i]=o??this._$Ej?.get(i)??o,this._$Em=null}}requestUpdate(t,e,n,i=!1,r){if(void 0!==t){const o=this.constructor;if(!1===i&&(r=this[t]),n??=o.getPropertyOptions(t),!((n.hasChanged??$)(r,e)||n.useDefault&&n.reflect&&r===this._$Ej?.get(t)&&!this.hasAttribute(o._$Eu(t,n))))return;this.C(t,e,n)}!1===this.isUpdatePending&&(this._$ES=this._$EP())}C(t,e,{useDefault:n,reflect:i,wrapped:r},o){n&&!(this._$Ej??=new Map).has(t)&&(this._$Ej.set(t,o??e??this[t]),!0!==r||void 0!==o)||(this._$AL.has(t)||(this.hasUpdated||n||(e=void 0),this._$AL.set(t,e)),!0===i&&this._$Em!==t&&(this._$Eq??=new Set).add(t))}async _$EP(){this.isUpdatePending=!0;try{await this._$ES}catch(t){Promise.reject(t)}const t=this.scheduleUpdate();return null!=t&&await t,!this.isUpdatePending}scheduleUpdate(){return this.performUpdate()}performUpdate(){if(!this.isUpdatePending)return;if(!this.hasUpdated){if(this.renderRoot??=this.createRenderRoot(),this._$Ep){for(const[t,e]of this._$Ep)this[t]=e;this._$Ep=void 0}const t=this.constructor.elementProperties;if(t.size>0)for(const[e,n]of t){const{wrapped:t}=n,i=this[e];!0!==t||this._$AL.has(e)||void 0===i||this.C(e,void 0,n,i)}}let t=!1;const e=this._$AL;try{t=this.shouldUpdate(e),t?(this.willUpdate(e),this._$EO?.forEach(t=>t.hostUpdate?.()),this.update(e)):this._$EM()}catch(e){throw t=!1,this._$EM(),e}t&&this._$AE(e)}willUpdate(t){}_$AE(t){this._$EO?.forEach(t=>t.hostUpdated?.()),this.hasUpdated||(this.hasUpdated=!0,this.firstUpdated(t)),this.updated(t)}_$EM(){this._$AL=new Map,this.isUpdatePending=!1}get updateComplete(){return this.getUpdateComplete()}getUpdateComplete(){return this._$ES}shouldUpdate(t){return!0}update(t){this._$Eq&&=this._$Eq.forEach(t=>this._$ET(t,this[t])),this._$EM()}updated(t){}firstUpdated(t){}};W.elementStyles=[],W.shadowRootOptions={mode:"open"},W[B("elementProperties")]=new Map,W[B("finalized")]=new Map,H?.({ReactiveElement:W}),(O.reactiveElementVersions??=[]).push("2.1.2"); +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +const U=globalThis,G=t=>t,q=U.trustedTypes,j=q?q.createPolicy("lit-html",{createHTML:t=>t}):void 0,X="$lit$",Y=`lit$${Math.random().toFixed(9).slice(2)}$`,Z="?"+Y,K=`<${Z}>`,Q=document,J=()=>Q.createComment(""),tt=t=>null===t||"object"!=typeof t&&"function"!=typeof t,et=Array.isArray,nt="[ \t\n\f\r]",it=/<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g,rt=/-->/g,ot=/>/g,at=RegExp(`>|${nt}(?:([^\\s"'>=/]+)(${nt}*=${nt}*(?:[^ \t\n\f\r"'\`<>=]|("|')|))|$)`,"g"),st=/'/g,lt=/"/g,ct=/^(?:script|style|textarea|title)$/i,ut=t=>(e,...n)=>({_$litType$:t,strings:e,values:n}),ht=ut(1),dt=ut(2),pt=Symbol.for("lit-noChange"),ft=Symbol.for("lit-nothing"),gt=new WeakMap,vt=Q.createTreeWalker(Q,129);function mt(t,e){if(!et(t)||!t.hasOwnProperty("raw"))throw Error("invalid template strings array");return void 0!==j?j.createHTML(e):e}const yt=(t,e)=>{const n=t.length-1,i=[];let r,o=2===e?"":3===e?"":"",a=it;for(let e=0;e"===l[0]?(a=r??it,c=-1):void 0===l[1]?c=-2:(c=a.lastIndex-l[2].length,s=l[1],a=void 0===l[3]?at:'"'===l[3]?lt:st):a===lt||a===st?a=at:a===rt||a===ot?a=it:(a=at,r=void 0);const h=a===at&&t[e+1].startsWith("/>")?" ":"";o+=a===it?n+K:c>=0?(i.push(s),n.slice(0,c)+X+n.slice(c)+Y+h):n+Y+(-2===c?e:h)}return[mt(t,o+(t[n]||"")+(2===e?"":3===e?"":"")),i]};class _t{constructor({strings:t,_$litType$:e},n){let i;this.parts=[];let r=0,o=0;const a=t.length-1,s=this.parts,[l,c]=yt(t,e);if(this.el=_t.createElement(l,n),vt.currentNode=this.el.content,2===e||3===e){const t=this.el.content.firstChild;t.replaceWith(...t.childNodes)}for(;null!==(i=vt.nextNode())&&s.length0){i.textContent=q?q.emptyScript:"";for(let n=0;net(t)||"function"==typeof t?.[Symbol.iterator])(t)?this.k(t):this._(t)}O(t){return this._$AA.parentNode.insertBefore(t,this._$AB)}T(t){this._$AH!==t&&(this._$AR(),this._$AH=this.O(t))}_(t){this._$AH!==ft&&tt(this._$AH)?this._$AA.nextSibling.data=t:this.T(Q.createTextNode(t)),this._$AH=t}$(t){const{values:e,_$litType$:n}=t,i="number"==typeof n?this._$AC(t):(void 0===n.el&&(n.el=_t.createElement(mt(n.h,n.h[0]),this.options)),n);if(this._$AH?._$AD===i)this._$AH.p(e);else{const t=new bt(i,this),n=t.u(this.options);t.p(e),this.T(n),this._$AH=t}}_$AC(t){let e=gt.get(t.strings);return void 0===e&>.set(t.strings,e=new _t(t)),e}k(t){et(this._$AH)||(this._$AH=[],this._$AR());const e=this._$AH;let n,i=0;for(const r of t)i===e.length?e.push(n=new wt(this.O(J()),this.O(J()),this,this.options)):n=e[i],n._$AI(r),i++;i2||""!==n[0]||""!==n[1]?(this._$AH=Array(n.length-1).fill(new String),this.strings=n):this._$AH=ft}_$AI(t,e=this,n,i){const r=this.strings;let o=!1;if(void 0===r)t=xt(this,t,e,0),o=!tt(t)||t!==this._$AH&&t!==pt,o&&(this._$AH=t);else{const i=t;let a,s;for(t=r[0],a=0;a{const i=n?.renderBefore??e;let r=i._$litPart$;if(void 0===r){const t=n?.renderBefore??null;i._$litPart$=r=new wt(e.insertBefore(J(),t),t,void 0,n??{})}return r._$AI(t),r})(e,this.renderRoot,this.renderOptions)}connectedCallback(){super.connectedCallback(),this._$Do?.setConnected(!0)}disconnectedCallback(){super.disconnectedCallback(),this._$Do?.setConnected(!1)}render(){return pt}}At._$litElement$=!0,At.finalized=!0,It.litElementHydrateSupport?.({LitElement:At});const Pt=It.litElementPolyfillSupport;Pt?.({LitElement:At}),(It.litElementVersions??=[]).push("4.2.2"); +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +const Lt={attribute:!0,type:String,converter:F,reflect:!1,hasChanged:$},Et=(t=Lt,e,n)=>{const{kind:i,metadata:r}=n;let o=globalThis.litPropertyMetadata.get(r);if(void 0===o&&globalThis.litPropertyMetadata.set(r,o=new Map),"setter"===i&&((t=Object.create(t)).wrapped=!0),o.set(n.name,t),"accessor"===i){const{name:i}=n;return{set(n){const r=e.get.call(this);e.set.call(this,n),this.requestUpdate(i,r,t,!0,n)},init(e){return void 0!==e&&this.C(i,void 0,t,e),e}}}if("setter"===i){const{name:i}=n;return function(n){const r=this[i];e.call(this,n),this.requestUpdate(i,r,t,!0,n)}}throw Error("Unsupported decorator location: "+i)}; +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */function zt(t){return(e,n)=>"object"==typeof n?Et(t,e,n):((t,e,n)=>{const i=e.hasOwnProperty(n);return e.constructor.createProperty(n,t),i?Object.getOwnPropertyDescriptor(e,n):void 0})(t,e,n)} +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */function Ot(t){return zt({...t,state:!0,attribute:!1})}const Nt={"&":"&","<":"<",">":">",'"':""","'":"'"};function Rt(t){return String(t).replace(/[&<>"']/g,t=>Nt[t]??t)}const Ht="span_panel_list_columns";function Bt(){try{const t=localStorage.getItem(Ht);if(!t)return 1;const e=parseInt(t,10);return 1===e||2===e||3===e?e:1}catch{return 1}}function Ft(t){try{localStorage.setItem(Ht,String(t))}catch{}}function $t(t,e,n={}){const r=Rt(t.device_name||i("header.default_name")),o=Rt(t.serial||""),a=Rt(t.firmware||""),s="current"===(e.chart_metric||"power"),l=!1!==n.showSwitches;return`\n
\n
\n
\n

${r}

\n ${o}\n \n ${l?`
\n ${Rt(i("header.enable_switches"))}\n
\n \n
\n
`:""}\n
\n ${function(t,e){const n="current"===(e.chart_metric||"power"),r=!!t.panel_entities?.site_power,o=!!t.panel_entities?.dsm_state,a=!!t.panel_entities?.current_power,s=!!t.panel_entities?.feedthrough_power,l=!!t.panel_entities?.pv_power,c=!!t.panel_entities?.battery_level;return`\n
\n ${r?`\n
\n ${i("header.site")}\n
\n 0\n ${n?"A":"kW"}\n
\n
`:""}\n ${o?`\n
\n ${i("header.grid")}\n
\n --\n
\n
`:""}\n ${a?`\n
\n ${i("header.upstream")}\n
\n --\n ${n?"A":"kW"}\n
\n
`:""}\n ${s?`\n
\n ${i("header.downstream")}\n
\n --\n ${n?"A":"kW"}\n
\n
`:""}\n ${l?`\n
\n ${i("header.solar")}\n
\n --\n ${n?"A":"kW"}\n
\n
`:""}\n ${c?`\n
\n ${i("header.battery")}\n
\n \n %\n
\n
`:""}\n
\n `}(t,e)}\n
\n
\n
\n ${a}\n
\n \n \n
\n
\n
\n ${Object.entries(m).filter(([t])=>"unknown"!==t).map(([,t])=>{const e=Rt(t.icon),n=Rt(t.color),i=Rt(t.label());let r;return r=t.icon2?``:t.textLabel?`${Rt(t.textLabel)}`:``,`
${r}${i}
`}).join("")}\n
\n
\n
\n `}const Vt=g.power;function Wt(t){return Vt.unit(t)}function Ut(t){return(t<0?"-":"")+Vt.format(t)}function Gt(t){return(Math.abs(t)/1e3).toFixed(1)}function qt(t){return Math.ceil(t/2)}function jt(t){return t%2==0?1:0}function Xt(t){if(2!==t.length)return null;const[e,n]=[Math.min(...t),Math.max(...t)];return qt(e)===qt(n)?"row-span":jt(e)===jt(n)?"col-span":"row-span"}function Yt(t){const e=t.chart_metric??o;return g[e]??g[o]}function Zt(t,e){const n=function(t){return Yt(t).entityRole}(e);return t.entities?.[n]??t.entities?.power??null}function Kt(t){return new Promise(e=>setTimeout(e,t))}class Qt{constructor(t){this._store=t}async callWS(t,e,n){const i=n?.retries??3,r=n?.errorId??`ws:${String(e.type??"unknown")}`;return this._withRetry(()=>t.callWS(e),i,r,n?.errorMessage)}async callService(t,e,n,i,r,o){const a=o?.retries??3,s=o?.errorId??`svc:${e}.${n}`;return this._withRetry(()=>t.callService(e,n,i,r),a,s,o?.errorMessage)}async _withRetry(t,e,n,r){if(this._store.hasAnyPanelOffline())try{const e=await t();return this._store.remove(n),e}catch(t){const e=t instanceof Error?t:new Error(String(t));throw this._store.add({key:n,level:"error",message:r??i("error.panel_offline"),persistent:!1}),e}let o;for(let i=0;i<=e;i++)try{const e=await t();return this._store.remove(n),e}catch(t){if(o=t instanceof Error?t:new Error(String(t)),i{try{const n={};e&&(n.config_entry_id=e);const o={type:"call_service",domain:l,service:"get_monitoring_status",service_data:n,return_response:!0},a=this._retry?await this._retry.callWS(t,o,{errorId:"fetch:monitoring",errorMessage:i("error.monitoring_failed")}):await t.callWS(o),s=a?.response??null;return r===this._generation&&(this._status=s,this._lastFetch=Date.now()),s}catch(t){return console.warn("SPAN Panel: monitoring status fetch failed",t),r===this._generation&&(this._status=null),this._retry||this._errorStore?.add({key:"fetch:monitoring",level:"warning",message:i("error.monitoring_failed"),persistent:!1}),null}finally{this._inflight?.gen===r&&(this._inflight=null)}})();return this._inflight={gen:r,promise:o},o}invalidate(){this._lastFetch=0,this._generation++}get status(){return this._status}clear(){this._status=null,this._lastFetch=0,this._generation++}}class te{constructor(){this._caches=new Map,this._errorStore=null}get errorStore(){return this._errorStore}set errorStore(t){this._errorStore=t;for(const e of this._caches.values())e.errorStore=t}async fetchOne(t,e){let n=this._caches.get(e);return n||(n=new Jt,n.errorStore=this._errorStore,this._caches.set(e,n)),n.fetch(t,e)}invalidate(){for(const t of this._caches.values())t.invalidate()}clear(){this._caches.clear()}}function ee(t,e){return t?.circuits?t.circuits[e]??null:null}function ne(t,e,n,i){const r=[];return n||r.push("circuit-off"),i&&r.push("circuit-producer"),function(t){return!!t&&null!=t.over_threshold_since}(e)&&r.push("circuit-alert"),r.join(" ")}function ie(t,e,n,r,o,a,s,l,h,d=!1){const p=e.entities?.power,f=p?a.states[p]:null,g=f&&parseFloat(f.state)||0,v=e.device_type===u||g<0,y=e.entities?.switch,_=y?a.states[y]:null,x=_?"on"===_.state:(f?.attributes?.relay_state||e.relay_state)===c,b=e.breaker_rating_a,w=b?`${Math.round(b)}A`:"",S=Rt(e.name||i("grid.unknown")),C=Yt(s);let M;if("current"===C.entityRole){const t=e.entities?.current,n=t?a.states[t]:null,i=n&&parseFloat(n.state)||0;M=`${C.format(i)}A`}else M=`${Ut(g)}${Wt(g)}`;const k=h||"unknown";let T="";if("unknown"!==k){const t=m[k]??m.unknown??{icon:"mdi:help",color:"#999",label:()=>"Unknown"},e=Rt(t.label()),n=Rt(t.icon),i=Rt(t.color);if(t.icon2){T=`\n \n \n `}else if(t.textLabel){T=`\n \n ${Rt(t.textLabel)}\n `}else T=``}const D=``;let I="",A=l?.utilization_pct??null;if(null==A&&e.breaker_rating_a){const t=e.entities?.current,n=t?a.states[t]:null,i=n?Math.abs(parseFloat(n.state)||0):0;A=Math.round(i/e.breaker_rating_a*1e3)/10}if(null!=A){I=`=80?"utilization-warning":"utilization-normal"}">${Math.round(A)}%`}return`\n
\n
\n
\n ${w?`${w}`:""}\n ${I}\n ${S}\n
\n
\n \n ${M}\n \n ${!1!==e.is_user_controllable&&e.entities?.switch?`\n
\n ${i(x?"grid.on":"grid.off")}\n \n
\n `:""}\n
\n
\n
\n ${T}\n ${D}\n
\n
\n
\n `}function re(t,e){return`\n
\n \n
\n `}const oe={names:["power","battery power"],suffixes:["_power"]},ae={names:["battery level","battery percentage"],suffixes:["_battery_level","_battery_percentage"]},se={names:["state of energy"],suffixes:["_soe_kwh"]},le={names:["nameplate capacity"],suffixes:["_nameplate_capacity"]};function ce(t,e){if(!t.entities)return null;for(const[n,i]of Object.entries(t.entities)){if("sensor"!==i.domain)continue;const t=(i.original_name??"").toLowerCase();if(e.names.some(e=>t===e))return n;if(i.unique_id&&e.suffixes.some(t=>i.unique_id.endsWith(t)))return n}return null}function ue(t){return ce(t,oe)}function he(t){return ce(t,ae)}function de(t){return ce(t,se)}function pe(t){return ce(t,le)}function fe(t,e,n,i){const r=n.visible_sub_entities||{};let o="";if(!t.entities)return o;for(const[n,a]of Object.entries(t.entities)){if(i.has(n))continue;if(!0!==r[n])continue;const s=e.states[n];if(!s)continue;let l=a.original_name||s.attributes.friendly_name||n;const c=t.name||"";let u;if(l.startsWith(c+" ")&&(l=l.slice(c.length+1)),e.formatEntityState)u=e.formatEntityState(s);else{u=s.state;const t=s.attributes.unit_of_measurement||"";t&&(u+=" "+t)}if("Wh"===(s.attributes.unit_of_measurement||"")){const t=parseFloat(s.state);isNaN(t)||(u=(t/1e3).toFixed(1)+" kWh")}o+=`\n
\n ${Rt(l)}:\n ${Rt(u)}\n
\n `}return o}function ge(t,e,n,r,o,a){if(n){const e=[{key:`${p}${t}_soc`,title:i("subdevice.soc"),available:!!o},{key:`${p}${t}_soe`,title:i("subdevice.soe"),available:!!a},{key:`${p}${t}_power`,title:i("subdevice.power"),available:!!r}].filter(t=>t.available);return`\n
\n ${e.map(t=>`\n
\n
${Rt(t.title)}
\n
\n
\n `).join("")}\n
\n `}return r?`
`:""}function ve(t){const e=void 0!==t.history_days||void 0!==t.history_hours||void 0!==t.history_minutes,n=60*(60*(24*(e&&parseInt(String(t.history_days))||0)+(e&&parseInt(String(t.history_hours))||0))+(e?parseInt(String(t.history_minutes))||0:5))*1e3;return Math.max(n,6e4)}function me(t){const e=s[t];return e?e.ms:s[a].ms}function ye(t){const e=t/1e3;return e<=600?Math.ceil(e):Math.min(5e3,Math.ceil(e/5))}function _e(t){return Math.max(500,Math.floor(t/5e3))}function xe(t,e,n,i,r,o){t.has(e)||t.set(e,[]);const a=t.get(e);a.push({time:i,value:n});const s=a.findIndex(t=>t.time>=r);s>0?a.splice(0,s):-1===s&&(a.length=0),a.length>o&&a.splice(0,a.length-o)}function be(t,e,n=500){if(0===t.length)return t;t.sort((t,e)=>t.time-e.time);const i=[t[0]];for(let e=1;e=n&&i.push(t[e]);return i.length>e&&i.splice(0,i.length-e),i}async function we(t,e,n,i,r){const o=new Date(Date.now()-i).toISOString(),a=i/36e5>72?"hour":"5minute",s=await t.callWS({type:"recorder/statistics_during_period",start_time:o,statistic_ids:e,period:a,types:["mean"]});for(const[t,e]of Object.entries(s)){const i=n.get(t);if(!i||!e)continue;const o=[];for(const t of e){const e=t.mean;if(null==e||!Number.isFinite(e))continue;const n=t.start;n>0&&o.push({time:n,value:e})}if(o.length>0){const t=r.get(i)||[],e=[...o,...t];e.sort((t,e)=>t.time-e.time),r.set(i,e)}}}async function Se(t,e,n,i,r){const o=new Date(Date.now()-i).toISOString(),a=await t.callWS({type:"history/history_during_period",start_time:o,entity_ids:e,minimal_response:!0,significant_changes_only:!0,no_attributes:!0}),s=ye(i),l=_e(i);for(const[t,e]of Object.entries(a)){const i=n.get(t);if(!i||!e)continue;const o=[];for(const t of e){const e=parseFloat(t.s);if(!Number.isFinite(e))continue;const n=1e3*(t.lu||t.lc||0);n>0&&o.push({time:n,value:e})}if(o.length>0){const t=r.get(i)||[],e=[...o,...t];r.set(i,be(e,s,l))}}}function Ce(t){if(!t.sub_devices)return[];const e=[];for(const[n,i]of Object.entries(t.sub_devices)){const t={power:ue(i)};i.type===h&&(t.soc=he(i),t.soe=de(i));for(const[i,r]of Object.entries(t))r&&e.push({entityId:r,key:`${p}${n}_${i}`,devId:n})}return e}async function Me(t,e,n,i,r,o){if(!e||!t)return;const a=new Map;for(const[t,i]of Object.entries(e.circuits)){const e=Zt(i,n);if(!e)continue;let o;o=r&&r.has(t)?me(r.get(t)):ve(n),a.has(o)||a.set(o,{entityIds:[],uuidByEntity:new Map});const s=a.get(o);s.entityIds.push(e),s.uuidByEntity.set(e,t)}for(const{entityId:t,key:i,devId:r}of Ce(e)){let e;e=o&&o.has(r)?me(o.get(r)):ve(n),a.has(e)||a.set(e,{entityIds:[],uuidByEntity:new Map});const s=a.get(e);s.entityIds.push(t),s.uuidByEntity.set(t,i)}const s=[];for(const[e,n]of a){if(0===n.entityIds.length)continue;e>2592e5?s.push(we(t,n.entityIds,n.uuidByEntity,e,i)):s.push(Se(t,n.entityIds,n.uuidByEntity,e,i))}await Promise.all(s)}var ke=function(){this.firefox=!1,this.ie=!1,this.edge=!1,this.newEdge=!1,this.weChat=!1},Te=new function(){this.browser=new ke,this.node=!1,this.wxa=!1,this.worker=!1,this.svgSupported=!1,this.touchEventsSupported=!1,this.pointerEventsSupported=!1,this.domSupported=!1,this.transformSupported=!1,this.transform3dSupported=!1,this.hasGlobalWindow="undefined"!=typeof window};"object"==typeof wx&&"function"==typeof wx.getSystemInfoSync?(Te.wxa=!0,Te.touchEventsSupported=!0):"undefined"==typeof document&&"undefined"!=typeof self?Te.worker=!0:!Te.hasGlobalWindow||"Deno"in window||"undefined"!=typeof navigator&&"string"==typeof navigator.userAgent&&navigator.userAgent.indexOf("Node.js")>-1?(Te.node=!0,Te.svgSupported=!0):function(t,e){var n=e.browser,i=t.match(/Firefox\/([\d.]+)/),r=t.match(/MSIE\s([\d.]+)/)||t.match(/Trident\/.+?rv:(([\d.]+))/),o=t.match(/Edge?\/([\d.]+)/),a=/micromessenger/i.test(t);i&&(n.firefox=!0,n.version=i[1]);r&&(n.ie=!0,n.version=r[1]);o&&(n.edge=!0,n.version=o[1],n.newEdge=+o[1].split(".")[0]>18);a&&(n.weChat=!0);e.svgSupported="undefined"!=typeof SVGRect,e.touchEventsSupported="ontouchstart"in window&&!n.ie&&!n.edge,e.pointerEventsSupported="onpointerdown"in window&&(n.edge||n.ie&&+n.version>=11);var s=e.domSupported="undefined"!=typeof document;if(s){var l=document.documentElement.style;e.transform3dSupported=(n.ie&&"transition"in l||n.edge||"WebKitCSSMatrix"in window&&"m11"in new WebKitCSSMatrix||"MozPerspective"in l)&&!("OTransition"in l),e.transformSupported=e.transform3dSupported||n.ie&&+n.version>=9}}(navigator.userAgent,Te);var De="sans-serif",Ie="12px "+De;var Ae,Pe,Le=function(t){var e={};if("undefined"==typeof JSON)return e;for(var n=0;n=0)o=r*t.length;else for(var a=0;a>1)%2;a.style.cssText=["position: absolute","visibility: hidden","padding: 0","margin: 0","border-width: 0","user-select: none","width:0","height:0",i[s]+":0",r[l]+":0",i[1-s]+":auto",r[1-l]+":auto",""].join("!important;"),t.appendChild(a),n.push(a)}return e.clearMarkers=function(){en(n,function(t){t.parentNode&&t.parentNode.removeChild(t)})},n}(e,o),s=function(t,e,n){for(var i=n?"invTrans":"trans",r=e[i],o=e.srcCoords,a=[],s=[],l=!0,c=0;c<4;c++){var u=t[c].getBoundingClientRect(),h=2*c,d=u.left,p=u.top;a.push(d,p),l=l&&o&&d===o[h]&&p===o[h+1],s.push(t[c].offsetLeft,t[c].offsetTop)}return l&&r?r:(e.srcCoords=a,e[i]=n?ei(s,a):ei(a,s))}(a,o,r);if(s)return s(t,n,i),!0}return!1}function oi(t){return"CANVAS"===t.nodeName.toUpperCase()}var ai=/([&<>"'])/g,si={"&":"&","<":"<",">":">",'"':""","'":"'"};function li(t){return null==t?"":(t+"").replace(ai,function(t,e){return si[e]})}var ci=/^(?:mouse|pointer|contextmenu|drag|drop)|click/,ui=[],hi=Te.browser.firefox&&+Te.browser.version.split(".")[0]<39;function di(t,e,n,i){return n=n||{},i?pi(t,e,n):hi&&null!=e.layerX&&e.layerX!==e.offsetX?(n.zrX=e.layerX,n.zrY=e.layerY):null!=e.offsetX?(n.zrX=e.offsetX,n.zrY=e.offsetY):pi(t,e,n),n}function pi(t,e,n){if(Te.domSupported&&t.getBoundingClientRect){var i=e.clientX,r=e.clientY;if(oi(t)){var o=t.getBoundingClientRect();return n.zrX=i-o.left,void(n.zrY=r-o.top)}if(ri(ui,t,i,r))return n.zrX=ui[0],void(n.zrY=ui[1])}n.zrX=n.zrY=0}function fi(t){return t||window.event}function gi(t,e,n){if(null!=(e=fi(e)).zrX)return e;var i=e.type;if(i&&i.indexOf("touch")>=0){var r="touchend"!==i?e.targetTouches[0]:e.changedTouches[0];r&&di(t,r,e,n)}else{di(t,e,e,n);var o=function(t){var e=t.wheelDelta;if(e)return e;var n=t.deltaX,i=t.deltaY;if(null==n||null==i)return e;return 3*(0!==i?Math.abs(i):Math.abs(n))*(i>0?-1:i<0?1:n>0?-1:1)}(e);e.zrDelta=o?o/120:-(e.detail||0)/3}var a=e.button;return null==e.which&&void 0!==a&&ci.test(e.type)&&(e.which=1&a?1:2&a?3:4&a?2:0),e}function vi(t,e,n,i){t.removeEventListener(e,n,i)}var mi=function(t){t.preventDefault(),t.stopPropagation(),t.cancelBubble=!0},yi=function(){function t(){this._track=[]}return t.prototype.recognize=function(t,e,n){return this._doTrack(t,e,n),this._recognize(t)},t.prototype.clear=function(){return this._track.length=0,this},t.prototype._doTrack=function(t,e,n){var i=t.touches;if(i){for(var r={points:[],touches:[],target:e,event:t},o=0,a=i.length;o1&&r&&r.length>1){var a=_i(r)/_i(o);!isFinite(a)&&(a=1),e.pinchScale=a;var s=[((i=r)[0][0]+i[1][0])/2,(i[0][1]+i[1][1])/2];return e.pinchX=s[0],e.pinchY=s[1],{type:"pinch",target:t[0].target,event:e}}}}};function bi(t){return t[0]=1,t[1]=0,t[2]=0,t[3]=1,t[4]=0,t[5]=0,t}function wi(t,e){return t[0]=e[0],t[1]=e[1],t[2]=e[2],t[3]=e[3],t[4]=e[4],t[5]=e[5],t}function Si(t,e,n){var i=e[0]*n[0]+e[2]*n[1],r=e[1]*n[0]+e[3]*n[1],o=e[0]*n[2]+e[2]*n[3],a=e[1]*n[2]+e[3]*n[3],s=e[0]*n[4]+e[2]*n[5]+e[4],l=e[1]*n[4]+e[3]*n[5]+e[5];return t[0]=i,t[1]=r,t[2]=o,t[3]=a,t[4]=s,t[5]=l,t}function Ci(t,e,n){return t[0]=e[0],t[1]=e[1],t[2]=e[2],t[3]=e[3],t[4]=e[4]+n[0],t[5]=e[5]+n[1],t}function Mi(t,e,n,i){void 0===i&&(i=[0,0]);var r=e[0],o=e[2],a=e[4],s=e[1],l=e[3],c=e[5],u=Math.sin(n),h=Math.cos(n);return t[0]=r*h+s*u,t[1]=-r*u+s*h,t[2]=o*h+l*u,t[3]=-o*u+h*l,t[4]=h*(a-i[0])+u*(c-i[1])+i[0],t[5]=h*(c-i[1])-u*(a-i[0])+i[1],t}function ki(t,e){var n=e[0],i=e[2],r=e[4],o=e[1],a=e[3],s=e[5],l=n*a-o*i;return l?(l=1/l,t[0]=a*l,t[1]=-o*l,t[2]=-i*l,t[3]=n*l,t[4]=(i*s-a*r)*l,t[5]=(o*r-n*s)*l,t):null}var Ti=function(){function t(t,e){this.x=t||0,this.y=e||0}return t.prototype.copy=function(t){return this.x=t.x,this.y=t.y,this},t.prototype.clone=function(){return new t(this.x,this.y)},t.prototype.set=function(t,e){return this.x=t,this.y=e,this},t.prototype.equal=function(t){return t.x===this.x&&t.y===this.y},t.prototype.add=function(t){return this.x+=t.x,this.y+=t.y,this},t.prototype.scale=function(t){this.x*=t,this.y*=t},t.prototype.scaleAndAdd=function(t,e){this.x+=t.x*e,this.y+=t.y*e},t.prototype.sub=function(t){return this.x-=t.x,this.y-=t.y,this},t.prototype.dot=function(t){return this.x*t.x+this.y*t.y},t.prototype.len=function(){return Math.sqrt(this.x*this.x+this.y*this.y)},t.prototype.lenSquare=function(){return this.x*this.x+this.y*this.y},t.prototype.normalize=function(){var t=this.len();return this.x/=t,this.y/=t,this},t.prototype.distance=function(t){var e=this.x-t.x,n=this.y-t.y;return Math.sqrt(e*e+n*n)},t.prototype.distanceSquare=function(t){var e=this.x-t.x,n=this.y-t.y;return e*e+n*n},t.prototype.negate=function(){return this.x=-this.x,this.y=-this.y,this},t.prototype.transform=function(t){if(t){var e=this.x,n=this.y;return this.x=t[0]*e+t[2]*n+t[4],this.y=t[1]*e+t[3]*n+t[5],this}},t.prototype.toArray=function(t){return t[0]=this.x,t[1]=this.y,t},t.prototype.fromArray=function(t){this.x=t[0],this.y=t[1]},t.set=function(t,e,n){t.x=e,t.y=n},t.copy=function(t,e){t.x=e.x,t.y=e.y},t.len=function(t){return Math.sqrt(t.x*t.x+t.y*t.y)},t.lenSquare=function(t){return t.x*t.x+t.y*t.y},t.dot=function(t,e){return t.x*e.x+t.y*e.y},t.add=function(t,e,n){t.x=e.x+n.x,t.y=e.y+n.y},t.sub=function(t,e,n){t.x=e.x-n.x,t.y=e.y-n.y},t.scale=function(t,e,n){t.x=e.x*n,t.y=e.y*n},t.scaleAndAdd=function(t,e,n,i){t.x=e.x+n.x*i,t.y=e.y+n.y*i},t.lerp=function(t,e,n,i){var r=1-i;t.x=r*e.x+i*n.x,t.y=r*e.y+i*n.y},t}(),Di=Math.min,Ii=Math.max,Ai=Math.abs,Pi=["x","y"],Li=["width","height"],Ei=new Ti,zi=new Ti,Oi=new Ti,Ni=new Ti,Ri=Gi(),Hi=Ri.minTv,Bi=Ri.maxTv,Fi=[0,0],$i=function(){function t(e,n,i,r){t.set(this,e,n,i,r)}return t.set=function(t,e,n,i,r){return i<0&&(e+=i,i=-i),r<0&&(n+=r,r=-r),t.x=e,t.y=n,t.width=i,t.height=r,t},t.prototype.union=function(t){var e=Di(t.x,this.x),n=Di(t.y,this.y);isFinite(this.x)&&isFinite(this.width)?this.width=Ii(t.x+t.width,this.x+this.width)-e:this.width=t.width,isFinite(this.y)&&isFinite(this.height)?this.height=Ii(t.y+t.height,this.y+this.height)-n:this.height=t.height,this.x=e,this.y=n},t.prototype.applyTransform=function(e){t.applyTransform(this,this,e)},t.prototype.calculateTransform=function(t){var e=this,n=t.width/e.width,i=t.height/e.height,r=[1,0,0,1,0,0];return Ci(r,r,[-e.x,-e.y]),function(t,e,n){var i=n[0],r=n[1];t[0]=e[0]*i,t[1]=e[1]*r,t[2]=e[2]*i,t[3]=e[3]*r,t[4]=e[4]*i,t[5]=e[5]*r}(r,r,[n,i]),Ci(r,r,[t.x,t.y]),r},t.prototype.intersect=function(e,n,i){return t.intersect(this,e,n,i)},t.intersect=function(e,n,i,r){i&&Ti.set(i,0,0);var o=r&&r.outIntersectRect||null,a=r&&r.clamp;if(o&&(o.x=o.y=o.width=o.height=NaN),!e||!n)return!1;e instanceof t||(e=t.set(Vi,e.x,e.y,e.width,e.height)),n instanceof t||(n=t.set(Wi,n.x,n.y,n.width,n.height));var s=!!i;Ri.reset(r,s);var l=Ri.touchThreshold,c=e.x+l,u=e.x+e.width-l,h=e.y+l,d=e.y+e.height-l,p=n.x+l,f=n.x+n.width-l,g=n.y+l,v=n.y+n.height-l;if(c>u||h>d||p>f||g>v)return!1;var m=!(u=t.x&&e<=t.x+t.width&&n>=t.y&&n<=t.y+t.height},t.prototype.contain=function(e,n){return t.contain(this,e,n)},t.prototype.clone=function(){return new t(this.x,this.y,this.width,this.height)},t.prototype.copy=function(e){t.copy(this,e)},t.prototype.plain=function(){return{x:this.x,y:this.y,width:this.width,height:this.height}},t.prototype.isFinite=function(){return isFinite(this.x)&&isFinite(this.y)&&isFinite(this.width)&&isFinite(this.height)},t.prototype.isZero=function(){return 0===this.width||0===this.height},t.create=function(e){return new t(e.x,e.y,e.width,e.height)},t.copy=function(t,e){return t.x=e.x,t.y=e.y,t.width=e.width,t.height=e.height,t},t.applyTransform=function(e,n,i){if(i){if(i[1]<1e-5&&i[1]>-1e-5&&i[2]<1e-5&&i[2]>-1e-5){var r=i[0],o=i[3],a=i[4],s=i[5];return e.x=n.x*r+a,e.y=n.y*o+s,e.width=n.width*r,e.height=n.height*o,e.width<0&&(e.x+=e.width,e.width=-e.width),void(e.height<0&&(e.y+=e.height,e.height=-e.height))}Ei.x=Oi.x=n.x,Ei.y=Ni.y=n.y,zi.x=Ni.x=n.x+n.width,zi.y=Oi.y=n.y+n.height,Ei.transform(i),Ni.transform(i),zi.transform(i),Oi.transform(i),e.x=Di(Ei.x,zi.x,Oi.x,Ni.x),e.y=Di(Ei.y,zi.y,Oi.y,Ni.y);var l=Ii(Ei.x,zi.x,Oi.x,Ni.x),c=Ii(Ei.y,zi.y,Oi.y,Ni.y);e.width=l-e.x,e.height=c-e.y}else e!==n&&t.copy(e,n)},t}(),Vi=new $i(0,0,0,0),Wi=new $i(0,0,0,0);function Ui(t,e,n,i,r,o,a,s){var l=Ai(e-n),c=Ai(i-t),u=Di(l,c),h=Pi[r],d=Pi[1-r],p=Li[r];e=c||!Ri.bidirectional)&&(Hi[h]=-c,Hi[d]=0,Ri.useDir&&Ri.calcDirMTV())))}function Gi(){var t=0,e=new Ti,n=new Ti,i={minTv:new Ti,maxTv:new Ti,useDir:!1,dirMinTv:new Ti,touchThreshold:0,bidirectional:!0,negativeSize:!1,reset:function(r,o){i.touchThreshold=0,r&&null!=r.touchThreshold&&(i.touchThreshold=Ii(0,r.touchThreshold)),i.negativeSize=!1,o&&(i.minTv.set(1/0,1/0),i.maxTv.set(0,0),i.useDir=!1,r&&null!=r.direction&&(i.useDir=!0,i.dirMinTv.copy(i.minTv),n.copy(i.minTv),t=r.direction,i.bidirectional=null==r.bidirectional||!!r.bidirectional,i.bidirectional||e.set(Math.cos(t),Math.sin(t))))},calcDirMTV:function(){var o=i.minTv,a=i.dirMinTv,s=o.y*o.y+o.x*o.x,l=Math.sin(t),c=Math.cos(t),u=l*o.y+c*o.x;r(u)?r(o.x)&&r(o.y)&&a.set(0,0):(n.x=s*c/u,n.y=s*l/u,r(n.x)&&r(n.y)?a.set(0,0):(i.bidirectional||e.dot(n)>0)&&n.len()=0;c--){var u=i[c];u===n||u.ignore||u.ignoreCoarsePointer||u.parent&&u.parent.ignoreCoarsePointer||(Ki.copy(u.getBoundingRect()),u.transform&&Ki.applyTransform(u.transform),Ki.intersect(l)&&o.push(u))}if(o.length)for(var h=Math.PI/12,d=2*Math.PI,p=0;p=0;o--){var a=t[o],s=void 0;if(a!==r&&!a.ignore&&(s=Ji(a,n,i))&&(!e.topTarget&&(e.topTarget=a),s!==qi)){e.target=a;break}}}function er(t,e,n){var i=t.painter;return e<0||e>i.getWidth()||n<0||n>i.getHeight()}en(["click","mousedown","mouseup","mousewheel","dblclick","contextmenu"],function(t){Qi.prototype[t]=function(e){var n,i,r=e.zrX,o=e.zrY,a=er(this,r,o);if("mouseup"===t&&a||(i=(n=this.findHover(r,o)).target),"mousedown"===t)this._downEl=i,this._downPoint=[e.zrX,e.zrY],this._upEl=i;else if("mouseup"===t)this._upEl=i;else if("click"===t){if(this._downEl!==this._upEl||!this._downPoint||Gn(this._downPoint,[e.zrX,e.zrY])>4)return;this._downPoint=null}this.dispatchToElement(n,t,e)}});function nr(t,e,n,i){var r=e+1;if(r===n)return 1;if(i(t[r++],t[e])<0){for(;r=0;)r++;return r-e}function ir(t,e,n,i,r){for(i===e&&i++;i>>1])<0?l=o:s=o+1;var c=i-s;switch(c){case 3:t[s+3]=t[s+2];case 2:t[s+2]=t[s+1];case 1:t[s+1]=t[s];break;default:for(;c>0;)t[s+c]=t[s+c-1],c--}t[s]=a}}function rr(t,e,n,i,r,o){var a=0,s=0,l=1;if(o(t,e[n+r])>0){for(s=i-r;l0;)a=l,(l=1+(l<<1))<=0&&(l=s);l>s&&(l=s),a+=r,l+=r}else{for(s=r+1;ls&&(l=s);var c=a;a=r-l,l=r-c}for(a++;a>>1);o(t,e[n+u])>0?a=u+1:l=u}return l}function or(t,e,n,i,r,o){var a=0,s=0,l=1;if(o(t,e[n+r])<0){for(s=r+1;ls&&(l=s);var c=a;a=r-l,l=r-c}else{for(s=i-r;l=0;)a=l,(l=1+(l<<1))<=0&&(l=s);l>s&&(l=s),a+=r,l+=r}for(a++;a>>1);o(t,e[n+u])<0?l=u:a=u+1}return l}function ar(t,e){var n,i,r=7,o=0,a=[];function s(s){var l=n[s],c=i[s],u=n[s+1],h=i[s+1];i[s]=c+h,s===o-3&&(n[s+1]=n[s+2],i[s+1]=i[s+2]),o--;var d=or(t[u],t,l,c,0,e);l+=d,0!==(c-=d)&&0!==(h=rr(t[l+c-1],t,u,h,h-1,e))&&(c<=h?function(n,i,o,s){var l=0;for(l=0;l=7||p>=7);if(f)break;g<0&&(g=0),g+=2}if((r=g)<1&&(r=1),1===i){for(l=0;l=0;l--)t[p+l]=t[d+l];return void(t[h]=a[u])}var f=r;for(;;){var g=0,v=0,m=!1;do{if(e(a[u],t[c])<0){if(t[h--]=t[c--],g++,v=0,0===--i){m=!0;break}}else if(t[h--]=a[u--],v++,g=0,1===--s){m=!0;break}}while((g|v)=0;l--)t[p+l]=t[d+l];if(0===i){m=!0;break}}if(t[h--]=a[u--],1===--s){m=!0;break}if(0!==(v=s-rr(t[c],a,0,s,s-1,e))){for(s-=v,p=(h-=v)+1,d=(u-=v)+1,l=0;l=7||v>=7);if(m)break;f<0&&(f=0),f+=2}(r=f)<1&&(r=1);if(1===s){for(p=(h-=i)+1,d=(c-=i)+1,l=i-1;l>=0;l--)t[p+l]=t[d+l];t[h]=a[u]}else{if(0===s)throw new Error;for(d=h-(s-1),l=0;l1;){var t=o-2;if(t>=1&&i[t-1]<=i[t]+i[t+1]||t>=2&&i[t-2]<=i[t]+i[t-1])i[t-1]i[t+1])break;s(t)}},forceMergeRuns:function(){for(;o>1;){var t=o-2;t>0&&i[t-1]=32;)e|=1&t,t>>=1;return t+e}(r);do{if((o=nr(t,n,i,e))s&&(l=s),ir(t,n,n+l,n+o,e),o=l}a.pushRun(n,o),a.mergeRuns(),r-=o,n+=o}while(0!==r);a.forceMergeRuns()}}}var lr=!1;function cr(){lr||(lr=!0,console.warn("z / z2 / zlevel of displayable is invalid, which may cause unexpected errors"))}function ur(t,e){return t.zlevel===e.zlevel?t.z===e.z?t.z2-e.z2:t.z-e.z:t.zlevel-e.zlevel}var hr,dr=function(){function t(){this._roots=[],this._displayList=[],this._displayListLen=0,this.displayableSortFunc=ur}return t.prototype.traverse=function(t,e){for(var n=0;n=0&&this._roots.splice(i,1)}},t.prototype.delAllRoots=function(){this._roots=[],this._displayList=[],this._displayListLen=0},t.prototype.getRoots=function(){return this._roots},t.prototype.dispose=function(){this._displayList=null,this._roots=null},t}();hr=Te.hasGlobalWindow&&(window.requestAnimationFrame&&window.requestAnimationFrame.bind(window)||window.msRequestAnimationFrame&&window.msRequestAnimationFrame.bind(window)||window.mozRequestAnimationFrame||window.webkitRequestAnimationFrame)||function(t){return setTimeout(t,16)};var pr={linear:function(t){return t},quadraticIn:function(t){return t*t},quadraticOut:function(t){return t*(2-t)},quadraticInOut:function(t){return(t*=2)<1?.5*t*t:-.5*(--t*(t-2)-1)},cubicIn:function(t){return t*t*t},cubicOut:function(t){return--t*t*t+1},cubicInOut:function(t){return(t*=2)<1?.5*t*t*t:.5*((t-=2)*t*t+2)},quarticIn:function(t){return t*t*t*t},quarticOut:function(t){return 1- --t*t*t*t},quarticInOut:function(t){return(t*=2)<1?.5*t*t*t*t:-.5*((t-=2)*t*t*t-2)},quinticIn:function(t){return t*t*t*t*t},quinticOut:function(t){return--t*t*t*t*t+1},quinticInOut:function(t){return(t*=2)<1?.5*t*t*t*t*t:.5*((t-=2)*t*t*t*t+2)},sinusoidalIn:function(t){return 1-Math.cos(t*Math.PI/2)},sinusoidalOut:function(t){return Math.sin(t*Math.PI/2)},sinusoidalInOut:function(t){return.5*(1-Math.cos(Math.PI*t))},exponentialIn:function(t){return 0===t?0:Math.pow(1024,t-1)},exponentialOut:function(t){return 1===t?1:1-Math.pow(2,-10*t)},exponentialInOut:function(t){return 0===t?0:1===t?1:(t*=2)<1?.5*Math.pow(1024,t-1):.5*(2-Math.pow(2,-10*(t-1)))},circularIn:function(t){return 1-Math.sqrt(1-t*t)},circularOut:function(t){return Math.sqrt(1- --t*t)},circularInOut:function(t){return(t*=2)<1?-.5*(Math.sqrt(1-t*t)-1):.5*(Math.sqrt(1-(t-=2)*t)+1)},elasticIn:function(t){var e,n=.1;return 0===t?0:1===t?1:(!n||n<1?(n=1,e=.1):e=.4*Math.asin(1/n)/(2*Math.PI),-n*Math.pow(2,10*(t-=1))*Math.sin((t-e)*(2*Math.PI)/.4))},elasticOut:function(t){var e,n=.1;return 0===t?0:1===t?1:(!n||n<1?(n=1,e=.1):e=.4*Math.asin(1/n)/(2*Math.PI),n*Math.pow(2,-10*t)*Math.sin((t-e)*(2*Math.PI)/.4)+1)},elasticInOut:function(t){var e,n=.1,i=.4;return 0===t?0:1===t?1:(!n||n<1?(n=1,e=.1):e=i*Math.asin(1/n)/(2*Math.PI),(t*=2)<1?n*Math.pow(2,10*(t-=1))*Math.sin((t-e)*(2*Math.PI)/i)*-.5:n*Math.pow(2,-10*(t-=1))*Math.sin((t-e)*(2*Math.PI)/i)*.5+1)},backIn:function(t){var e=1.70158;return t*t*((e+1)*t-e)},backOut:function(t){var e=1.70158;return--t*t*((e+1)*t+e)+1},backInOut:function(t){var e=2.5949095;return(t*=2)<1?t*t*((e+1)*t-e)*.5:.5*((t-=2)*t*((e+1)*t+e)+2)},bounceIn:function(t){return 1-pr.bounceOut(1-t)},bounceOut:function(t){return t<1/2.75?7.5625*t*t:t<2/2.75?7.5625*(t-=1.5/2.75)*t+.75:t<2.5/2.75?7.5625*(t-=2.25/2.75)*t+.9375:7.5625*(t-=2.625/2.75)*t+.984375},bounceInOut:function(t){return t<.5?.5*pr.bounceIn(2*t):.5*pr.bounceOut(2*t-1)+.5}},fr=Math.pow,gr=Math.sqrt,vr=1e-8,mr=1e-4,yr=gr(3),_r=1/3,xr=Hn(),br=Hn(),wr=Hn();function Sr(t){return t>-1e-8&&tvr||t<-1e-8}function Mr(t,e,n,i,r){var o=1-r;return o*o*(o*t+3*r*e)+r*r*(r*i+3*o*n)}function kr(t,e,n,i,r){var o=1-r;return 3*(((e-t)*o+2*(n-e)*r)*o+(i-n)*r*r)}function Tr(t,e,n,i,r,o){var a=i+3*(e-n)-t,s=3*(n-2*e+t),l=3*(e-t),c=t-r,u=s*s-3*a*l,h=s*l-9*a*c,d=l*l-3*s*c,p=0;if(Sr(u)&&Sr(h)){if(Sr(s))o[0]=0;else(C=-l/s)>=0&&C<=1&&(o[p++]=C)}else{var f=h*h-4*u*d;if(Sr(f)){var g=h/u,v=-g/2;(C=-s/a+g)>=0&&C<=1&&(o[p++]=C),v>=0&&v<=1&&(o[p++]=v)}else if(f>0){var m=gr(f),y=u*s+1.5*a*(-h+m),_=u*s+1.5*a*(-h-m);(C=(-s-((y=y<0?-fr(-y,_r):fr(y,_r))+(_=_<0?-fr(-_,_r):fr(_,_r))))/(3*a))>=0&&C<=1&&(o[p++]=C)}else{var x=(2*u*s-3*a*h)/(2*gr(u*u*u)),b=Math.acos(x)/3,w=gr(u),S=Math.cos(b),C=(-s-2*w*S)/(3*a),M=(v=(-s+w*(S+yr*Math.sin(b)))/(3*a),(-s+w*(S-yr*Math.sin(b)))/(3*a));C>=0&&C<=1&&(o[p++]=C),v>=0&&v<=1&&(o[p++]=v),M>=0&&M<=1&&(o[p++]=M)}}return p}function Dr(t,e,n,i,r){var o=6*n-12*e+6*t,a=9*e+3*i-3*t-9*n,s=3*e-3*t,l=0;if(Sr(a)){if(Cr(o))(u=-s/o)>=0&&u<=1&&(r[l++]=u)}else{var c=o*o-4*a*s;if(Sr(c))r[0]=-o/(2*a);else if(c>0){var u,h=gr(c),d=(-o-h)/(2*a);(u=(-o+h)/(2*a))>=0&&u<=1&&(r[l++]=u),d>=0&&d<=1&&(r[l++]=d)}}return l}function Ir(t,e,n,i,r,o){var a=(e-t)*r+t,s=(n-e)*r+e,l=(i-n)*r+n,c=(s-a)*r+a,u=(l-s)*r+s,h=(u-c)*r+c;o[0]=t,o[1]=a,o[2]=c,o[3]=h,o[4]=h,o[5]=u,o[6]=l,o[7]=i}function Ar(t,e,n,i,r,o,a,s,l){for(var c=t,u=e,h=0,d=1/l,p=1;p<=l;p++){var f=p*d,g=Mr(t,n,r,a,f),v=Mr(e,i,o,s,f),m=g-c,y=v-u;h+=Math.sqrt(m*m+y*y),c=g,u=v}return h}function Pr(t,e,n,i){var r=1-i;return r*(r*t+2*i*e)+i*i*n}function Lr(t,e,n,i){return 2*((1-i)*(e-t)+i*(n-e))}function Er(t,e,n){var i=t+n-2*e;return 0===i?.5:(t-e)/i}function zr(t,e,n,i,r){var o=(e-t)*i+t,a=(n-e)*i+e,s=(a-o)*i+o;r[0]=t,r[1]=o,r[2]=s,r[3]=s,r[4]=a,r[5]=n}function Or(t,e,n,i,r,o,a){for(var s=t,l=e,c=0,u=1/a,h=1;h<=a;h++){var d=h*u,p=Pr(t,n,r,d),f=Pr(e,i,o,d),g=p-s,v=f-l;c+=Math.sqrt(g*g+v*v),s=p,l=f}return c}var Nr=/cubic-bezier\(([0-9,\.e ]+)\)/;function Rr(t){var e=t&&Nr.exec(t);if(e){var n=e[1].split(","),i=+kn(n[0]),r=+kn(n[1]),o=+kn(n[2]),a=+kn(n[3]);if(isNaN(i+r+o+a))return;var s=[];return function(t){return t<=0?0:t>=1?1:Tr(0,i,o,1,t,s)&&Mr(0,r,a,1,s[0])}}}var Hr=function(){function t(t){this._inited=!1,this._startTime=0,this._pausedTime=0,this._paused=!1,this._life=t.life||1e3,this._delay=t.delay||0,this.loop=t.loop||!1,this.onframe=t.onframe||Nn,this.ondestroy=t.ondestroy||Nn,this.onrestart=t.onrestart||Nn,t.easing&&this.setEasing(t.easing)}return t.prototype.step=function(t,e){if(this._inited||(this._startTime=t+this._delay,this._inited=!0),!this._paused){var n=this._life,i=t-this._startTime-this._pausedTime,r=i/n;r<0&&(r=0),r=Math.min(r,1);var o=this.easingFunc,a=o?o(r):r;if(this.onframe(a),1===r){if(!this.loop)return!0;var s=i%n;this._startTime=t-s,this._pausedTime=0,this.onrestart()}return!1}this._pausedTime+=e},t.prototype.pause=function(){this._paused=!0},t.prototype.resume=function(){this._paused=!1},t.prototype.setEasing=function(t){this.easing=t,this.easingFunc=un(t)?t:pr[t]||Rr(t)},t}(),Br=function(t){this.value=t},Fr=function(){function t(){this._len=0}return t.prototype.insert=function(t){var e=new Br(t);return this.insertEntry(e),e},t.prototype.insertEntry=function(t){this.head?(this.tail.next=t,t.prev=this.tail,t.next=null,this.tail=t):this.head=this.tail=t,this._len++},t.prototype.remove=function(t){var e=t.prev,n=t.next;e?e.next=n:this.head=n,n?n.prev=e:this.tail=e,t.next=t.prev=null,this._len--},t.prototype.len=function(){return this._len},t.prototype.clear=function(){this.head=this.tail=null,this._len=0},t}(),$r=function(){function t(t){this._list=new Fr,this._maxSize=10,this._map={},this._maxSize=t}return t.prototype.put=function(t,e){var n=this._list,i=this._map,r=null;if(null==i[t]){var o=n.len(),a=this._lastRemovedEntry;if(o>=this._maxSize&&o>0){var s=n.head;n.remove(s),delete i[s.key],r=s.value,this._lastRemovedEntry=s}a?a.value=e:a=new Br(e),a.key=t,n.insertEntry(a),i[t]=a}return r},t.prototype.get=function(t){var e=this._map[t],n=this._list;if(null!=e)return e!==n.tail&&(n.remove(e),n.insertEntry(e)),e.value},t.prototype.clear=function(){this._list.clear(),this._map={}},t.prototype.len=function(){return this._list.len()},t}(),Vr={transparent:[0,0,0,0],aliceblue:[240,248,255,1],antiquewhite:[250,235,215,1],aqua:[0,255,255,1],aquamarine:[127,255,212,1],azure:[240,255,255,1],beige:[245,245,220,1],bisque:[255,228,196,1],black:[0,0,0,1],blanchedalmond:[255,235,205,1],blue:[0,0,255,1],blueviolet:[138,43,226,1],brown:[165,42,42,1],burlywood:[222,184,135,1],cadetblue:[95,158,160,1],chartreuse:[127,255,0,1],chocolate:[210,105,30,1],coral:[255,127,80,1],cornflowerblue:[100,149,237,1],cornsilk:[255,248,220,1],crimson:[220,20,60,1],cyan:[0,255,255,1],darkblue:[0,0,139,1],darkcyan:[0,139,139,1],darkgoldenrod:[184,134,11,1],darkgray:[169,169,169,1],darkgreen:[0,100,0,1],darkgrey:[169,169,169,1],darkkhaki:[189,183,107,1],darkmagenta:[139,0,139,1],darkolivegreen:[85,107,47,1],darkorange:[255,140,0,1],darkorchid:[153,50,204,1],darkred:[139,0,0,1],darksalmon:[233,150,122,1],darkseagreen:[143,188,143,1],darkslateblue:[72,61,139,1],darkslategray:[47,79,79,1],darkslategrey:[47,79,79,1],darkturquoise:[0,206,209,1],darkviolet:[148,0,211,1],deeppink:[255,20,147,1],deepskyblue:[0,191,255,1],dimgray:[105,105,105,1],dimgrey:[105,105,105,1],dodgerblue:[30,144,255,1],firebrick:[178,34,34,1],floralwhite:[255,250,240,1],forestgreen:[34,139,34,1],fuchsia:[255,0,255,1],gainsboro:[220,220,220,1],ghostwhite:[248,248,255,1],gold:[255,215,0,1],goldenrod:[218,165,32,1],gray:[128,128,128,1],green:[0,128,0,1],greenyellow:[173,255,47,1],grey:[128,128,128,1],honeydew:[240,255,240,1],hotpink:[255,105,180,1],indianred:[205,92,92,1],indigo:[75,0,130,1],ivory:[255,255,240,1],khaki:[240,230,140,1],lavender:[230,230,250,1],lavenderblush:[255,240,245,1],lawngreen:[124,252,0,1],lemonchiffon:[255,250,205,1],lightblue:[173,216,230,1],lightcoral:[240,128,128,1],lightcyan:[224,255,255,1],lightgoldenrodyellow:[250,250,210,1],lightgray:[211,211,211,1],lightgreen:[144,238,144,1],lightgrey:[211,211,211,1],lightpink:[255,182,193,1],lightsalmon:[255,160,122,1],lightseagreen:[32,178,170,1],lightskyblue:[135,206,250,1],lightslategray:[119,136,153,1],lightslategrey:[119,136,153,1],lightsteelblue:[176,196,222,1],lightyellow:[255,255,224,1],lime:[0,255,0,1],limegreen:[50,205,50,1],linen:[250,240,230,1],magenta:[255,0,255,1],maroon:[128,0,0,1],mediumaquamarine:[102,205,170,1],mediumblue:[0,0,205,1],mediumorchid:[186,85,211,1],mediumpurple:[147,112,219,1],mediumseagreen:[60,179,113,1],mediumslateblue:[123,104,238,1],mediumspringgreen:[0,250,154,1],mediumturquoise:[72,209,204,1],mediumvioletred:[199,21,133,1],midnightblue:[25,25,112,1],mintcream:[245,255,250,1],mistyrose:[255,228,225,1],moccasin:[255,228,181,1],navajowhite:[255,222,173,1],navy:[0,0,128,1],oldlace:[253,245,230,1],olive:[128,128,0,1],olivedrab:[107,142,35,1],orange:[255,165,0,1],orangered:[255,69,0,1],orchid:[218,112,214,1],palegoldenrod:[238,232,170,1],palegreen:[152,251,152,1],paleturquoise:[175,238,238,1],palevioletred:[219,112,147,1],papayawhip:[255,239,213,1],peachpuff:[255,218,185,1],peru:[205,133,63,1],pink:[255,192,203,1],plum:[221,160,221,1],powderblue:[176,224,230,1],purple:[128,0,128,1],red:[255,0,0,1],rosybrown:[188,143,143,1],royalblue:[65,105,225,1],saddlebrown:[139,69,19,1],salmon:[250,128,114,1],sandybrown:[244,164,96,1],seagreen:[46,139,87,1],seashell:[255,245,238,1],sienna:[160,82,45,1],silver:[192,192,192,1],skyblue:[135,206,235,1],slateblue:[106,90,205,1],slategray:[112,128,144,1],slategrey:[112,128,144,1],snow:[255,250,250,1],springgreen:[0,255,127,1],steelblue:[70,130,180,1],tan:[210,180,140,1],teal:[0,128,128,1],thistle:[216,191,216,1],tomato:[255,99,71,1],turquoise:[64,224,208,1],violet:[238,130,238,1],wheat:[245,222,179,1],white:[255,255,255,1],whitesmoke:[245,245,245,1],yellow:[255,255,0,1],yellowgreen:[154,205,50,1]};function Wr(t){return(t=Math.round(t))<0?0:t>255?255:t}function Ur(t){return t<0?0:t>1?1:t}function Gr(t){var e=t;return e.length&&"%"===e.charAt(e.length-1)?Wr(parseFloat(e)/100*255):Wr(parseInt(e,10))}function qr(t){var e=t;return e.length&&"%"===e.charAt(e.length-1)?Ur(parseFloat(e)/100):Ur(parseFloat(e))}function jr(t,e,n){return n<0?n+=1:n>1&&(n-=1),6*n<1?t+(e-t)*n*6:2*n<1?e:3*n<2?t+(e-t)*(2/3-n)*6:t}function Xr(t,e,n){return t+(e-t)*n}function Yr(t,e,n,i,r){return t[0]=e,t[1]=n,t[2]=i,t[3]=r,t}function Zr(t,e){return t[0]=e[0],t[1]=e[1],t[2]=e[2],t[3]=e[3],t}var Kr=new $r(20),Qr=null;function Jr(t,e){Qr&&Zr(Qr,e),Qr=Kr.put(t,Qr||e.slice())}function to(t,e){if(t){e=e||[];var n=Kr.get(t);if(n)return Zr(e,n);var i=(t+="").replace(/ /g,"").toLowerCase();if(i in Vr)return Zr(e,Vr[i]),Jr(t,e),e;var r,o=i.length;if("#"===i.charAt(0))return 4===o||5===o?(r=parseInt(i.slice(1,4),16))>=0&&r<=4095?(Yr(e,(3840&r)>>4|(3840&r)>>8,240&r|(240&r)>>4,15&r|(15&r)<<4,5===o?parseInt(i.slice(4),16)/15:1),Jr(t,e),e):void Yr(e,0,0,0,1):7===o||9===o?(r=parseInt(i.slice(1,7),16))>=0&&r<=16777215?(Yr(e,(16711680&r)>>16,(65280&r)>>8,255&r,9===o?parseInt(i.slice(7),16)/255:1),Jr(t,e),e):void Yr(e,0,0,0,1):void 0;var a=i.indexOf("("),s=i.indexOf(")");if(-1!==a&&s+1===o){var l=i.substr(0,a),c=i.substr(a+1,s-(a+1)).split(","),u=1;switch(l){case"rgba":if(4!==c.length)return 3===c.length?Yr(e,+c[0],+c[1],+c[2],1):Yr(e,0,0,0,1);u=qr(c.pop());case"rgb":return c.length>=3?(Yr(e,Gr(c[0]),Gr(c[1]),Gr(c[2]),3===c.length?u:qr(c[3])),Jr(t,e),e):void Yr(e,0,0,0,1);case"hsla":return 4!==c.length?void Yr(e,0,0,0,1):(c[3]=qr(c[3]),eo(c,e),Jr(t,e),e);case"hsl":return 3!==c.length?void Yr(e,0,0,0,1):(eo(c,e),Jr(t,e),e);default:return}}Yr(e,0,0,0,1)}}function eo(t,e){var n=(parseFloat(t[0])%360+360)%360/360,i=qr(t[1]),r=qr(t[2]),o=r<=.5?r*(i+1):r+i-r*i,a=2*r-o;return Yr(e=e||[],Wr(255*jr(a,o,n+1/3)),Wr(255*jr(a,o,n)),Wr(255*jr(a,o,n-1/3)),1),4===t.length&&(e[3]=t[3]),e}function no(t,e){var n=to(t);if(n){for(var i=0;i<3;i++)n[i]=n[i]*(1-e)|0,n[i]>255?n[i]=255:n[i]<0&&(n[i]=0);return ro(n,4===n.length?"rgba":"rgb")}}function io(t,e,n,i){var r=to(t);if(t)return r=function(t){if(t){var e,n,i=t[0]/255,r=t[1]/255,o=t[2]/255,a=Math.min(i,r,o),s=Math.max(i,r,o),l=s-a,c=(s+a)/2;if(0===l)e=0,n=0;else{n=c<.5?l/(s+a):l/(2-s-a);var u=((s-i)/6+l/2)/l,h=((s-r)/6+l/2)/l,d=((s-o)/6+l/2)/l;i===s?e=d-h:r===s?e=1/3+u-d:o===s&&(e=2/3+h-u),e<0&&(e+=1),e>1&&(e-=1)}var p=[360*e,n,c];return null!=t[3]&&p.push(t[3]),p}}(r),null!=n&&(r[1]=qr(un(n)?n(r[1]):n)),null!=i&&(r[2]=qr(un(i)?i(r[2]):i)),ro(eo(r),"rgba")}function ro(t,e){if(t&&t.length){var n=t[0]+","+t[1]+","+t[2];return"rgba"!==e&&"hsva"!==e&&"hsla"!==e||(n+=","+t[3]),e+"("+n+")"}}function oo(t,e){var n=to(t);return n?(.299*n[0]+.587*n[1]+.114*n[2])*n[3]/255+(1-n[3])*e:0}var ao=new $r(100);function so(t){if(hn(t)){var e=ao.get(t);return e||(e=no(t,-.1),ao.put(t,e)),e}if(yn(t)){var n=Ze({},t);return n.colorStops=nn(t.colorStops,function(t){return{offset:t.offset,color:no(t.color,-.1)}}),n}return t}var lo=Math.round;function co(t){var e;if(t&&"transparent"!==t){if("string"==typeof t&&t.indexOf("rgba")>-1){var n=to(t);n&&(t="rgb("+n[0]+","+n[1]+","+n[2]+")",e=n[3])}}else t="none";return{color:t,opacity:null==e?1:e}}var uo=1e-4;function ho(t){return t-1e-4}function po(t){return lo(1e3*t)/1e3}function fo(t){return lo(1e4*t)/1e4}var go={left:"start",right:"end",center:"middle",middle:"middle"};function vo(t){return t&&!!t.image}function mo(t){return vo(t)||function(t){return t&&!!t.svgElement}(t)}function yo(t){return"linear"===t.type}function _o(t){return"radial"===t.type}function xo(t){return t&&("linear"===t.type||"radial"===t.type)}function bo(t){return"url(#"+t+")"}function wo(t){var e=t.getGlobalScale(),n=Math.max(e[0],e[1]);return Math.max(Math.ceil(Math.log(n)/Math.log(10)),1)}function So(t){var e=t.x||0,n=t.y||0,i=(t.rotation||0)*Rn,r=bn(t.scaleX,1),o=bn(t.scaleY,1),a=t.skewX||0,s=t.skewY||0,l=[];return(e||n)&&l.push("translate("+e+"px,"+n+"px)"),i&&l.push("rotate("+i+")"),1===r&&1===o||l.push("scale("+r+","+o+")"),(a||s)&&l.push("skew("+lo(a*Rn)+"deg, "+lo(s*Rn)+"deg)"),l.join(" ")}var Co=Te.hasGlobalWindow&&un(window.btoa)?function(t){return window.btoa(unescape(encodeURIComponent(t)))}:"undefined"!=typeof Buffer?function(t){return Buffer.from(t).toString("base64")}:function(t){return null},Mo=Array.prototype.slice;function ko(t,e,n){return(e-t)*n+t}function To(t,e,n,i){for(var r=e.length,o=0;oi?e:t,o=Math.min(n,i),a=r[o-1]||{color:[0,0,0,0],offset:0},s=o;sa)i.length=a;else for(var s=o;s=1},t.prototype.getAdditiveTrack=function(){return this._additiveTrack},t.prototype.addKeyframe=function(t,e,n){this._needsSort=!0;var i=this.keyframes,r=i.length,o=!1,a=6,s=e;if(tn(e)){var l=function(t){return tn(t&&t[0])?2:1}(e);a=l,(1===l&&!pn(e[0])||2===l&&!pn(e[0][0]))&&(o=!0)}else if(pn(e)&&!_n(e))a=0;else if(hn(e))if(isNaN(+e)){var c=to(e);c&&(s=c,a=3)}else a=0;else if(yn(e)){var u=Ze({},s);u.colorStops=nn(e.colorStops,function(t){return{offset:t.offset,color:to(t.color)}}),yo(e)?a=4:_o(e)&&(a=5),s=u}0===r?this.valType=a:a===this.valType&&6!==a||(o=!0),this.discrete=this.discrete||o;var h={time:t,value:s,rawValue:e,percent:0};return n&&(h.easing=n,h.easingFunc=un(n)?n:pr[n]||Rr(n)),i.push(h),h},t.prototype.prepare=function(t,e){var n=this.keyframes;this._needsSort&&n.sort(function(t,e){return t.time-e.time});for(var i=this.valType,r=n.length,o=n[r-1],a=this.discrete,s=Oo(i),l=zo(i),c=0;c=0&&!(l[n].percent<=e);n--);n=p(n,c-2)}else{for(n=d;ne);n++);n=p(n-1,c-2)}r=l[n+1],i=l[n]}if(i&&r){this._lastFr=n,this._lastFrP=e;var f=r.percent-i.percent,g=0===f?1:p((e-i.percent)/f,1);r.easingFunc&&(g=r.easingFunc(g));var v=o?this._additiveValue:h?No:t[u];if(!Oo(s)&&!h||v||(v=this._additiveValue=[]),this.discrete)t[u]=g<1?i.rawValue:r.rawValue;else if(Oo(s))1===s?To(v,i[a],r[a],g):function(t,e,n,i){for(var r=e.length,o=r&&e[0].length,a=0;a0&&s.addKeyframe(0,Lo(l),i),this._trackKeys.push(a)}s.addKeyframe(t,Lo(e[a]),i)}return this._maxTime=Math.max(this._maxTime,t),this},t.prototype.pause=function(){this._clip.pause(),this._paused=!0},t.prototype.resume=function(){this._clip.resume(),this._paused=!1},t.prototype.isPaused=function(){return!!this._paused},t.prototype.duration=function(t){return this._maxTime=t,this._force=!0,this},t.prototype._doneCallback=function(){this._setTracksFinished(),this._clip=null;var t=this._doneCbs;if(t)for(var e=t.length,n=0;n0)){this._started=1;for(var e=this,n=[],i=this._maxTime||0,r=0;r1){var a=o.pop();r.addKeyframe(a.time,t[i]),r.prepare(this._maxTime,r.getAdditiveTrack())}}}},t}();function Bo(){return(new Date).getTime()}var Fo,$o,Vo=function(t){function e(e){var n=t.call(this)||this;return n._running=!1,n._time=0,n._pausedTime=0,n._pauseStart=0,n._paused=!1,e=e||{},n.stage=e.stage||{},n}return _(e,t),e.prototype.addClip=function(t){t.animation&&this.removeClip(t),this._head?(this._tail.next=t,t.prev=this._tail,t.next=null,this._tail=t):this._head=this._tail=t,t.animation=this},e.prototype.addAnimator=function(t){t.animation=this;var e=t.getClip();e&&this.addClip(e)},e.prototype.removeClip=function(t){if(t.animation){var e=t.prev,n=t.next;e?e.next=n:this._head=n,n?n.prev=e:this._tail=e,t.next=t.prev=t.animation=null}},e.prototype.removeAnimator=function(t){var e=t.getClip();e&&this.removeClip(e),t.animation=null},e.prototype.update=function(t){for(var e=Bo()-this._pausedTime,n=e-this._time,i=this._head;i;){var r=i.next;i.step(e,n)?(i.ondestroy(),this.removeClip(i),i=r):i=r}this._time=e,t||(this.trigger("frame",n),this.stage.update&&this.stage.update())},e.prototype._startLoop=function(){var t=this;this._running=!0,hr(function e(){t._running&&(hr(e),!t._paused&&t.update())})},e.prototype.start=function(){this._running||(this._time=Bo(),this._pausedTime=0,this._startLoop())},e.prototype.stop=function(){this._running=!1},e.prototype.pause=function(){this._paused||(this._pauseStart=Bo(),this._paused=!0)},e.prototype.resume=function(){this._paused&&(this._pausedTime+=Bo()-this._pauseStart,this._paused=!1)},e.prototype.clear=function(){for(var t=this._head;t;){var e=t.next;t.prev=t.next=t.animation=null,t=e}this._head=this._tail=null},e.prototype.isFinished=function(){return null==this._head},e.prototype.animate=function(t,e){e=e||{},this.start();var n=new Ho(t,e.loop);return this.addAnimator(n),n},e}(Qn),Wo=Te.domSupported,Uo=($o={pointerdown:1,pointerup:1,pointermove:1,pointerout:1},{mouse:Fo=["click","dblclick","mousewheel","wheel","mouseout","mouseup","mousedown","mousemove","contextmenu"],touch:["touchstart","touchend","touchmove"],pointer:nn(Fo,function(t){var e=t.replace("mouse","pointer");return $o.hasOwnProperty(e)?e:t})}),Go=["mousemove","mouseup"],qo=["pointermove","pointerup"],jo=!1;function Xo(t){var e=t.pointerType;return"pen"===e||"touch"===e}function Yo(t){t&&(t.zrByTouch=!0)}function Zo(t,e){for(var n=e,i=!1;n&&9!==n.nodeType&&!(i=n.domBelongToZr||n!==e&&n===t.painterRoot);)n=n.parentNode;return i}var Ko=function(t,e){this.stopPropagation=Nn,this.stopImmediatePropagation=Nn,this.preventDefault=Nn,this.type=e.type,this.target=this.currentTarget=t.dom,this.pointerType=e.pointerType,this.clientX=e.clientX,this.clientY=e.clientY},Qo={mousedown:function(t){t=gi(this.dom,t),this.__mayPointerCapture=[t.zrX,t.zrY],this.trigger("mousedown",t)},mousemove:function(t){t=gi(this.dom,t);var e=this.__mayPointerCapture;!e||t.zrX===e[0]&&t.zrY===e[1]||this.__togglePointerCapture(!0),this.trigger("mousemove",t)},mouseup:function(t){t=gi(this.dom,t),this.__togglePointerCapture(!1),this.trigger("mouseup",t)},mouseout:function(t){Zo(this,(t=gi(this.dom,t)).toElement||t.relatedTarget)||(this.__pointerCapturing&&(t.zrEventControl="no_globalout"),this.trigger("mouseout",t))},wheel:function(t){jo=!0,t=gi(this.dom,t),this.trigger("mousewheel",t)},mousewheel:function(t){jo||(t=gi(this.dom,t),this.trigger("mousewheel",t))},touchstart:function(t){Yo(t=gi(this.dom,t)),this.__lastTouchMoment=new Date,this.handler.processGesture(t,"start"),Qo.mousemove.call(this,t),Qo.mousedown.call(this,t)},touchmove:function(t){Yo(t=gi(this.dom,t)),this.handler.processGesture(t,"change"),Qo.mousemove.call(this,t)},touchend:function(t){Yo(t=gi(this.dom,t)),this.handler.processGesture(t,"end"),Qo.mouseup.call(this,t),+new Date-+this.__lastTouchMoment<300&&Qo.click.call(this,t)},pointerdown:function(t){Qo.mousedown.call(this,t)},pointermove:function(t){Xo(t)||Qo.mousemove.call(this,t)},pointerup:function(t){Qo.mouseup.call(this,t)},pointerout:function(t){Xo(t)||Qo.mouseout.call(this,t)}};en(["click","dblclick","contextmenu"],function(t){Qo[t]=function(e){e=gi(this.dom,e),this.trigger(t,e)}});var Jo={pointermove:function(t){Xo(t)||Jo.mousemove.call(this,t)},pointerup:function(t){Jo.mouseup.call(this,t)},mousemove:function(t){this.trigger("mousemove",t)},mouseup:function(t){var e=this.__pointerCapturing;this.__togglePointerCapture(!1),this.trigger("mouseup",t),e&&(t.zrEventControl="only_globalout",this.trigger("mouseout",t))}};function ta(t,e){var n=e.domHandlers;Te.pointerEventsSupported?en(Uo.pointer,function(i){na(e,i,function(e){n[i].call(t,e)})}):(Te.touchEventsSupported&&en(Uo.touch,function(i){na(e,i,function(r){n[i].call(t,r),function(t){t.touching=!0,null!=t.touchTimer&&(clearTimeout(t.touchTimer),t.touchTimer=null),t.touchTimer=setTimeout(function(){t.touching=!1,t.touchTimer=null},700)}(e)})}),en(Uo.mouse,function(i){na(e,i,function(r){r=fi(r),e.touching||n[i].call(t,r)})}))}function ea(t,e){function n(n){na(e,n,function(i){i=fi(i),Zo(t,i.target)||(i=function(t,e){return gi(t.dom,new Ko(t,e),!0)}(t,i),e.domHandlers[n].call(t,i))},{capture:!0})}Te.pointerEventsSupported?en(qo,n):Te.touchEventsSupported||en(Go,n)}function na(t,e,n,i){t.mounted[e]=n,t.listenerOpts[e]=i,function(t,e,n,i){t.addEventListener(e,n,i)}(t.domTarget,e,n,i)}function ia(t){var e=t.mounted;for(var n in e)e.hasOwnProperty(n)&&vi(t.domTarget,n,e[n],t.listenerOpts[n]);t.mounted={}}var ra=function(t,e){this.mounted={},this.listenerOpts={},this.touching=!1,this.domTarget=t,this.domHandlers=e},oa=function(t){function e(e,n){var i=t.call(this)||this;return i.__pointerCapturing=!1,i.dom=e,i.painterRoot=n,i._localHandlerScope=new ra(e,Qo),Wo&&(i._globalHandlerScope=new ra(document,Jo)),ta(i,i._localHandlerScope),i}return _(e,t),e.prototype.dispose=function(){ia(this._localHandlerScope),Wo&&ia(this._globalHandlerScope)},e.prototype.setCursor=function(t){this.dom.style&&(this.dom.style.cursor=t||"default")},e.prototype.__togglePointerCapture=function(t){if(this.__mayPointerCapture=null,Wo&&+this.__pointerCapturing^+t){this.__pointerCapturing=t;var e=this._globalHandlerScope;t?ea(this,e):ia(e)}},e}(Qn),aa=1;Te.hasGlobalWindow&&(aa=Math.max(window.devicePixelRatio||window.screen&&window.screen.deviceXDPI/window.screen.logicalXDPI||1,1));var sa=aa,la="#333",ca="#ccc",ua=bi,ha=5e-5;function da(t){return t>ha||t<-5e-5}var pa,fa=[],ga=[],va=[1,0,0,1,0,0],ma=Math.abs,ya=function(){function t(){}var e;return t.prototype.getLocalTransform=function(e){return t.getLocalTransform(this,e)},t.prototype.setPosition=function(t){this.x=t[0],this.y=t[1]},t.prototype.setScale=function(t){this.scaleX=t[0],this.scaleY=t[1]},t.prototype.setSkew=function(t){this.skewX=t[0],this.skewY=t[1]},t.prototype.setOrigin=function(t){this.originX=t[0],this.originY=t[1]},t.prototype.needLocalTransform=function(){return da(this.rotation)||da(this.x)||da(this.y)||da(this.scaleX-1)||da(this.scaleY-1)||da(this.skewX)||da(this.skewY)},t.prototype.updateTransform=function(){var t=this.parent&&this.parent.transform,e=this.needLocalTransform(),n=this.transform;e||t?(n=n||[1,0,0,1,0,0],e?this.getLocalTransform(n):ua(n),t&&(e?Si(n,t,n):wi(n,t)),this.transform=n,this._resolveGlobalScaleRatio(n)):n&&(ua(n),this.invTransform=null)},t.prototype._resolveGlobalScaleRatio=function(t){var e=this.globalScaleRatio;if(null!=e&&1!==e){this.getGlobalScale(fa);var n=fa[0]<0?-1:1,i=fa[1]<0?-1:1,r=((fa[0]-n)*e+n)/fa[0]||0,o=((fa[1]-i)*e+i)/fa[1]||0;t[0]*=r,t[1]*=r,t[2]*=o,t[3]*=o}this.invTransform=this.invTransform||[1,0,0,1,0,0],ki(this.invTransform,t)},t.prototype.getComputedTransform=function(){for(var t=this,e=[];t;)e.push(t),t=t.parent;for(;t=e.pop();)t.updateTransform();return this.transform},t.prototype.setLocalTransform=function(t){if(t){var e=t[0]*t[0]+t[1]*t[1],n=t[2]*t[2]+t[3]*t[3],i=Math.atan2(t[1],t[0]),r=Math.PI/2+i-Math.atan2(t[3],t[2]);n=Math.sqrt(n)*Math.cos(r),e=Math.sqrt(e),this.skewX=r,this.skewY=0,this.rotation=-i,this.x=+t[4],this.y=+t[5],this.scaleX=e,this.scaleY=n,this.originX=0,this.originY=0}},t.prototype.decomposeTransform=function(){if(this.transform){var t=this.parent,e=this.transform;t&&t.transform&&(t.invTransform=t.invTransform||[1,0,0,1,0,0],Si(ga,t.invTransform,e),e=ga);var n=this.originX,i=this.originY;(n||i)&&(va[4]=n,va[5]=i,Si(ga,e,va),ga[4]-=n,ga[5]-=i,e=ga),this.setLocalTransform(e)}},t.prototype.getGlobalScale=function(t){var e=this.transform;return t=t||[],e?(t[0]=Math.sqrt(e[0]*e[0]+e[1]*e[1]),t[1]=Math.sqrt(e[2]*e[2]+e[3]*e[3]),e[0]<0&&(t[0]=-t[0]),e[3]<0&&(t[1]=-t[1]),t):(t[0]=1,t[1]=1,t)},t.prototype.transformCoordToLocal=function(t,e){var n=[t,e],i=this.invTransform;return i&&jn(n,n,i),n},t.prototype.transformCoordToGlobal=function(t,e){var n=[t,e],i=this.transform;return i&&jn(n,n,i),n},t.prototype.getLineScale=function(){var t=this.transform;return t&&ma(t[0]-1)>1e-10&&ma(t[3]-1)>1e-10?Math.sqrt(ma(t[0]*t[3]-t[2]*t[1])):1},t.prototype.copyTransform=function(t){xa(this,t)},t.getLocalTransform=function(t,e){e=e||[];var n=t.originX||0,i=t.originY||0,r=t.scaleX,o=t.scaleY,a=t.anchorX,s=t.anchorY,l=t.rotation||0,c=t.x,u=t.y,h=t.skewX?Math.tan(t.skewX):0,d=t.skewY?Math.tan(-t.skewY):0;if(n||i||a||s){var p=n+a,f=i+s;e[4]=-p*r-h*f*o,e[5]=-f*o-d*p*r}else e[4]=e[5]=0;return e[0]=r,e[3]=o,e[1]=d*r,e[2]=h*o,l&&Mi(e,e,l),e[4]+=n+c,e[5]+=i+u,e},t.initDefaultProps=((e=t.prototype).scaleX=e.scaleY=e.globalScaleRatio=1,void(e.x=e.y=e.originX=e.originY=e.skewX=e.skewY=e.rotation=e.anchorX=e.anchorY=0)),t}(),_a=["x","y","originX","originY","anchorX","anchorY","rotation","scaleX","scaleY","skewX","skewY"];function xa(t,e){for(var n=0;n<_a.length;n++){var i=_a[n];t[i]=e[i]}}function ba(t){pa||(pa=new $r(100)),t=t||Ie;var e=pa.get(t);return e||(e={font:t,strWidthCache:new $r(500),asciiWidthMap:null,asciiWidthMapTried:!1,stWideCharWidth:Ee.measureText("国",t).width,asciiCharWidth:Ee.measureText("a",t).width},pa.put(t,e)),e}var wa=0,Sa=5;function Ca(t,e){return t.asciiWidthMapTried||(t.asciiWidthMap=function(t){if(!(wa>=Sa)){t=t||Ie;for(var e=[],n=+new Date,i=0;i<=127;i++)e[i]=Ee.measureText(String.fromCharCode(i),t).width;var r=+new Date-n;return r>16?wa=Sa:r>2&&wa++,e}}(t.font),t.asciiWidthMapTried=!0),0<=e&&e<=127?null!=t.asciiWidthMap?t.asciiWidthMap[e]:t.asciiCharWidth:t.stWideCharWidth}function Ma(t,e){var n=t.strWidthCache,i=n.get(e);return null==i&&(i=Ee.measureText(e,t.font).width,n.put(e,i)),i}function ka(t,e,n,i){var r=Ma(ba(e),t),o=Aa(e),a=Da(0,r,n),s=Ia(0,o,i);return new $i(a,s,r,o)}function Ta(t,e,n,i){var r=((t||"")+"").split("\n");if(1===r.length)return ka(r[0],e,n,i);for(var o=new $i(0,0,0,0),a=0;a=0?parseFloat(t)/100*e:parseFloat(t):t}function La(t,e,n){var i=e.position||"inside",r=null!=e.distance?e.distance:5,o=n.height,a=n.width,s=o/2,l=n.x,c=n.y,u="left",h="top";if(i instanceof Array)l+=Pa(i[0],n.width),c+=Pa(i[1],n.height),u=null,h=null;else switch(i){case"left":l-=r,c+=s,u="right",h="middle";break;case"right":l+=r+a,c+=s,h="middle";break;case"top":l+=a/2,c-=r,u="center",h="bottom";break;case"bottom":l+=a/2,c+=o+r,u="center";break;case"inside":l+=a/2,c+=s,u="center",h="middle";break;case"insideLeft":l+=r,c+=s,h="middle";break;case"insideRight":l+=a-r,c+=s,u="right",h="middle";break;case"insideTop":l+=a/2,c+=r,u="center";break;case"insideBottom":l+=a/2,c+=o-r,u="center",h="bottom";break;case"insideTopLeft":l+=r,c+=r;break;case"insideTopRight":l+=a-r,c+=r,u="right";break;case"insideBottomLeft":l+=r,c+=o-r,h="bottom";break;case"insideBottomRight":l+=a-r,c+=o-r,u="right",h="bottom"}return(t=t||{}).x=l,t.y=c,t.align=u,t.verticalAlign=h,t}var Ea="__zr_normal__",za=_a.concat(["ignore"]),Oa=rn(_a,function(t,e){return t[e]=!0,t},{ignore:!1}),Na={},Ra=new $i(0,0,0,0),Ha=[],Ba=function(){function t(t){this.id=qe(),this.animators=[],this.currentStates=[],this.states={},this._init(t)}return t.prototype._init=function(t){this.attr(t)},t.prototype.drift=function(t,e,n){switch(this.draggable){case"horizontal":e=0;break;case"vertical":t=0}var i=this.transform;i||(i=this.transform=[1,0,0,1,0,0]),i[4]+=t,i[5]+=e,this.decomposeTransform(),this.markRedraw()},t.prototype.beforeUpdate=function(){},t.prototype.afterUpdate=function(){},t.prototype.update=function(){this.updateTransform(),this.__dirty&&this.updateInnerText()},t.prototype.updateInnerText=function(t){var e=this._textContent;if(e&&(!e.ignore||t)){this.textConfig||(this.textConfig={});var n=this.textConfig,i=n.local,r=e.innerTransformable,o=void 0,a=void 0,s=!1;r.parent=i?this:null;var l=!1;r.copyTransform(e);var c=null!=n.position,u=n.autoOverflowArea,h=void 0;if((u||c)&&(h=Ra,n.layoutRect?h.copy(n.layoutRect):h.copy(this.getBoundingRect()),i||h.applyTransform(this.transform)),c){this.calculateTextPosition?this.calculateTextPosition(Na,n,h):La(Na,n,h),r.x=Na.x,r.y=Na.y,o=Na.align,a=Na.verticalAlign;var d=n.origin;if(d&&null!=n.rotation){var p=void 0,f=void 0;"center"===d?(p=.5*h.width,f=.5*h.height):(p=Pa(d[0],h.width),f=Pa(d[1],h.height)),l=!0,r.originX=-r.x+p+(i?0:h.x),r.originY=-r.y+f+(i?0:h.y)}}null!=n.rotation&&(r.rotation=n.rotation);var g=n.offset;g&&(r.x+=g[0],r.y+=g[1],l||(r.originX=-g[0],r.originY=-g[1]));var v=this._innerTextDefaultStyle||(this._innerTextDefaultStyle={});if(u){var m=v.overflowRect=v.overflowRect||new $i(0,0,0,0);r.getLocalTransform(Ha),ki(Ha,Ha),$i.copy(m,h),m.applyTransform(Ha)}else v.overflowRect=null;var y=void 0,_=void 0,x=void 0;(null==n.inside?"string"==typeof n.position&&n.position.indexOf("inside")>=0:n.inside)&&this.canBeInsideText()?(y=n.insideFill,_=n.insideStroke,null!=y&&"auto"!==y||(y=this.getInsideTextFill()),null!=_&&"auto"!==_||(_=this.getInsideTextStroke(y),x=!0)):(y=n.outsideFill,_=n.outsideStroke,null!=y&&"auto"!==y||(y=this.getOutsideFill()),null!=_&&"auto"!==_||(_=this.getOutsideStroke(y),x=!0)),(y=y||"#000")===v.fill&&_===v.stroke&&x===v.autoStroke&&o===v.align&&a===v.verticalAlign||(s=!0,v.fill=y,v.stroke=_,v.autoStroke=x,v.align=o,v.verticalAlign=a,e.setDefaultTextStyle(v)),e.__dirty|=1,s&&e.dirtyStyle(!0)}},t.prototype.canBeInsideText=function(){return!0},t.prototype.getInsideTextFill=function(){return"#fff"},t.prototype.getInsideTextStroke=function(t){return"#000"},t.prototype.getOutsideFill=function(){return this.__zr&&this.__zr.isDarkMode()?ca:la},t.prototype.getOutsideStroke=function(t){var e=this.__zr&&this.__zr.getBackgroundColor(),n="string"==typeof e&&to(e);n||(n=[255,255,255,1]);for(var i=n[3],r=this.__zr.isDarkMode(),o=0;o<3;o++)n[o]=n[o]*i+(r?0:255)*(1-i);return n[3]=1,ro(n,"rgba")},t.prototype.traverse=function(t,e){},t.prototype.attrKV=function(t,e){"textConfig"===t?this.setTextConfig(e):"textContent"===t?this.setTextContent(e):"clipPath"===t?this.setClipPath(e):"extra"===t?(this.extra=this.extra||{},Ze(this.extra,e)):this[t]=e},t.prototype.hide=function(){this.ignore=!0,this.markRedraw()},t.prototype.show=function(){this.ignore=!1,this.markRedraw()},t.prototype.attr=function(t,e){if("string"==typeof t)this.attrKV(t,e);else if(fn(t))for(var n=an(t),i=0;i0},t.prototype.getState=function(t){return this.states[t]},t.prototype.ensureState=function(t){var e=this.states;return e[t]||(e[t]={}),e[t]},t.prototype.clearStates=function(t){this.useState(Ea,!1,t)},t.prototype.useState=function(t,e,n,i){var r=t===Ea;if(this.hasState()||!r){var o=this.currentStates,a=this.stateTransition;if(!(Qe(o,t)>=0)||!e&&1!==o.length){var s;if(this.stateProxy&&!r&&(s=this.stateProxy(t)),s||(s=this.states&&this.states[t]),s||r){r||this.saveCurrentToNormalState(s);var l=!!(s&&s.hoverLayer||i);l&&this._toggleHoverLayerFlag(!0),this._applyStateObj(t,s,this._normalState,e,!n&&!this.__inHover&&a&&a.duration>0,a);var c=this._textContent,u=this._textGuide;return c&&c.useState(t,e,n,l),u&&u.useState(t,e,n,l),r?(this.currentStates=[],this._normalState={}):e?this.currentStates.push(t):this.currentStates=[t],this._updateAnimationTargets(),this.markRedraw(),!l&&this.__inHover&&(this._toggleHoverLayerFlag(!1),this.__dirty&=-2),s}je("State "+t+" not exists.")}}},t.prototype.useStates=function(t,e,n){if(t.length){var i=[],r=this.currentStates,o=t.length,a=o===r.length;if(a)for(var s=0;s0,p);var f=this._textContent,g=this._textGuide;f&&f.useStates(t,e,h),g&&g.useStates(t,e,h),this._updateAnimationTargets(),this.currentStates=t.slice(),this.markRedraw(),!h&&this.__inHover&&(this._toggleHoverLayerFlag(!1),this.__dirty&=-2)}else this.clearStates()},t.prototype.isSilent=function(){for(var t=this;t;){if(t.silent)return!0;var e=t.__hostTarget;t=e?t.ignoreHostSilent?null:e:t.parent}return!1},t.prototype._updateAnimationTargets=function(){for(var t=0;t=0){var n=this.currentStates.slice();n.splice(e,1),this.useStates(n)}},t.prototype.replaceState=function(t,e,n){var i=this.currentStates.slice(),r=Qe(i,t),o=Qe(i,e)>=0;r>=0?o?i.splice(r,1):i[r]=e:n&&!o&&i.push(e),this.useStates(i)},t.prototype.toggleState=function(t,e){e?this.useState(t,!0):this.removeState(t)},t.prototype._mergeStates=function(t){for(var e,n={},i=0;i=0&&e.splice(n,1)}),this.animators.push(t),n&&n.animation.addAnimator(t),n&&n.wakeUp()},t.prototype.updateDuringAnimation=function(t){this.markRedraw()},t.prototype.stopAnimation=function(t,e){for(var n=this.animators,i=n.length,r=[],o=0;o0&&n.during&&o[0].during(function(t,e){n.during(e)});for(var d=0;d0||r.force&&!a.length){var w,S=void 0,C=void 0,M=void 0;if(s){C={},d&&(S={});for(x=0;x<_;x++){C[m=g[x]]=n[m],d?S[m]=i[m]:n[m]=i[m]}}else if(d){M={};for(x=0;x<_;x++){M[m=g[x]]=Lo(n[m]),Va(n,i,m)}}(w=new Ho(n,!1,!1,h?on(f,function(t){return t.targetName===e}):null)).targetName=e,r.scope&&(w.scope=r.scope),d&&S&&w.whenWithKeys(0,S,g),M&&w.whenWithKeys(0,M,g),w.whenWithKeys(null==c?500:c,s?C:i,g).delay(u||0),t.addAnimator(w,e),a.push(w)}}Je(Ba,Qn),Je(Ba,ya);var Ua=function(t){function e(e){var n=t.call(this)||this;return n.isGroup=!0,n._children=[],n.attr(e),n}return _(e,t),e.prototype.childrenRef=function(){return this._children},e.prototype.children=function(){return this._children.slice()},e.prototype.childAt=function(t){return this._children[t]},e.prototype.childOfName=function(t){for(var e=this._children,n=0;n=0&&(n.splice(i,0,t),this._doAdd(t))}return this},e.prototype.replace=function(t,e){var n=Qe(this._children,t);return n>=0&&this.replaceAt(e,n),this},e.prototype.replaceAt=function(t,e){var n=this._children,i=n[e];if(t&&t!==this&&t.parent!==this&&t!==i){n[e]=t,i.parent=null;var r=this.__zr;r&&i.removeSelfFromZr(r),this._doAdd(t)}return this},e.prototype._doAdd=function(t){t.parent&&t.parent.remove(t),t.parent=this;var e=this.__zr;e&&e!==t.__zr&&t.addSelfToZr(e),e&&e.refresh()},e.prototype.remove=function(t){var e=this.__zr,n=this._children,i=Qe(n,t);return i<0||(n.splice(i,1),t.parent=null,e&&t.removeSelfFromZr(e),e&&e.refresh()),this},e.prototype.removeAll=function(){for(var t=this._children,e=this.__zr,n=0;n0&&(this._stillFrameAccum++,this._stillFrameAccum>this._sleepAfterStill&&this.animation.stop())},t.prototype.setSleepAfterStill=function(t){this._sleepAfterStill=t},t.prototype.wakeUp=function(){this._disposed||(this.animation.start(),this._stillFrameAccum=0)},t.prototype.refreshHover=function(){this._needsRefreshHover=!0},t.prototype.refreshHoverImmediately=function(){this._disposed||(this._needsRefreshHover=!1,this.painter.refreshHover&&"canvas"===this.painter.getType()&&this.painter.refreshHover())},t.prototype.resize=function(t){this._disposed||(t=t||{},this.painter.resize(t.width,t.height),this.handler.resize())},t.prototype.clearAnimation=function(){this._disposed||this.animation.clear()},t.prototype.getWidth=function(){if(!this._disposed)return this.painter.getWidth()},t.prototype.getHeight=function(){if(!this._disposed)return this.painter.getHeight()},t.prototype.setCursorStyle=function(t){this._disposed||this.handler.setCursorStyle(t)},t.prototype.findHover=function(t,e){if(!this._disposed)return this.handler.findHover(t,e)},t.prototype.on=function(t,e,n){return this._disposed||this.handler.on(t,e,n),this},t.prototype.off=function(t,e){this._disposed||this.handler.off(t,e)},t.prototype.trigger=function(t,e){this._disposed||this.handler.trigger(t,e)},t.prototype.clear=function(){if(!this._disposed){for(var t=this.storage.getRoots(),e=0;e0){if(t<=r)return a;if(t>=o)return s}else{if(t>=r)return a;if(t<=o)return s}else{if(t===r)return a;if(t===o)return s}return(t-r)/l*c+a}var es=function(t,e,n){switch(t){case"center":case"middle":t="50%";break;case"left":case"top":t="0%";break;case"right":case"bottom":t="100%"}return ns(t,e,n)};function ns(t,e,n){return hn(t)?(i=t,i.replace(/^\s+|\s+$/g,"")).match(/%$/)?parseFloat(t)/100*e+(n||0):parseFloat(t):null==t?NaN:+t;var i}function is(t,e,n){return null==e&&(e=10),e=Math.min(Math.max(0,e),20),t=(+t).toFixed(e),n?t:+t}function rs(t){if(t=+t,isNaN(t))return 0;if(t>1e-14)for(var e=1,n=0;n<15;n++,e*=10)if(Math.round(t*e)/e===t)return n;return function(t){var e=t.toString().toLowerCase(),n=e.indexOf("e"),i=n>0?+e.slice(n+1):0,r=n>0?n:e.length,o=e.indexOf("."),a=o<0?0:r-1-o;return Math.max(0,a-i)}(t)}function os(t,e){var n=Math.max(rs(t),rs(e)),i=t+e;return n>20?i:is(i,n)}function as(t){var e=2*Math.PI;return(t%e+e)%e}function ss(t){return t>-1e-4&&t=10&&e++,e}function hs(t,e){var n=us(t),i=Math.pow(10,n),r=t/i;return t=(r<1.5?1:r<2.5?2:r<4?3:r<7?5:10)*i,n>=-20?+t.toFixed(n<0?-n:0):t}function ds(t){var e=parseFloat(t);return e==t&&(0!==e||!hn(t)||t.indexOf("x")<=0)?e:NaN}function ps(){return Math.round(9*Math.random())}function fs(t,e){return 0===e?t:fs(e,t%e)}function gs(t,e){return null==t?e:null==e?t:t*e/fs(t,e)}var vs="undefined"!=typeof console&&console.warn&&console.log;function ms(t,e){!function(t,e){vs&&console[t]("[ECharts] "+e)}("error",t)}function ys(t){throw new Error(t)}function _s(t,e,n){return(e-t)*n+t}var xs="series\0";function bs(t){return t instanceof Array?t:null==t?[]:[t]}function ws(t,e,n){if(t){t[e]=t[e]||{},t.emphasis=t.emphasis||{},t.emphasis[e]=t.emphasis[e]||{};for(var i=0,r=n.length;i=0||r&&Qe(r,s)<0)){var l=n.getShallow(s,e);null!=l&&(o[t[a][0]]=l)}}return o}}var Zs=Ys([["fill","color"],["shadowBlur"],["shadowOffsetX"],["shadowOffsetY"],["opacity"],["shadowColor"]]),Ks=function(){function t(){}return t.prototype.getAreaStyle=function(t,e){return Zs(this,t,e)},t}(),Qs=new $r(50);function Js(t){if("string"==typeof t){var e=Qs.get(t);return e&&e.image}return t}function tl(t,e,n,i,r){if(t){if("string"==typeof t){if(e&&e.__zrImageSrc===t||!n)return e;var o=Qs.get(t),a={hostEl:n,cb:i,cbPayload:r};return o?!nl(e=o.image)&&o.pending.push(a):((e=Ee.loadImage(t,el,el)).__zrImageSrc=t,Qs.put(t,e.__cachedImgObj={image:e,pending:[a]})),e}return t}return e}function el(){var t=this.__cachedImgObj;this.onload=this.onerror=this.__cachedImgObj=null;for(var e=0;e=s;c++)l-=s;var u=Ma(a,n);return u>l&&(n="",u=0),l=t-u,r.ellipsis=n,r.ellipsisWidth=u,r.contentWidth=l,r.containerWidth=t,r}function al(t,e,n){var i=n.containerWidth,r=n.contentWidth,o=n.fontMeasureInfo;if(!i)return t.textLine="",void(t.isTruncated=!1);var a=Ma(o,e);if(a<=i)return t.textLine=e,void(t.isTruncated=!1);for(var s=0;;s++){if(a<=r||s>=n.maxIterations){e+=n.ellipsis;break}var l=0===s?sl(e,r,o):a>0?Math.floor(e.length*r/a):0;a=Ma(o,e=e.substr(0,l))}""===e&&(e=n.placeholder),t.textLine=e,t.isTruncated=!0}function sl(t,e,n){for(var i=0,r=0,o=t.length;r0&&f+i.accumWidth>i.width&&(o=e.split("\n"),h=!0),i.accumWidth=f}else{var g=fl(e,u,i.width,i.breakAll,i.accumWidth);i.accumWidth=g.accumWidth+p,a=g.linesWidths,o=g.lines}}o||(o=e.split("\n"));for(var v=ba(u),m=0;m=32&&e<=591||e>=880&&e<=4351||e>=4608&&e<=5119||e>=7680&&e<=8303}(t)||!!dl[t]}function fl(t,e,n,i,r){for(var o=[],a=[],s="",l="",c=0,u=0,h=ba(e),d=0;dn:r+u+f>n)?u?(s||l)&&(g?(s||(s=l,l="",u=c=0),o.push(s),a.push(u-c),l+=p,s="",u=c+=f):(l&&(s+=l,l="",c=0),o.push(s),a.push(u),s=p,u=f)):g?(o.push(l),a.push(c),l=p,c=f):(o.push(p),a.push(f)):(u+=f,g?(l+=p,c+=f):(l&&(s+=l,l="",c=0),s+=p))}else l&&(s+=l,u+=c),o.push(s),a.push(u),s="",l="",c=0,u=0}return l&&(s+=l),s&&(o.push(s),a.push(u)),1===o.length&&(u+=r),{accumWidth:u,lines:o,linesWidths:a}}function gl(t,e,n,i,r,o){if(t.baseX=n,t.baseY=i,t.outerWidth=t.outerHeight=null,e){var a=2*e.width,s=2*e.height;$i.set(vl,Da(n,a,r),Ia(i,s,o),a,s),$i.intersect(e,vl,null,ml);var l=ml.outIntersectRect;t.outerWidth=l.width,t.outerHeight=l.height,t.baseX=Da(l.x,l.width,r,!0),t.baseY=Ia(l.y,l.height,o,!0)}}var vl=new $i(0,0,0,0),ml={outIntersectRect:{},clamp:!0};function yl(t){return null!=t?t+="":t=""}function _l(t,e,n,i){var r=new $i(Da(t.x||0,e,t.textAlign),Ia(t.y||0,n,t.textBaseline),e,n),o=null!=i?i:xl(t)?t.lineWidth:0;return o>0&&(r.x-=o/2,r.y-=o/2,r.width+=o,r.height+=o),r}function xl(t){var e=t.stroke;return null!=e&&"none"!==e&&t.lineWidth>0}var bl="__zr_style_"+Math.round(10*Math.random()),wl={shadowBlur:0,shadowOffsetX:0,shadowOffsetY:0,shadowColor:"#000",opacity:1,blend:"source-over"},Sl={style:{shadowBlur:!0,shadowOffsetX:!0,shadowOffsetY:!0,shadowColor:!0,opacity:!0}};wl[bl]=!0;var Cl=["z","z2","invisible"],Ml=["invisible"],kl=function(t){function e(e){return t.call(this,e)||this}var n;return _(e,t),e.prototype._init=function(e){for(var n=an(e),i=0;i1e-4)return s[0]=t-n,s[1]=e-i,l[0]=t+n,void(l[1]=e+i);if(zl[0]=Ll(r)*n+t,zl[1]=Pl(r)*i+e,Ol[0]=Ll(o)*n+t,Ol[1]=Pl(o)*i+e,c(s,zl,Ol),u(l,zl,Ol),(r%=El)<0&&(r+=El),(o%=El)<0&&(o+=El),r>o&&!a?o+=El:rr&&(Nl[0]=Ll(p)*n+t,Nl[1]=Pl(p)*i+e,c(s,Nl,s),u(l,Nl,l))}var Wl={M:1,L:2,C:3,Q:4,A:5,Z:6,R:7},Ul=[],Gl=[],ql=[],jl=[],Xl=[],Yl=[],Zl=Math.min,Kl=Math.max,Ql=Math.cos,Jl=Math.sin,tc=Math.abs,ec=Math.PI,nc=2*ec,ic="undefined"!=typeof Float32Array,rc=[];function oc(t){return Math.round(t/ec*1e8)/1e8%2*ec}var ac=function(){function t(t){this.dpr=1,this._xi=0,this._yi=0,this._x0=0,this._y0=0,this._len=0,t&&(this._saveData=!1),this._saveData&&(this.data=[])}var e;return t.prototype.increaseVersion=function(){this._version++},t.prototype.getVersion=function(){return this._version},t.prototype.setScale=function(t,e,n){(n=n||0)>0&&(this._ux=tc(n/sa/t)||0,this._uy=tc(n/sa/e)||0)},t.prototype.setDPR=function(t){this.dpr=t},t.prototype.setContext=function(t){this._ctx=t},t.prototype.getContext=function(){return this._ctx},t.prototype.beginPath=function(){return this._ctx&&this._ctx.beginPath(),this.reset(),this},t.prototype.reset=function(){this._saveData&&(this._len=0),this._pathSegLen&&(this._pathSegLen=null,this._pathLen=0),this._version++},t.prototype.moveTo=function(t,e){return this._drawPendingPt(),this.addData(Wl.M,t,e),this._ctx&&this._ctx.moveTo(t,e),this._x0=t,this._y0=e,this._xi=t,this._yi=e,this},t.prototype.lineTo=function(t,e){var n=tc(t-this._xi),i=tc(e-this._yi),r=n>this._ux||i>this._uy;if(this.addData(Wl.L,t,e),this._ctx&&r&&this._ctx.lineTo(t,e),r)this._xi=t,this._yi=e,this._pendingPtDist=0;else{var o=n*n+i*i;o>this._pendingPtDist&&(this._pendingPtX=t,this._pendingPtY=e,this._pendingPtDist=o)}return this},t.prototype.bezierCurveTo=function(t,e,n,i,r,o){return this._drawPendingPt(),this.addData(Wl.C,t,e,n,i,r,o),this._ctx&&this._ctx.bezierCurveTo(t,e,n,i,r,o),this._xi=r,this._yi=o,this},t.prototype.quadraticCurveTo=function(t,e,n,i){return this._drawPendingPt(),this.addData(Wl.Q,t,e,n,i),this._ctx&&this._ctx.quadraticCurveTo(t,e,n,i),this._xi=n,this._yi=i,this},t.prototype.arc=function(t,e,n,i,r,o){this._drawPendingPt(),rc[0]=i,rc[1]=r,function(t,e){var n=oc(t[0]);n<0&&(n+=nc);var i=n-t[0],r=t[1];r+=i,!e&&r-n>=nc?r=n+nc:e&&n-r>=nc?r=n-nc:!e&&n>r?r=n+(nc-oc(n-r)):e&&n0&&o))for(var a=0;ac.length&&(this._expandData(),c=this.data);for(var u=0;u0&&(this._ctx&&this._ctx.lineTo(this._pendingPtX,this._pendingPtY),this._pendingPtDist=0)},t.prototype._expandData=function(){if(!(this.data instanceof Array)){for(var t=[],e=0;e11&&(this.data=new Float32Array(t)))}},t.prototype.getBoundingRect=function(){ql[0]=ql[1]=Xl[0]=Xl[1]=Number.MAX_VALUE,jl[0]=jl[1]=Yl[0]=Yl[1]=-Number.MAX_VALUE;var t,e=this.data,n=0,i=0,r=0,o=0;for(t=0;tn||tc(v)>i||h===e-1)&&(f=Math.sqrt(I*I+v*v),r=g,o=_);break;case Wl.C:var m=t[h++],y=t[h++],_=(g=t[h++],t[h++]),x=t[h++],b=t[h++];f=Ar(r,o,m,y,g,_,x,b,10),r=x,o=b;break;case Wl.Q:f=Or(r,o,m=t[h++],y=t[h++],g=t[h++],_=t[h++],10),r=g,o=_;break;case Wl.A:var w=t[h++],S=t[h++],C=t[h++],M=t[h++],k=t[h++],T=t[h++],D=T+k;h+=1,p&&(a=Ql(k)*C+w,s=Jl(k)*M+S),f=Kl(C,M)*Zl(nc,Math.abs(T)),r=Ql(D)*C+w,o=Jl(D)*M+S;break;case Wl.R:a=r=t[h++],s=o=t[h++],f=2*t[h++]+2*t[h++];break;case Wl.Z:var I=a-r;v=s-o;f=Math.sqrt(I*I+v*v),r=a,o=s}f>=0&&(l[u++]=f,c+=f)}return this._pathLen=c,c},t.prototype.rebuildPath=function(t,e){var n,i,r,o,a,s,l,c,u,h,d=this.data,p=this._ux,f=this._uy,g=this._len,v=e<1,m=0,y=0,_=0;if(!v||(this._pathSegLen||this._calculateLength(),l=this._pathSegLen,c=e*this._pathLen))t:for(var x=0;x0&&(t.lineTo(u,h),_=0),b){case Wl.M:n=r=d[x++],i=o=d[x++],t.moveTo(r,o);break;case Wl.L:a=d[x++],s=d[x++];var S=tc(a-r),C=tc(s-o);if(S>p||C>f){if(v){if(m+(X=l[y++])>c){var M=(c-m)/X;t.lineTo(r*(1-M)+a*M,o*(1-M)+s*M);break t}m+=X}t.lineTo(a,s),r=a,o=s,_=0}else{var k=S*S+C*C;k>_&&(u=a,h=s,_=k)}break;case Wl.C:var T=d[x++],D=d[x++],I=d[x++],A=d[x++],P=d[x++],L=d[x++];if(v){if(m+(X=l[y++])>c){Ir(r,T,I,P,M=(c-m)/X,Ul),Ir(o,D,A,L,M,Gl),t.bezierCurveTo(Ul[1],Gl[1],Ul[2],Gl[2],Ul[3],Gl[3]);break t}m+=X}t.bezierCurveTo(T,D,I,A,P,L),r=P,o=L;break;case Wl.Q:T=d[x++],D=d[x++],I=d[x++],A=d[x++];if(v){if(m+(X=l[y++])>c){zr(r,T,I,M=(c-m)/X,Ul),zr(o,D,A,M,Gl),t.quadraticCurveTo(Ul[1],Gl[1],Ul[2],Gl[2]);break t}m+=X}t.quadraticCurveTo(T,D,I,A),r=I,o=A;break;case Wl.A:var E=d[x++],z=d[x++],O=d[x++],N=d[x++],R=d[x++],H=d[x++],B=d[x++],F=!d[x++],$=O>N?O:N,V=tc(O-N)>.001,W=R+H,U=!1;if(v)m+(X=l[y++])>c&&(W=R+H*(c-m)/X,U=!0),m+=X;if(V&&t.ellipse?t.ellipse(E,z,O,N,B,R,W,F):t.arc(E,z,$,R,W,F),U)break t;w&&(n=Ql(R)*O+E,i=Jl(R)*N+z),r=Ql(W)*O+E,o=Jl(W)*N+z;break;case Wl.R:n=r=d[x],i=o=d[x+1],a=d[x++],s=d[x++];var G=d[x++],q=d[x++];if(v){if(m+(X=l[y++])>c){var j=c-m;t.moveTo(a,s),t.lineTo(a+Zl(j,G),s),(j-=G)>0&&t.lineTo(a+G,s+Zl(j,q)),(j-=q)>0&&t.lineTo(a+Kl(G-j,0),s+q),(j-=G)>0&&t.lineTo(a,s+Kl(q-j,0));break t}m+=X}t.rect(a,s,G,q);break;case Wl.Z:if(v){var X;if(m+(X=l[y++])>c){M=(c-m)/X;t.lineTo(r*(1-M)+n*M,o*(1-M)+i*M);break t}m+=X}t.closePath(),r=n,o=i}}},t.prototype.clone=function(){var e=new t,n=this.data;return e.data=n.slice?n.slice():Array.prototype.slice.call(n),e._len=this._len,e},t.prototype.canSave=function(){return!!this._saveData},t.CMD=Wl,t.initDefaultProps=((e=t.prototype)._saveData=!0,e._ux=0,e._uy=0,e._pendingPtDist=0,void(e._version=0)),t}();function sc(t,e,n,i,r,o,a){if(0===r)return!1;var s=r,l=0;if(a>e+s&&a>i+s||at+s&&o>n+s||oe+h&&u>i+h&&u>o+h&&u>s+h||ut+h&&c>n+h&&c>r+h&&c>a+h||c=0&&pe+c&&l>i+c&&l>o+c||lt+c&&s>n+c&&s>r+c||s=0&&gn||u+cr&&(r+=dc);var d=Math.atan2(l,s);return d<0&&(d+=dc),d>=i&&d<=r||d+dc>=i&&d+dc<=r}function fc(t,e,n,i,r,o){if(o>e&&o>i||or?s:0}var gc=ac.CMD,vc=2*Math.PI;var mc=[-1,-1,-1],yc=[-1,-1];function _c(){var t=yc[0];yc[0]=yc[1],yc[1]=t}function xc(t,e,n,i,r,o,a,s,l,c){if(c>e&&c>i&&c>o&&c>s||c1&&_c(),p=Mr(e,i,o,s,yc[0]),d>1&&(f=Mr(e,i,o,s,yc[1]))),2===d?ve&&s>i&&s>o||s=0&&u<=1&&(r[l++]=u);else{var c=a*a-4*o*s;if(Sr(c))(u=-a/(2*o))>=0&&u<=1&&(r[l++]=u);else if(c>0){var u,h=gr(c),d=(-a-h)/(2*o);(u=(-a+h)/(2*o))>=0&&u<=1&&(r[l++]=u),d>=0&&d<=1&&(r[l++]=d)}}return l}(e,i,o,s,mc);if(0===l)return 0;var c=Er(e,i,o);if(c>=0&&c<=1){for(var u=0,h=Pr(e,i,o,c),d=0;dn||s<-n)return 0;var l=Math.sqrt(n*n-s*s);mc[0]=-l,mc[1]=l;var c=Math.abs(i-r);if(c<1e-4)return 0;if(c>=vc-1e-4){i=0,r=vc;var u=o?1:-1;return a>=mc[0]+t&&a<=mc[1]+t?u:0}if(i>r){var h=i;i=r,r=h}i<0&&(i+=vc,r+=vc);for(var d=0,p=0;p<2;p++){var f=mc[p];if(f+t>a){var g=Math.atan2(s,f);u=o?1:-1;g<0&&(g=vc+g),(g>=i&&g<=r||g+vc>=i&&g+vc<=r)&&(g>Math.PI/2&&g<1.5*Math.PI&&(u=-u),d+=u)}}return d}function Sc(t,e,n,i,r){for(var o,a,s=t.data,l=t.len(),c=0,u=0,h=0,d=0,p=0,f=0;f1&&(n||(c+=fc(u,h,d,p,i,r))),v&&(d=u=s[f],p=h=s[f+1]),g){case gc.M:u=d=s[f++],h=p=s[f++];break;case gc.L:if(n){if(sc(u,h,s[f],s[f+1],e,i,r))return!0}else c+=fc(u,h,s[f],s[f+1],i,r)||0;u=s[f++],h=s[f++];break;case gc.C:if(n){if(lc(u,h,s[f++],s[f++],s[f++],s[f++],s[f],s[f+1],e,i,r))return!0}else c+=xc(u,h,s[f++],s[f++],s[f++],s[f++],s[f],s[f+1],i,r)||0;u=s[f++],h=s[f++];break;case gc.Q:if(n){if(cc(u,h,s[f++],s[f++],s[f],s[f+1],e,i,r))return!0}else c+=bc(u,h,s[f++],s[f++],s[f],s[f+1],i,r)||0;u=s[f++],h=s[f++];break;case gc.A:var m=s[f++],y=s[f++],_=s[f++],x=s[f++],b=s[f++],w=s[f++];f+=1;var S=!!(1-s[f++]);o=Math.cos(b)*_+m,a=Math.sin(b)*x+y,v?(d=o,p=a):c+=fc(u,h,o,a,i,r);var C=(i-m)*x/_+m;if(n){if(pc(m,y,x,b,b+w,S,e,C,r))return!0}else c+=wc(m,y,x,b,b+w,S,C,r);u=Math.cos(b+w)*_+m,h=Math.sin(b+w)*x+y;break;case gc.R:if(d=u=s[f++],p=h=s[f++],o=d+s[f++],a=p+s[f++],n){if(sc(d,p,o,p,e,i,r)||sc(o,p,o,a,e,i,r)||sc(o,a,d,a,e,i,r)||sc(d,a,d,p,e,i,r))return!0}else c+=fc(o,p,o,a,i,r),c+=fc(d,a,d,p,i,r);break;case gc.Z:if(n){if(sc(u,h,d,p,e,i,r))return!0}else c+=fc(u,h,d,p,i,r);u=d,h=p}}return n||function(t,e){return Math.abs(t-e)<1e-4}(h,p)||(c+=fc(u,h,d,p,i,r)||0),0!==c}var Cc=Ke({fill:"#000",stroke:null,strokePercent:1,fillOpacity:1,strokeOpacity:1,lineDashOffset:0,lineWidth:1,lineCap:"butt",miterLimit:10,strokeNoScale:!1,strokeFirst:!1},wl),Mc={style:Ke({fill:!0,stroke:!0,strokePercent:!0,fillOpacity:!0,strokeOpacity:!0,lineDashOffset:!0,lineWidth:!0,miterLimit:!0},Sl.style)},kc=_a.concat(["invisible","culling","z","z2","zlevel","parent"]),Tc=function(t){function e(e){return t.call(this,e)||this}var n;return _(e,t),e.prototype.update=function(){var n=this;t.prototype.update.call(this);var i=this.style;if(i.decal){var r=this._decalEl=this._decalEl||new e;r.buildPath===e.prototype.buildPath&&(r.buildPath=function(t){n.buildPath(t,n.shape)}),r.silent=!0;var o=r.style;for(var a in i)o[a]!==i[a]&&(o[a]=i[a]);o.fill=i.fill?i.decal:null,o.decal=null,o.shadowColor=null,i.strokeFirst&&(o.stroke=null);for(var s=0;s.5?la:e>.2?"#eee":ca}if(t)return ca}return la},e.prototype.getInsideTextStroke=function(t){var e=this.style.fill;if(hn(e)){var n=this.__zr;if(!(!n||!n.isDarkMode())===oo(t,0)<.4)return e}},e.prototype.buildPath=function(t,e,n){},e.prototype.pathUpdated=function(){this.__dirty&=-5},e.prototype.getUpdatedPathProxy=function(t){return!this.path&&this.createPathProxy(),this.path.beginPath(),this.buildPath(this.path,this.shape,t),this.path},e.prototype.createPathProxy=function(){this.path=new ac(!1)},e.prototype.hasStroke=function(){var t=this.style,e=t.stroke;return!(null==e||"none"===e||!(t.lineWidth>0))},e.prototype.hasFill=function(){var t=this.style.fill;return null!=t&&"none"!==t},e.prototype.getBoundingRect=function(){var t=this._rect,e=this.style,n=!t;if(n){var i=!1;this.path||(i=!0,this.createPathProxy());var r=this.path;(i||4&this.__dirty)&&(r.beginPath(),this.buildPath(r,this.shape,!1),this.pathUpdated()),t=r.getBoundingRect()}if(this._rect=t,this.hasStroke()&&this.path&&this.path.len()>0){var o=this._rectStroke||(this._rectStroke=t.clone());if(this.__dirty||n){o.copy(t);var a=e.strokeNoScale?this.getLineScale():1,s=e.lineWidth;if(!this.hasFill()){var l=this.strokeContainThreshold;s=Math.max(s,null==l?4:l)}a>1e-10&&(o.width+=s/a,o.height+=s/a,o.x-=s/a/2,o.y-=s/a/2)}return o}return t},e.prototype.contain=function(t,e){var n=this.transformCoordToLocal(t,e),i=this.getBoundingRect(),r=this.style;if(t=n[0],e=n[1],i.contain(t,e)){var o=this.path;if(this.hasStroke()){var a=r.lineWidth,s=r.strokeNoScale?this.getLineScale():1;if(s>1e-10&&(this.hasFill()||(a=Math.max(a,this.strokeContainThreshold)),function(t,e,n,i){return Sc(t,e,!0,n,i)}(o,a/s,t,e)))return!0}if(this.hasFill())return function(t,e,n){return Sc(t,0,!1,e,n)}(o,t,e)}return!1},e.prototype.dirtyShape=function(){this.__dirty|=4,this._rect&&(this._rect=null),this._decalEl&&this._decalEl.dirtyShape(),this.markRedraw()},e.prototype.dirty=function(){this.dirtyStyle(),this.dirtyShape()},e.prototype.animateShape=function(t){return this.animate("shape",t)},e.prototype.updateDuringAnimation=function(t){"style"===t?this.dirtyStyle():"shape"===t?this.dirtyShape():this.markRedraw()},e.prototype.attrKV=function(e,n){"shape"===e?this.setShape(n):t.prototype.attrKV.call(this,e,n)},e.prototype.setShape=function(t,e){var n=this.shape;return n||(n=this.shape={}),"string"==typeof t?n[t]=e:Ze(n,t),this.dirtyShape(),this},e.prototype.shapeChanged=function(){return!!(4&this.__dirty)},e.prototype.createStyle=function(t){return zn(Cc,t)},e.prototype._innerSaveToNormal=function(e){t.prototype._innerSaveToNormal.call(this,e);var n=this._normalState;e.shape&&!n.shape&&(n.shape=Ze({},this.shape))},e.prototype._applyStateObj=function(e,n,i,r,o,a){t.prototype._applyStateObj.call(this,e,n,i,r,o,a);var s,l=!(n&&r);if(n&&n.shape?o?r?s=n.shape:(s=Ze({},i.shape),Ze(s,n.shape)):(s=Ze({},r?this.shape:i.shape),Ze(s,n.shape)):l&&(s=i.shape),s)if(o){this.shape=Ze({},this.shape);for(var c={},u=an(s),h=0;hc&&(n*=c/(a=n+i),i*=c/a),r+o>c&&(r*=c/(a=r+o),o*=c/a),i+r>u&&(i*=u/(a=i+r),r*=u/a),n+o>u&&(n*=u/(a=n+o),o*=u/a),t.moveTo(s+n,l),t.lineTo(s+c-i,l),0!==i&&t.arc(s+c-i,l+i,i,-Math.PI/2,0),t.lineTo(s+c,l+u-r),0!==r&&t.arc(s+c-r,l+u-r,r,0,Math.PI/2),t.lineTo(s+o,l+u),0!==o&&t.arc(s+o,l+u-o,o,Math.PI/2,Math.PI),t.lineTo(s,l+n),0!==n&&t.arc(s+n,l+n,n,Math.PI,1.5*Math.PI)}(t,e):t.rect(n,i,r,o)},e.prototype.isZeroArea=function(){return!this.shape.width||!this.shape.height},e}(Tc);Bc.prototype.type="rect";var Fc={fill:"#000"},$c={},Vc={style:Ke({fill:!0,stroke:!0,fillOpacity:!0,strokeOpacity:!0,lineWidth:!0,fontSize:!0,lineHeight:!0,width:!0,height:!0,textShadowColor:!0,textShadowBlur:!0,textShadowOffsetX:!0,textShadowOffsetY:!0,backgroundColor:!0,padding:!0,borderColor:!0,borderWidth:!0,borderRadius:!0},Sl.style)},Wc=function(t){function e(e){var n=t.call(this)||this;return n.type="text",n._children=[],n._defaultStyle=Fc,n.attr(e),n}return _(e,t),e.prototype.childrenRef=function(){return this._children},e.prototype.update=function(){t.prototype.update.call(this),this.styleChanged()&&this._updateSubTexts();for(var e=0;em&&p){var _=Math.floor(m/d);f=f||v.length>_,y=(v=v.slice(0,_)).length*d}if(r&&u&&null!=g)for(var x=ol(g,c,e.ellipsis,{minChar:e.truncateMinChar,placeholder:e.placeholder}),b={},w=0;w0,M=0;Mg&&hl(o,a.substring(g,v),e,f),hl(o,d[2],e,f,d[1]),g=il.lastIndex}gh){var z=o.lines.length;D>0?(M.tokens=M.tokens.slice(0,D),S(M,T,k),o.lines=o.lines.slice(0,C+1)):o.lines=o.lines.slice(0,C),o.isTruncated=o.isTruncated||o.lines.length=0&&"right"===(T=_[k]).align;)this._placeToken(T,t,b,f,M,"right",v),w-=T.width,M-=T.width,k--;for(C+=(s-(C-p)-(g-M)-w)/2;S<=k;)T=_[S],this._placeToken(T,t,b,f,C+T.width/2,"center",v),C+=T.width,S++;f+=b}},e.prototype._placeToken=function(t,e,n,i,r,o,a){var s=e.rich[t.styleName]||{};s.text=t.text;var l=t.verticalAlign,c=i+n/2;"top"===l?c=i+t.height/2:"bottom"===l&&(c=i+n-t.height/2),!t.isLineHolder&&eu(s)&&this._renderBackground(s,e,"right"===o?r-t.width:"center"===o?r-t.width/2:r,c-t.height/2,t.width,t.height);var u=!!s.backgroundColor,h=t.textPadding;h&&(r=Jc(r,o,h),c-=t.height/2-h[0]-t.innerHeight/2);var d=this._getOrCreateChild(Ic),p=d.createStyle();d.useStyle(p);var f=this._defaultStyle,g=!1,v=0,m=!1,y=Qc("fill"in s?s.fill:"fill"in e?e.fill:(g=!0,f.fill)),_=Kc("stroke"in s?s.stroke:"stroke"in e?e.stroke:u||a||f.autoStroke&&!g?null:(v=2,m=!0,f.stroke)),x=s.textShadowBlur>0||e.textShadowBlur>0;p.text=t.text,p.x=r,p.y=c,x&&(p.shadowBlur=s.textShadowBlur||e.textShadowBlur||0,p.shadowColor=s.textShadowColor||e.textShadowColor||"transparent",p.shadowOffsetX=s.textShadowOffsetX||e.textShadowOffsetX||0,p.shadowOffsetY=s.textShadowOffsetY||e.textShadowOffsetY||0),p.textAlign=o,p.textBaseline="middle",p.font=t.font||Ie,p.opacity=wn(s.opacity,e.opacity,1),Xc(p,s),_&&(p.lineWidth=wn(s.lineWidth,e.lineWidth,v),p.lineDash=bn(s.lineDash,e.lineDash),p.lineDashOffset=e.lineDashOffset||0,p.stroke=_),y&&(p.fill=y),d.setBoundingRect(_l(p,t.contentWidth,t.contentHeight,m?0:null))},e.prototype._renderBackground=function(t,e,n,i,r,o){var a,s,l,c=t.backgroundColor,u=t.borderWidth,h=t.borderColor,d=c&&c.image,p=c&&!d,f=t.borderRadius,g=this;if(p||t.lineHeight||u&&h){(a=this._getOrCreateChild(Bc)).useStyle(a.createStyle()),a.style.fill=null;var v=a.shape;v.x=n,v.y=i,v.width=r,v.height=o,v.r=f,a.dirtyShape()}if(p)(l=a.style).fill=c||null,l.fillOpacity=bn(t.fillOpacity,1);else if(d){(s=this._getOrCreateChild(Lc)).onload=function(){g.dirtyStyle()};var m=s.style;m.image=c.image,m.x=n,m.y=i,m.width=r,m.height=o}u&&h&&((l=a.style).lineWidth=u,l.stroke=h,l.strokeOpacity=bn(t.strokeOpacity,1),l.lineDash=t.borderDash,l.lineDashOffset=t.borderDashOffset||0,a.strokeContainThreshold=0,a.hasFill()&&a.hasStroke()&&(l.strokeFirst=!0,l.lineWidth*=2));var y=(a||s).style;y.shadowBlur=t.shadowBlur||0,y.shadowColor=t.shadowColor||"transparent",y.shadowOffsetX=t.shadowOffsetX||0,y.shadowOffsetY=t.shadowOffsetY||0,y.opacity=wn(t.opacity,e.opacity,1)},e.makeFont=function(t){var e="";return Yc(t)&&(e=[t.fontStyle,t.fontWeight,jc(t.fontSize),t.fontFamily||"sans-serif"].join(" ")),e&&kn(e)||t.textFont||t.font},e}(kl),Uc={left:!0,right:1,center:1},Gc={top:1,bottom:1,middle:1},qc=["fontStyle","fontWeight","fontSize","fontFamily"];function jc(t){return"string"!=typeof t||-1===t.indexOf("px")&&-1===t.indexOf("rem")&&-1===t.indexOf("em")?isNaN(+t)?"12px":t+"px":t}function Xc(t,e){for(var n=0;n=0,o=!1;if(t instanceof Tc){var a=ou(t),s=r&&a.selectFill||a.normalFill,l=r&&a.selectStroke||a.normalStroke;if(gu(s)||gu(l)){var c=(i=i||{}).style||{};"inherit"===c.fill?(o=!0,i=Ze({},i),(c=Ze({},c)).fill=s):!gu(c.fill)&&gu(s)?(o=!0,i=Ze({},i),(c=Ze({},c)).fill=so(s)):!gu(c.stroke)&&gu(l)&&(o||(i=Ze({},i),c=Ze({},c)),c.stroke=so(l)),i.style=c}}if(i&&null==i.z2){o||(i=Ze({},i));var u=t.z2EmphasisLift;i.z2=t.z2+(null!=u?u:10)}return i}(this,0,e,n);if("blur"===t)return function(t,e,n){var i=Qe(t.currentStates,e)>=0,r=t.style.opacity,o=i?null:function(t,e,n,i){for(var r=t.style,o={},a=0;a0){var o={dataIndex:r,seriesIndex:t.seriesIndex};null!=i&&(o.dataType=i),e.push(o)}})}),e}function Vu(t,e,n){ju(t,!0),Cu(t,Tu),function(t,e,n){var i=nu(t);null!=e?(i.focus=e,i.blurScope=n):i.focus&&(i.focus=null)}(t,e,n)}function Wu(t,e,n,i){i?function(t){ju(t,!1)}(t):Vu(t,e,n)}var Uu=["emphasis","blur","select"],Gu={itemStyle:"getItemStyle",lineStyle:"getLineStyle",areaStyle:"getAreaStyle"};function qu(t,e,n,i){n=n||"itemStyle";for(var r=0;r1&&(a*=eh(f),s*=eh(f));var g=(r===o?-1:1)*eh((a*a*(s*s)-a*a*(p*p)-s*s*(d*d))/(a*a*(p*p)+s*s*(d*d)))||0,v=g*a*p/s,m=g*-s*d/a,y=(t+n)/2+ih(h)*v-nh(h)*m,_=(e+i)/2+nh(h)*v+ih(h)*m,x=sh([1,0],[(d-v)/a,(p-m)/s]),b=[(d-v)/a,(p-m)/s],w=[(-1*d-v)/a,(-1*p-m)/s],S=sh(b,w);if(ah(b,w)<=-1&&(S=rh),ah(b,w)>=1&&(S=0),S<0){var C=Math.round(S/rh*1e6)/1e6;S=2*rh+C%2*rh}u.addData(c,y,_,a,s,x,S,h,o)}var ch=/([mlvhzcqtsa])([^mlvhzcqtsa]*)/gi,uh=/-?([0-9]*\.)?[0-9]+([eE]-?[0-9]+)?/g;var hh=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return _(e,t),e.prototype.applyTransform=function(t){},e}(Tc);function dh(t){return null!=t.setData}function ph(t,e){var n=function(t){var e=new ac;if(!t)return e;var n,i=0,r=0,o=i,a=r,s=ac.CMD,l=t.match(ch);if(!l)return e;for(var c=0;cA*A+P*P&&(C=k,M=T),{cx:C,cy:M,x0:-u,y0:-h,x1:C*(r/b-1),y1:M*(r/b-1)}}function Ah(t,e){var n,i=kh(e.r,0),r=kh(e.r0||0,0),o=i>0;if(o||r>0){if(o||(i=r,r=0),r>i){var a=i;i=r,r=a}var s=e.startAngle,l=e.endAngle;if(!isNaN(s)&&!isNaN(l)){var c=e.cx,u=e.cy,h=!!e.clockwise,d=Ch(l-s),p=d>_h&&d%_h;if(p>Dh&&(d=p),i>Dh)if(d>_h-Dh)t.moveTo(c+i*bh(s),u+i*xh(s)),t.arc(c,u,i,s,l,!h),r>Dh&&(t.moveTo(c+r*bh(l),u+r*xh(l)),t.arc(c,u,r,l,s,h));else{var f=void 0,g=void 0,v=void 0,m=void 0,y=void 0,_=void 0,x=void 0,b=void 0,w=void 0,S=void 0,C=void 0,M=void 0,k=void 0,T=void 0,D=void 0,I=void 0,A=i*bh(s),P=i*xh(s),L=r*bh(l),E=r*xh(l),z=d>Dh;if(z){var O=e.cornerRadius;O&&(n=function(t){var e;if(cn(t)){var n=t.length;if(!n)return t;e=1===n?[t[0],t[0],0,0]:2===n?[t[0],t[0],t[1],t[1]]:3===n?t.concat(t[2]):t}else e=[t,t,t,t];return e}(O),f=n[0],g=n[1],v=n[2],m=n[3]);var N=Ch(i-r)/2;if(y=Th(N,v),_=Th(N,m),x=Th(N,f),b=Th(N,g),C=w=kh(y,_),M=S=kh(x,b),(w>Dh||S>Dh)&&(k=i*bh(l),T=i*xh(l),D=r*bh(s),I=r*xh(s),dDh){var U=Th(v,C),G=Th(m,C),q=Ih(D,I,A,P,i,U,h),j=Ih(k,T,L,E,i,G,h);t.moveTo(c+q.cx+q.x0,u+q.cy+q.y0),C0&&t.arc(c+q.cx,u+q.cy,U,Sh(q.y0,q.x0),Sh(q.y1,q.x1),!h),t.arc(c,u,i,Sh(q.cy+q.y1,q.cx+q.x1),Sh(j.cy+j.y1,j.cx+j.x1),!h),G>0&&t.arc(c+j.cx,u+j.cy,G,Sh(j.y1,j.x1),Sh(j.y0,j.x0),!h))}else t.moveTo(c+A,u+P),t.arc(c,u,i,s,l,!h);else t.moveTo(c+A,u+P);if(r>Dh&&z)if(M>Dh){U=Th(f,M),q=Ih(L,E,k,T,r,-(G=Th(g,M)),h),j=Ih(A,P,D,I,r,-U,h);t.lineTo(c+q.cx+q.x0,u+q.cy+q.y0),M0&&t.arc(c+q.cx,u+q.cy,G,Sh(q.y0,q.x0),Sh(q.y1,q.x1),!h),t.arc(c,u,r,Sh(q.cy+q.y1,q.cx+q.x1),Sh(j.cy+j.y1,j.cx+j.x1),h),U>0&&t.arc(c+j.cx,u+j.cy,U,Sh(j.y1,j.x1),Sh(j.y0,j.x0),!h))}else t.lineTo(c+L,u+E),t.arc(c,u,r,l,s,h);else t.lineTo(c+L,u+E)}else t.moveTo(c,u);t.closePath()}}}var Ph=function(){this.cx=0,this.cy=0,this.r0=0,this.r=0,this.startAngle=0,this.endAngle=2*Math.PI,this.clockwise=!0,this.cornerRadius=0},Lh=function(t){function e(e){return t.call(this,e)||this}return _(e,t),e.prototype.getDefaultShape=function(){return new Ph},e.prototype.buildPath=function(t,e){Ah(t,e)},e.prototype.isZeroArea=function(){return this.shape.startAngle===this.shape.endAngle||this.shape.r===this.shape.r0},e}(Tc);Lh.prototype.type="sector";var Eh=function(){this.cx=0,this.cy=0,this.r=0,this.r0=0},zh=function(t){function e(e){return t.call(this,e)||this}return _(e,t),e.prototype.getDefaultShape=function(){return new Eh},e.prototype.buildPath=function(t,e){var n=e.cx,i=e.cy,r=2*Math.PI;t.moveTo(n+e.r,i),t.arc(n,i,e.r,0,r,!1),t.moveTo(n+e.r0,i),t.arc(n,i,e.r0,0,r,!0)},e}(Tc);function Oh(t,e,n){var i=e.smooth,r=e.points;if(r&&r.length>=2){if(i){var o=function(t,e,n,i){var r,o,a,s,l=[],c=[],u=[],h=[];if(i){a=[1/0,1/0],s=[-1/0,-1/0];for(var d=0,p=t.length;did[1]){if(r=!1,rd.negativeSize||n)return r;var s=ed(id[0]-nd[1]),l=ed(nd[0]-id[1]);Jh(s,l)>ad.len()&&(s=l||!rd.bidirectional)&&(Ti.scale(od,a,-l*i),rd.useDir&&rd.calcDirMTV()))}}return r},t.prototype._getProjMinMaxOnAxis=function(t,e,n){for(var i=this._axes[t],r=this._origin,o=e[0].dot(i)+r[t],a=o,s=o,l=1;l0){var h={duration:u.duration,delay:u.delay||0,easing:u.easing,done:o,force:!!o||!!a,setToFinal:!c,scope:t,during:a};l?e.animateFrom(n,h):e.animateTo(n,h)}else e.stopAnimation(),!l&&e.attr(n),a&&a(1),o&&o()}function dd(t,e,n,i,r,o){hd("update",t,e,n,i,r,o)}function pd(t,e,n,i,r,o){hd("enter",t,e,n,i,r,o)}function fd(t){if(!t.__zr)return!0;for(var e=0;e=-1e-6)return!1;var f=t-r,g=e-o,v=zd(f,g,c,u)/p;if(v<0||v>1)return!1;var m=zd(f,g,h,d)/p;return!(m<0||m>1)}function zd(t,e,n,i){return t*i-n*e}function Od(t,e,n,i,r){return null==e||(pn(e)?Nd[0]=Nd[1]=Nd[2]=Nd[3]=e:(Nd[0]=e[0],Nd[1]=e[1],Nd[2]=e[2],Nd[3]=e[3]),i&&(Nd[0]=Qa(0,Nd[0]),Nd[1]=Qa(0,Nd[1]),Nd[2]=Qa(0,Nd[2]),Nd[3]=Qa(0,Nd[3])),n&&(Nd[0]=-Nd[0],Nd[1]=-Nd[1],Nd[2]=-Nd[2],Nd[3]=-Nd[3]),Rd(t,Nd,"x","width",3,1,r&&r[0]||0),Rd(t,Nd,"y","height",0,2,r&&r[1]||0)),t}var Nd=[0,0,0,0];function Rd(t,e,n,i,r,o,a){var s=e[o]+e[r],l=t[i];t[i]+=s,a=Qa(0,Ka(a,l)),t[i]=0?-e[r]:e[o]>=0?l+e[o]:Ja(s)>1e-8?(l-a)*e[r]/s:0):t[n]-=e[r]}function Hd(t){var e=t.itemTooltipOption,n=t.componentModel,i=t.itemName,r=hn(e)?{formatter:e}:e,o=n.mainType,a=n.componentIndex,s={componentType:o,name:i,$vars:["name"]};s[o+"Index"]=a;var l=t.formatterParamsExtra;l&&en(an(l),function(t){On(s,t)||(s[t]=l[t],s.$vars.push(t))});var c=nu(t.el);c.componentMainType=o,c.componentIndex=a,c.tooltipConfig={name:i,option:Ke({content:i,encodeHTMLContent:!0,formatterParams:s},r)}}function Bd(t,e){var n;t.isGroup&&(n=e(t)),n||t.traverse(e)}function Fd(t,e){if(t)if(cn(t))for(var n=0;ne&&(e=i),ie&&(n=e=0),{min:n,max:e}},clipPointsByRect:function(t,e){return nn(t,function(t){var n=t[0];n=Qa(n,e.x),n=Ka(n,e.x+e.width);var i=t[1];return i=Qa(i,e.y),[n,i=Ka(i,e.y+e.height)]})},clipRectByRect:function(t,e){var n=Qa(t.x,e.x),i=Ka(t.x+t.width,e.x+e.width),r=Qa(t.y,e.y),o=Ka(t.y+t.height,e.y+e.height);if(i>=n&&o>=r)return{x:n,y:r,width:i-n,height:o-r}},createIcon:Ld,ensureCopyRect:Wd,ensureCopyTransform:Ud,expandOrShrinkRect:Od,extendPath:function(t,e){return xd(t,e)},extendShape:function(t){return Tc.extend(t)},getShapeClass:function(t){if(md.hasOwnProperty(t))return md[t]},getTransform:function(t,e){for(var n=bi([]);t&&t!==e;)Si(n,t.getLocalTransform(),n),t=t.parent;return n},groupTransition:Pd,initProps:pd,isBoundingRectAxisAligned:$d,isElementRemoved:fd,lineLineIntersect:Ed,linePolygonIntersect:function(t,e,n,i,r){for(var o=0,a=r[r.length-1];oJa(o[1])?o[0]>0?"right":"left":o[1]>0?"bottom":"top"},traverseElements:Fd,traverseUpdateZ:qd,updateProps:dd}),Yd={};function Zd(t,e,n){var i,r=t.labelFetcher,o=t.labelDataIndex,a=t.labelDimIndex,s=e.normal;r&&(i=r.getFormattedLabel(o,"normal",null,a,s&&s.get("formatter"),null!=n?{interpolatedValue:n}:null)),null==i&&(i=un(t.defaultText)?t.defaultText(o,t,n):t.defaultText);for(var l={normal:i},c=0;c-1?wp:Cp;function Dp(t,e){t=t.toUpperCase(),kp[t]=new _p(e),Mp[t]=e}Dp(Sp,{time:{month:["January","February","March","April","May","June","July","August","September","October","November","December"],monthAbbr:["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"],dayOfWeek:["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"],dayOfWeekAbbr:["Sun","Mon","Tue","Wed","Thu","Fri","Sat"]},legend:{selector:{all:"All",inverse:"Inv"}},toolbox:{brush:{title:{rect:"Box Select",polygon:"Lasso Select",lineX:"Horizontally Select",lineY:"Vertically Select",keep:"Keep Selections",clear:"Clear Selections"}},dataView:{title:"Data View",lang:["Data View","Close","Refresh"]},dataZoom:{title:{zoom:"Zoom",back:"Zoom Reset"}},magicType:{title:{line:"Switch to Line Chart",bar:"Switch to Bar Chart",stack:"Stack",tiled:"Tile"}},restore:{title:"Restore"},saveAsImage:{title:"Save as Image",lang:["Right Click to Save Image"]}},series:{typeNames:{pie:"Pie chart",bar:"Bar chart",line:"Line chart",scatter:"Scatter plot",effectScatter:"Ripple scatter plot",radar:"Radar chart",tree:"Tree",treemap:"Treemap",boxplot:"Boxplot",candlestick:"Candlestick",k:"K line chart",heatmap:"Heat map",map:"Map",parallel:"Parallel coordinate map",lines:"Line graph",graph:"Relationship graph",sankey:"Sankey diagram",funnel:"Funnel chart",gauge:"Gauge",pictorialBar:"Pictorial bar",themeRiver:"Theme River Map",sunburst:"Sunburst",custom:"Custom chart",chart:"Chart"}},aria:{general:{withTitle:'This is a chart about "{title}"',withoutTitle:"This is a chart"},series:{single:{prefix:"",withName:" with type {seriesType} named {seriesName}.",withoutName:" with type {seriesType}."},multiple:{prefix:". It consists of {seriesCount} series count.",withName:" The {seriesId} series is a {seriesType} representing {seriesName}.",withoutName:" The {seriesId} series is a {seriesType}.",separator:{middle:"",end:""}}},data:{allData:"The data is as follows: ",partialData:"The first {displayCnt} items are: ",withName:"the data for {name} is {value}",withoutName:"{value}",separator:{middle:", ",end:". "}}}}),Dp(wp,{time:{month:["一月","二月","三月","四月","五月","六月","七月","八月","九月","十月","十一月","十二月"],monthAbbr:["1月","2月","3月","4月","5月","6月","7月","8月","9月","10月","11月","12月"],dayOfWeek:["星期日","星期一","星期二","星期三","星期四","星期五","星期六"],dayOfWeekAbbr:["日","一","二","三","四","五","六"]},legend:{selector:{all:"全选",inverse:"反选"}},toolbox:{brush:{title:{rect:"矩形选择",polygon:"圈选",lineX:"横向选择",lineY:"纵向选择",keep:"保持选择",clear:"清除选择"}},dataView:{title:"数据视图",lang:["数据视图","关闭","刷新"]},dataZoom:{title:{zoom:"区域缩放",back:"区域缩放还原"}},magicType:{title:{line:"切换为折线图",bar:"切换为柱状图",stack:"切换为堆叠",tiled:"切换为平铺"}},restore:{title:"还原"},saveAsImage:{title:"保存为图片",lang:["右键另存为图片"]}},series:{typeNames:{pie:"饼图",bar:"柱状图",line:"折线图",scatter:"散点图",effectScatter:"涟漪散点图",radar:"雷达图",tree:"树图",treemap:"矩形树图",boxplot:"箱型图",candlestick:"K线图",k:"K线图",heatmap:"热力图",map:"地图",parallel:"平行坐标图",lines:"线图",graph:"关系图",sankey:"桑基图",funnel:"漏斗图",gauge:"仪表盘图",pictorialBar:"象形柱图",themeRiver:"主题河流图",sunburst:"旭日图",custom:"自定义图表",chart:"图表"}},aria:{general:{withTitle:"这是一个关于“{title}”的图表。",withoutTitle:"这是一个图表,"},series:{single:{prefix:"",withName:"图表类型是{seriesType},表示{seriesName}。",withoutName:"图表类型是{seriesType}。"},multiple:{prefix:"它由{seriesCount}个图表系列组成。",withName:"第{seriesId}个系列是一个表示{seriesName}的{seriesType},",withoutName:"第{seriesId}个系列是一个{seriesType},",separator:{middle:";",end:"。"}}},data:{allData:"其数据是——",partialData:"其中,前{displayCnt}项是——",withName:"{name}的数据是{value}",withoutName:"{value}",separator:{middle:",",end:""}}}});function Ip(){return null}var Ap=1e3,Pp=6e4,Lp=36e5,Ep=864e5,zp=31536e6,Op={year:/({yyyy}|{yy})/,month:/({MMMM}|{MMM}|{MM}|{M})/,day:/({dd}|{d})/,hour:/({HH}|{H}|{hh}|{h})/,minute:/({mm}|{m})/,second:/({ss}|{s})/,millisecond:/({SSS}|{S})/},Np={year:"{yyyy}",month:"{MMM}",day:"{d}",hour:"{HH}:{mm}",minute:"{HH}:{mm}",second:"{HH}:{mm}:{ss}",millisecond:"{HH}:{mm}:{ss} {SSS}"},Rp="{yyyy}-{MM}-{dd}",Hp={year:"{yyyy}",month:"{yyyy}-{MM}",day:Rp,hour:Rp+" "+Np.hour,minute:Rp+" "+Np.minute,second:Rp+" "+Np.second,millisecond:"{yyyy}-{MM}-{dd} {HH}:{mm}:{ss} {SSS}"},Bp=["year","month","day","hour","minute","second","millisecond"],Fp=["year","half-year","quarter","month","week","half-week","day","half-day","quarter-day","hour","minute","second","millisecond"];function $p(t){return hn(t)||un(t)?t:function(t){t=t||{};var e={},n=!0;return en(Bp,function(e){n&&(n=null==t[e])}),en(Bp,function(i,r){var o=t[i];e[i]={};for(var a=null,s=r;s>=0;s--){var l=Bp[s],c=fn(o)&&!cn(o)?o[l]:o,u=void 0;cn(c)?a=(u=c.slice())[0]||"":hn(c)?u=[a=c]:(null==a?a=Np[i]:Op[l].test(a)||(a=e[l][l][0]+" "+a),u=[a],n&&(u[1]="{primary|"+a+"}")),e[i][l]=u}}),e}(t)}function Vp(t,e){return"0000".substr(0,e-(t+="").length)+t}function Wp(t){switch(t){case"half-year":case"quarter":return"month";case"week":case"half-week":return"day";case"half-day":case"quarter-day":return"hour";default:return t}}function Up(t){return t===Wp(t)}function Gp(t,e,n,i){var r=cs(t),o=r[Xp(n)](),a=r[Yp(n)]()+1,s=Math.floor((a-1)/3)+1,l=r[Zp(n)](),c=r["get"+(n?"UTC":"")+"Day"](),u=r[Kp(n)](),h=(u-1)%12+1,d=r[Qp(n)](),p=r[Jp(n)](),f=r[tf(n)](),g=u>=12?"pm":"am",v=g.toUpperCase(),m=i instanceof _p?i:function(t){return kp[t]}(i||Tp)||kp[Cp],y=m.getModel("time"),_=y.get("month"),x=y.get("monthAbbr"),b=y.get("dayOfWeek"),w=y.get("dayOfWeekAbbr");return(e||"").replace(/{a}/g,g+"").replace(/{A}/g,v+"").replace(/{yyyy}/g,o+"").replace(/{yy}/g,Vp(o%100+"",2)).replace(/{Q}/g,s+"").replace(/{MMMM}/g,_[a-1]).replace(/{MMM}/g,x[a-1]).replace(/{MM}/g,Vp(a,2)).replace(/{M}/g,a+"").replace(/{dd}/g,Vp(l,2)).replace(/{d}/g,l+"").replace(/{eeee}/g,b[c]).replace(/{ee}/g,w[c]).replace(/{e}/g,c+"").replace(/{HH}/g,Vp(u,2)).replace(/{H}/g,u+"").replace(/{hh}/g,Vp(h+"",2)).replace(/{h}/g,h+"").replace(/{mm}/g,Vp(d,2)).replace(/{m}/g,d+"").replace(/{ss}/g,Vp(p,2)).replace(/{s}/g,p+"").replace(/{SSS}/g,Vp(f,3)).replace(/{S}/g,f+"")}function qp(t,e){var n=cs(t),i=n[Yp(e)]()+1,r=n[Zp(e)](),o=n[Kp(e)](),a=n[Qp(e)](),s=n[Jp(e)](),l=0===n[tf(e)](),c=l&&0===s,u=c&&0===a,h=u&&0===o,d=h&&1===r;return d&&1===i?"year":d?"month":h?"day":u?"hour":c?"minute":l?"second":"millisecond"}function jp(t,e,n){switch(e){case"year":t[nf(n)](0);case"month":t[rf(n)](1);case"day":t[of(n)](0);case"hour":t[af(n)](0);case"minute":t[sf(n)](0);case"second":t[lf(n)](0)}return t}function Xp(t){return t?"getUTCFullYear":"getFullYear"}function Yp(t){return t?"getUTCMonth":"getMonth"}function Zp(t){return t?"getUTCDate":"getDate"}function Kp(t){return t?"getUTCHours":"getHours"}function Qp(t){return t?"getUTCMinutes":"getMinutes"}function Jp(t){return t?"getUTCSeconds":"getSeconds"}function tf(t){return t?"getUTCMilliseconds":"getMilliseconds"}function ef(t){return t?"setUTCFullYear":"setFullYear"}function nf(t){return t?"setUTCMonth":"setMonth"}function rf(t){return t?"setUTCDate":"setDate"}function of(t){return t?"setUTCHours":"setHours"}function af(t){return t?"setUTCMinutes":"setMinutes"}function sf(t){return t?"setUTCSeconds":"setSeconds"}function lf(t){return t?"setUTCMilliseconds":"setMilliseconds"}function cf(t){if(isNaN(ds(t)))return hn(t)?t:"-";var e=(t+"").split(".");return e[0].replace(/(\d{1,3})(?=(?:\d{3})+(?!\d))/g,"$1,")+(e.length>1?"."+e[1]:"")}function uf(t,e){return t=(t||"").toLowerCase().replace(/-(.)/g,function(t,e){return e.toUpperCase()}),e&&t&&(t=t.charAt(0).toUpperCase()+t.slice(1)),t}var hf=Cn;function df(t,e,n){function i(t){return t&&kn(t)?t:"-"}function r(t){return!(null==t||isNaN(t)||!isFinite(t))}var o="time"===e,a=t instanceof Date;if(o||a){var s=o?cs(t):t;if(!isNaN(+s))return Gp(s,"{yyyy}-{MM}-{dd} {HH}:{mm}:{ss}",n);if(a)return"-"}if("ordinal"===e)return dn(t)?i(t):pn(t)&&r(t)?t+"":"-";var l=ds(t);return r(l)?cf(l):dn(t)?i(t):"boolean"==typeof t?t+"":"-"}var pf=["a","b","c","d","e","f","g"],ff=function(t,e){return"{"+t+(null==e?"":e)+"}"};function gf(t,e,n){cn(e)||(e=[e]);var i=e.length;if(!i)return"";for(var r=e[0].$vars||[],o=0;oi||l.newline?(o=0,u=g,a+=s+n,s=d.height):s=Math.max(s,d.height)}else{var v=d.height+(f?-f.y+d.y:0);(h=a+v)>r||l.newline?(o+=s+n,a=0,h=v,s=d.width):s=Math.max(s,d.width)}l.newline||(l.x=o,l.y=a,l.markRedraw(),"horizontal"===t?o=u+n:a=h+n)})}function Pf(t,e,n){n=hf(n||0);var i=e.width,r=e.height,o=es(t.left,i),a=es(t.top,r),s=es(t.right,i),l=es(t.bottom,r),c=es(t.width,i),u=es(t.height,r),h=n[2]+n[0],d=n[1]+n[3],p=t.aspect;switch(isNaN(c)&&(c=i-s-d-o),isNaN(u)&&(u=r-l-h-a),null!=p&&(isNaN(c)&&isNaN(u)&&(p>i/r?c=.8*i:u=.8*r),isNaN(c)&&(c=p*u),isNaN(u)&&(u=c/p)),isNaN(o)&&(o=i-s-c-d),isNaN(a)&&(a=r-l-u-h),t.left||t.right){case"center":o=i/2-c/2-n[3];break;case"right":o=i-c-d}switch(t.top||t.bottom){case"middle":case"center":a=r/2-u/2-n[0];break;case"bottom":a=r-u-h}o=o||0,a=a||0,isNaN(c)&&(c=i-d-o-(s||0)),isNaN(u)&&(u=r-h-a-(l||0));var f=new $i((e.x||0)+o+n[3],(e.y||0)+a+n[0],c,u);return f.margin=n,f}ln(Af,"vertical"),ln(Af,"horizontal");var Lf=1;function Ef(t,e,n){var i,r,o,a,s=t.boxCoordinateSystem;if(s){var l=function(t){var e=t.getShallow("coord",!0),n=xf;if(null==e){var i=wf.get(t.type);i&&i.getCoord2&&(n=bf,e=i.getCoord2(t))}return{coord:e,from:n}}(t),c=l.coord,u=l.from;if(s.dataToLayout){o=Lf,a=u;var h=s.dataToLayout(c);i=h.contentRect||h.rect}}return null==o&&(o=Lf),o===Lf&&(i||(i={x:0,y:0,width:e.getWidth(),height:e.getHeight()}),r=[i.x+i.width/2,i.y+i.height/2]),{type:o,refContainer:i,refPoint:r,boxCoordFrom:a}}function zf(t){var e=t.layoutMode||t.constructor.layoutMode;return fn(e)?e:e?{type:e}:null}function Of(t,e,n){var i=n&&n.ignoreSize;!cn(i)&&(i=[i,i]);var r=a(If[0],0),o=a(If[1],1);function a(n,r){var o={},a=0,l={},c=0;if(Tf(n,function(e){l[e]=t[e]}),Tf(n,function(t){On(e,t)&&(o[t]=l[t]=e[t]),s(o,t)&&a++,s(l,t)&&c++}),i[r])return s(e,n[1])?l[n[2]]=null:s(e,n[2])&&(l[n[1]]=null),l;if(2!==c&&a){if(a>=2)return o;for(var u=0;u=0;a--)o=Ye(o,n[a],!0);e.defaultOption=o}return e.defaultOption},e.prototype.getReferringComponents=function(t,e){var n=t+"Index",i=t+"Id";return Hs(this.ecModel,t,{index:this.get(n,!0),id:this.get(i,!0)},e)},e.prototype.getBoxLayoutParams=function(){return e=!1,{left:(t=this).getShallow("left",e),top:t.getShallow("top",e),right:t.getShallow("right",e),bottom:t.getShallow("bottom",e),width:t.getShallow("width",e),height:t.getShallow("height",e)};var t,e},e.prototype.getZLevelKey=function(){return""},e.prototype.setZLevel=function(t){this.option.zlevel=t},e.protoInitialize=((n=e.prototype).type="component",n.id="",n.name="",n.mainType="",n.subType="",void(n.componentIndex=0)),e}(_p);Us(Hf,_p),Xs(Hf),function(t){var e={};t.registerSubTypeDefaulter=function(t,n){var i=Vs(t);e[i.main]=n},t.determineSubType=function(n,i){var r=i.type;if(!r){var o=Vs(n).main;t.hasSubTypes(n)&&e[o]&&(r=e[o](i))}return r}}(Hf),function(t,e){function n(t,e){return t[e]||(t[e]={predecessor:[],successor:[]}),t[e]}t.topologicalTravel=function(t,i,r,o){if(t.length){var a=function(t){var i={},r=[];return en(t,function(o){var a=n(i,o),s=function(t,e){var n=[];return en(t,function(t){Qe(e,t)>=0&&n.push(t)}),n}(a.originalDeps=e(o),t);a.entryCount=s.length,0===a.entryCount&&r.push(o),en(s,function(t){Qe(a.predecessor,t)<0&&a.predecessor.push(t);var e=n(i,t);Qe(e.successor,t)<0&&e.successor.push(o)})}),{graph:i,noEntryList:r}}(i),s=a.graph,l=a.noEntryList,c={};for(en(t,function(t){c[t]=!0});l.length;){var u=l.pop(),h=s[u],d=!!c[u];d&&(r.call(o,u,h.originalDeps.slice()),delete c[u]),en(h.successor,d?f:p)}en(c,function(){throw new Error("")})}function p(t){s[t].entryCount--,0===s[t].entryCount&&l.push(t)}function f(t){c[t]=!0,p(t)}}}(Hf,function(t){var e=[];en(Hf.getClassesByMainType(t),function(t){e=e.concat(t.dependencies||t.prototype.dependencies||[])}),e=nn(e,function(t){return Vs(t).main}),"dataset"!==t&&Qe(e,"dataset")<=0&&e.unshift("dataset");return e});var Bf={color:{},darkColor:{},size:{}},Ff=Bf.color={theme:["#5070dd","#b6d634","#505372","#ff994d","#0ca8df","#ffd10a","#fb628b","#785db0","#3fbe95"],neutral00:"#fff",neutral05:"#f4f7fd",neutral10:"#e8ebf0",neutral15:"#dbdee4",neutral20:"#cfd2d7",neutral25:"#c3c5cb",neutral30:"#b7b9be",neutral35:"#aaacb2",neutral40:"#9ea0a5",neutral45:"#929399",neutral50:"#86878c",neutral55:"#797b7f",neutral60:"#6d6e73",neutral65:"#616266",neutral70:"#54555a",neutral75:"#48494d",neutral80:"#3c3c41",neutral85:"#303034",neutral90:"#232328",neutral95:"#17171b",neutral99:"#000",accent05:"#eff1f9",accent10:"#e0e4f2",accent15:"#d0d6ec",accent20:"#c0c9e6",accent25:"#b1bbdf",accent30:"#a1aed9",accent35:"#91a0d3",accent40:"#8292cc",accent45:"#7285c6",accent50:"#6578ba",accent55:"#5c6da9",accent60:"#536298",accent65:"#4a5787",accent70:"#404c76",accent75:"#374165",accent80:"#2e3654",accent85:"#252b43",accent90:"#1b2032",accent95:"#121521",transparent:"rgba(0,0,0,0)",highlight:"rgba(255,231,130,0.8)"};for(var $f in Ze(Ff,{primary:Ff.neutral80,secondary:Ff.neutral70,tertiary:Ff.neutral60,quaternary:Ff.neutral50,disabled:Ff.neutral20,border:Ff.neutral30,borderTint:Ff.neutral20,borderShade:Ff.neutral40,background:Ff.neutral05,backgroundTint:"rgba(234,237,245,0.5)",backgroundTransparent:"rgba(255,255,255,0)",backgroundShade:Ff.neutral10,shadow:"rgba(0,0,0,0.2)",shadowTint:"rgba(129,130,136,0.2)",axisLine:Ff.neutral70,axisLineTint:Ff.neutral40,axisTick:Ff.neutral70,axisTickMinor:Ff.neutral60,axisLabel:Ff.neutral70,axisSplitLine:Ff.neutral15,axisMinorSplitLine:Ff.neutral05}),Ff)if(Ff.hasOwnProperty($f)){var Vf=Ff[$f];"theme"===$f?Bf.darkColor.theme=Ff.theme.slice():"highlight"===$f?Bf.darkColor.highlight="rgba(255,231,130,0.4)":0===$f.indexOf("accent")?Bf.darkColor[$f]=io(Vf,0,function(t){return.5*t},function(t){return Math.min(1,1.3-t)}):Bf.darkColor[$f]=io(Vf,0,function(t){return.9*t},function(t){return 1-Math.pow(t,1.5)})}Bf.size={xxs:2,xs:5,s:10,m:15,l:20,xl:30,xxl:40,xxxl:50};var Wf="";"undefined"!=typeof navigator&&(Wf=navigator.platform||"");var Uf="rgba(0, 0, 0, 0.2)",Gf=Bf.color.theme[0],qf=io(Gf,0,null,.9),jf={darkMode:"auto",colorBy:"series",color:Bf.color.theme,gradientColor:[qf,Gf],aria:{decal:{decals:[{color:Uf,dashArrayX:[1,0],dashArrayY:[2,5],symbolSize:1,rotation:Math.PI/6},{color:Uf,symbol:"circle",dashArrayX:[[8,8],[0,8,8,0]],dashArrayY:[6,0],symbolSize:.8},{color:Uf,dashArrayX:[1,0],dashArrayY:[4,3],rotation:-Math.PI/4},{color:Uf,dashArrayX:[[6,6],[0,6,6,0]],dashArrayY:[6,0]},{color:Uf,dashArrayX:[[1,0],[1,6]],dashArrayY:[1,0,6,0],rotation:Math.PI/4},{color:Uf,symbol:"triangle",dashArrayX:[[9,9],[0,9,9,0]],dashArrayY:[7,2],symbolSize:.75}]}},textStyle:{fontFamily:Wf.match(/^Win/)?"Microsoft YaHei":"sans-serif",fontSize:12,fontStyle:"normal",fontWeight:"normal"},blendMode:null,stateAnimation:{duration:300,easing:"cubicOut"},animation:"auto",animationDuration:1e3,animationDurationUpdate:500,animationEasing:"cubicInOut",animationEasingUpdate:"cubicInOut",animationThreshold:2e3,progressiveThreshold:3e3,progressive:400,hoverLayerThreshold:3e3,useUTC:!1},Xf=En(["tooltip","label","itemName","itemId","itemGroupId","itemChildGroupId","seriesName"]),Yf="original",Zf="arrayRows",Kf="objectRows",Qf="keyedColumns",Jf="typedArray",tg="unknown",eg="column",ng="row",ig=1,rg=2,og=3,ag=Es();function sg(t,e,n){var i={},r=lg(e);if(!r||!t)return i;var o,a,s=[],l=[],c=e.ecModel,u=ag(c).datasetMap,h=r.uid+"_"+n.seriesLayoutBy;en(t=t.slice(),function(e,n){var r=fn(e)?e:t[n]={name:e};"ordinal"===r.type&&null==o&&(o=n,a=f(r)),i[r.name]=[]});var d=u.get(h)||u.set(h,{categoryWayDim:a,valueWayDim:0});function p(t,e,n){for(var i=0;ie)return t[i];return t[n-1]}(i,a):n;if(u=u||n,!u||!u.length)return;var h=u[l];r&&(c[r]=h);return s.paletteIdx=(l+1)%u.length,h}(this,hg,i,r,t,e,n)},t.prototype.clearColorPalette=function(){!function(t,e){e(t).paletteIdx=0,e(t).paletteNameMap={}}(this,hg)},t}();var vg="\0_ec_inner",mg=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return _(e,t),e.prototype.init=function(t,e,n,i,r,o){i=i||{},this.option=null,this._theme=new _p(i),this._locale=new _p(r),this._optionManager=o},e.prototype.setOption=function(t,e,n){var i=xg(e);this._optionManager.setOption(t,n,i),this._resetOption(null,i)},e.prototype.resetOption=function(t,e){return this._resetOption(t,xg(e))},e.prototype._resetOption=function(t,e){var n=!1,i=this._optionManager;if(!t||"recreate"===t){var r=i.mountOption("recreate"===t);this.option&&"recreate"!==t?(this.restoreData(),this._mergeOption(r,e)):fg(this,r),n=!0}if("timeline"!==t&&"media"!==t||this.restoreData(),!t||"recreate"===t||"timeline"===t){var o=i.getTimelineOption(this);o&&(n=!0,this._mergeOption(o,e))}if(!t||"recreate"===t||"media"===t){var a=i.getMediaOption(this);a.length&&en(a,function(t){n=!0,this._mergeOption(t,e)},this)}return n},e.prototype.mergeOption=function(t){this._mergeOption(t,null)},e.prototype._mergeOption=function(t,e){var n=this.option,i=this._componentsMap,r=this._componentsCount,o=[],a=En(),s=e&&e.replaceMergeMainTypeMap;ag(this).datasetMap=En(),en(t,function(t,e){null!=t&&(Hf.hasClass(e)?e&&(o.push(e),a.set(e,!0)):n[e]=null==n[e]?Xe(t):Ye(n[e],t,!0))}),s&&s.each(function(t,e){Hf.hasClass(e)&&!a.get(e)&&(o.push(e),a.set(e,!0))}),Hf.topologicalTravel(o,Hf.getAllClassMainTypes(),function(e){var o=function(t,e,n){var i=ug.get(e);if(!i)return n;var r=i(t);return r?n.concat(r):n}(this,e,bs(t[e])),a=i.get(e),l=a?s&&s.get(e)?"replaceMerge":"normalMerge":"replaceAll",c=ks(a,o,l);(function(t,e,n){en(t,function(t){var i=t.newOption;fn(i)&&(t.keyInfo.mainType=e,t.keyInfo.subType=function(t,e,n,i){return e.type?e.type:n?n.subType:i.determineSubType(t,e)}(e,i,t.existing,n))})})(c,e,Hf),n[e]=null,i.set(e,null),r.set(e,0);var u,h=[],d=[],p=0;en(c,function(t,n){var i=t.existing,r=t.newOption;if(r){var o="series"===e,a=Hf.getClass(e,t.keyInfo.subType,!o);if(!a)return;if("tooltip"===e){if(u)return;u=!0}if(i&&i.constructor===a)i.name=t.keyInfo.name,i.mergeOption(r,this),i.optionUpdated(r,!1);else{var s=Ze({componentIndex:n},t.keyInfo);Ze(i=new a(r,this,this,s),s),t.brandNew&&(i.__requireNewView=!0),i.init(r,this,this),i.optionUpdated(null,!0)}}else i&&(i.mergeOption({},this),i.optionUpdated({},!1));i?(h.push(i.option),d.push(i),p++):(h.push(void 0),d.push(void 0))},this),n[e]=h,i.set(e,d),r.set(e,p),"series"===e&&dg(this)},this),this._seriesIndices||dg(this)},e.prototype.getOption=function(){var t=Xe(this.option);return en(t,function(e,n){if(Hf.hasClass(n)){for(var i=bs(e),r=i.length,o=!1,a=r-1;a>=0;a--)i[a]&&!Ps(i[a])?o=!0:(i[a]=null,!o&&r--);i.length=r,t[n]=i}}),delete t[vg],t},e.prototype.setTheme=function(t){this._theme=new _p(t),this._resetOption("recreate",null)},e.prototype.getTheme=function(){return this._theme},e.prototype.getLocaleModel=function(){return this._locale},e.prototype.setUpdatePayload=function(t){this._payload=t},e.prototype.getUpdatePayload=function(){return this._payload},e.prototype.getComponent=function(t,e){var n=this._componentsMap.get(t);if(n){var i=n[e||0];if(i)return i;if(null==e)for(var r=0;r=e:"max"===n?t<=e:t===e})(i[a],t,o)||(r=!1)}}),r}var kg=en,Tg=fn,Dg=["areaStyle","lineStyle","nodeStyle","linkStyle","chordStyle","label","labelLine"];function Ig(t){var e=t&&t.itemStyle;if(e)for(var n=0,i=Dg.length;nc&&(c=p)}s[0]=l,s[1]=c}},i=function(){return this._data?this._data.length/this._dimSize:0};function r(t){for(var e=0;e=0&&(s=o.interpolatedValue[l])}return null!=s?s+"":""}):void 0},t.prototype.getRawValue=function(t,e){return yv(this.getData(e),t)},t.prototype.formatTooltip=function(t,e,n){},t}();function bv(t){var e,n;return fn(t)?t.type&&(n=t):e=t,{text:e,frag:n}}function wv(t){return new Sv(t)}var Sv=function(){function t(t){t=t||{},this._reset=t.reset,this._plan=t.plan,this._count=t.count,this._onDirty=t.onDirty,this._dirty=!0}return t.prototype.perform=function(t){var e,n=this._upstream,i=t&&t.skip;if(this._dirty&&n){var r=this.context;r.data=r.outputData=n.context.outputData}this.__pipeline&&(this.__pipeline.currentTask=this),this._plan&&!i&&(e=this._plan(this.context));var o,a=u(this._modBy),s=this._modDataCount||0,l=u(t&&t.modBy),c=t&&t.modDataCount||0;function u(t){return!(t>=1)&&(t=1),t}a===l&&s===c||(e="reset"),(this._dirty||"reset"===e)&&(this._dirty=!1,o=this._doReset(i)),this._modBy=l,this._modDataCount=c;var h=t&&t.step;if(this._dueEnd=n?n._outputDueEnd:this._count?this._count(this.context):1/0,this._progress){var d=this._dueIndex,p=Math.min(null!=h?this._dueIndex+h:1/0,this._dueEnd);if(!i&&(o||d1&&i>0?s:a}};return o;function a(){return e=t?null:oi?-this._resultLT:0},t}(),Tv=function(){function t(){}return t.prototype.getRawData=function(){throw new Error("not supported")},t.prototype.getRawDataItem=function(t){throw new Error("not supported")},t.prototype.cloneRawData=function(){},t.prototype.getDimensionInfo=function(t){},t.prototype.cloneAllDimensionInfo=function(){},t.prototype.count=function(){},t.prototype.retrieveValue=function(t,e){},t.prototype.retrieveValueFromItem=function(t,e){},t.prototype.convertValue=function(t,e){return Mv(t,e)},t}();function Dv(t){if(!zv(t.sourceFormat)){ys("")}return t.data}function Iv(t){var e=t.sourceFormat,n=t.data;if(!zv(e)){ys("")}if(e===Zf){for(var i=[],r=0,o=n.length;r65535?Rv:Hv}function Wv(){return[1/0,-1/0]}function Uv(t){var e=t.constructor;return e===Array?t.slice():new e(t)}function Gv(t,e,n,i,r){var o=$v[n||"float"];if(r){var a=t[e],s=a&&a.length;if(s!==i){for(var l=new o(i),c=0;cg[1]&&(g[1]=f)}return this._rawCount=this._count=s,{start:a,end:s}},t.prototype._initDataFromProvider=function(t,e,n){for(var i=this._provider,r=this._chunks,o=this._dimensions,a=o.length,s=this._rawExtent,l=nn(o,function(t){return t.property}),c=0;cv[1]&&(v[1]=g)}}!i.persistent&&i.clean&&i.clean(),this._rawCount=this._count=e,this._extent=[]},t.prototype.count=function(){return this._count},t.prototype.get=function(t,e){if(!(e>=0&&e=0&&e=this._rawCount||t<0)return-1;if(!this._indices)return t;var e=this._indices,n=e[t];if(null!=n&&nt))return o;r=o-1}}return-1},t.prototype.getIndices=function(){var t,e=this._indices;if(e){var n=e.constructor,i=this._count;if(n===Array){t=new n(i);for(var r=0;r=c&&_<=u||isNaN(_))&&(a[s++]=p),p++}d=!0}else if(2===r){f=h[i[0]];var v=h[i[1]],m=t[i[1]][0],y=t[i[1]][1];for(g=0;g=c&&_<=u||isNaN(_))&&(x>=m&&x<=y||isNaN(x))&&(a[s++]=p),p++}d=!0}}if(!d)if(1===r)for(g=0;g=c&&_<=u||isNaN(_))&&(a[s++]=b)}else for(g=0;gt[C][1])&&(w=!1)}w&&(a[s++]=e.getRawIndex(g))}return sv[1]&&(v[1]=g)}}}},t.prototype.lttbDownSample=function(t,e){var n,i,r,o=this.clone([t],!0),a=o._chunks[t],s=this.count(),l=0,c=Math.floor(1/e),u=this.getRawIndex(0),h=new(Vv(this._rawCount))(Math.min(2*(Math.ceil(s/c)+2),s));h[l++]=u;for(var d=1;dn&&(n=i,r=M)}C>0&&Ca&&(f=a-c);for(var g=0;gp&&(p=v,d=c+g)}var m=this.getRawIndex(u),y=this.getRawIndex(d);uc-p&&(s=c-p,a.length=s);for(var f=0;fu[1]&&(u[1]=v),h[d++]=m}return r._count=d,r._indices=h,r._updateGetRawIdx(),r},t.prototype.each=function(t,e){if(this._count)for(var n=t.length,i=this._chunks,r=0,o=this.count();ra&&(a=l)}return i=[o,a],this._extent[t]=i,i},t.prototype.getRawDataItem=function(t){var e=this.getRawIndex(t);if(this._provider.persistent)return this._provider.getItem(e);for(var n=[],i=this._chunks,r=0;r=0?this._indices[t]:-1},t.prototype._updateGetRawIdx=function(){this.getRawIndex=this._indices?this._getRawIdx:this._getRawIdxIdentity},t.internalField=function(){function t(t,e,n,i){return Mv(t[i],this._dimensions[i])}Ov={arrayRows:t,objectRows:function(t,e,n,i){return Mv(t[e],this._dimensions[i])},keyedColumns:t,original:function(t,e,n,i){var r=t&&(null==t.value?t:t.value);return Mv(r instanceof Array?r[i]:r,this._dimensions[i])},typedArray:function(t,e,n,i){return t[i]}}}(),t}(),jv=function(){function t(t){this._sourceList=[],this._storeList=[],this._upstreamSignList=[],this._versionSignBase=0,this._dirty=!0,this._sourceHost=t}return t.prototype.dirty=function(){this._setLocalSource([],[]),this._storeList=[],this._dirty=!0},t.prototype._setLocalSource=function(t,e){this._sourceList=t,this._upstreamSignList=e,this._versionSignBase++,this._versionSignBase>9e10&&(this._versionSignBase=0)},t.prototype._getVersionSign=function(){return this._sourceHost.uid+"_"+this._versionSignBase},t.prototype.prepareSource=function(){this._isDirty()&&(this._createSource(),this._dirty=!1)},t.prototype._createSource=function(){this._setLocalSource([],[]);var t,e,n=this._sourceHost,i=this._getUpstreamSourceManagers(),r=!!i.length;if(Xv(n)){var o=n,a=void 0,s=void 0,l=void 0;if(r){var c=i[0];c.prepareSource(),a=(l=c.getSource()).data,s=l.sourceFormat,e=[c._getVersionSign()]}else s=vn(a=o.get("data",!0))?Jf:Yf,e=[];var u=this._getSourceMetaRawOption()||{},h=l&&l.metaRawOption||{},d=bn(u.seriesLayoutBy,h.seriesLayoutBy)||null,p=bn(u.sourceHeader,h.sourceHeader),f=bn(u.dimensions,h.dimensions);t=d!==h.seriesLayoutBy||!!p!=!!h.sourceHeader||f?[tv(a,{seriesLayoutBy:d,sourceHeader:p,dimensions:f},s)]:[]}else{var g=n;if(r){var v=this._applyTransform(i);t=v.sourceList,e=v.upstreamSignList}else{t=[tv(g.get("source",!0),this._getSourceMetaRawOption(),null)],e=[]}}this._setLocalSource(t,e)},t.prototype._applyTransform=function(t){var e,n=this._sourceHost,i=n.get("transform",!0),r=n.get("fromTransformResult",!0);if(null!=r){1!==t.length&&Yv("")}var o,a=[],s=[];return en(t,function(t){t.prepareSource();var e=t.getSource(r||0);null==r||e||Yv(""),a.push(e),s.push(t._getVersionSign())}),i?e=function(t,e){var n=bs(t),i=n.length;i||ys("");for(var r=0,o=i;r1||n>0&&!t.noHeader;return en(t.blocks,function(t){var n=im(t);n>=e&&(e=n+ +(i&&(!n||em(t)&&!t.noHeader)))}),e}return 0}function rm(t,e,n,i){var r,o=e.noHeader,a=(r=im(e),{html:Qv[r],richText:Jv[r]}),s=[],l=e.blocks||[];Mn(!l||cn(l)),l=l||[];var c=t.orderMode;if(e.sortBlocks&&c){l=l.slice();var u={valueAsc:"asc",valueDesc:"desc"};if(On(u,c)){var h=new kv(u[c],null);l.sort(function(t,e){return h.evaluate(t.sortParam,e.sortParam)})}else"seriesDesc"===c&&l.reverse()}en(l,function(n,r){var o=e.valueFormatter,l=nm(n)(o?Ze(Ze({},t),{valueFormatter:o}):t,n,r>0?a.html:0,i);null!=l&&s.push(l)});var d="richText"===t.renderMode?s.join(a.richText):sm(i,s.join(""),o?n:a.html);if(o)return d;var p=df(e.header,"ordinal",t.useUTC),f=Kv(i,t.renderMode).nameStyle,g=Zv(i);return"richText"===t.renderMode?lm(t,p,f)+a.richText+d:sm(i,'
'+li(p)+"
"+d,n)}function om(t,e,n,i){var r=t.renderMode,o=e.noName,a=e.noValue,s=!e.markerType,l=e.name,c=t.useUTC,u=e.valueFormatter||t.valueFormatter||function(t){return nn(t=cn(t)?t:[t],function(t,e){return df(t,cn(p)?p[e]:p,c)})};if(!o||!a){var h=s?"":t.markupStyleCreator.makeTooltipMarker(e.markerType,e.markerColor||Bf.color.secondary,r),d=o?"":df(l,"ordinal",c),p=e.valueType,f=a?[]:u(e.value,e.dataIndex),g=!s||!o,v=!s&&o,m=Kv(i,r),y=m.nameStyle,_=m.valueStyle;return"richText"===r?(s?"":h)+(o?"":lm(t,d,y))+(a?"":function(t,e,n,i,r){var o=[r],a=i?10:20;return n&&o.push({padding:[0,0,0,a],align:"right"}),t.markupStyleCreator.wrapRichTextStyle(cn(e)?e.join(" "):e,o)}(t,f,g,v,_)):sm(i,(s?"":h)+(o?"":function(t,e,n){return''+li(t)+""}(d,!s,y))+(a?"":function(t,e,n,i){var r=n?"10px":"20px",o=e?"float:right;margin-left:"+r:"";return t=cn(t)?t:[t],''+nn(t,function(t){return li(t)}).join("  ")+""}(f,g,v,_)),n)}}function am(t,e,n,i,r,o){if(t)return nm(t)({useUTC:r,renderMode:n,orderMode:i,markupStyleCreator:e,valueFormatter:t.valueFormatter},t,0,o)}function sm(t,e,n){return'
'+e+'
'}function lm(t,e,n){return t.markupStyleCreator.wrapRichTextStyle(e,n)}function cm(t,e){var n=t.get("padding");return null!=n?n:"richText"===e?[8,10]:10}var um=function(){function t(){this.richTextStyles={},this._nextStyleNameId=ps()}return t.prototype._generateStyleName=function(){return"__EC_aUTo_"+this._nextStyleNameId++},t.prototype.makeTooltipMarker=function(t,e,n){var i="richText"===n?this._generateStyleName():null,r=function(t,e){var n=hn(t)?{color:t,extraCssText:e}:t||{},i=n.color,r=n.type;e=n.extraCssText;var o=n.renderMode||"html";return i?"html"===o?"subItem"===r?'':'':{renderMode:o,content:"{"+(n.markerId||"markerX")+"|} ",style:"subItem"===r?{width:4,height:4,borderRadius:2,backgroundColor:i}:{width:10,height:10,borderRadius:5,backgroundColor:i}}:""}({color:e,type:t,renderMode:n,markerId:i});return hn(r)?r:(this.richTextStyles[i]=r.style,r.content)},t.prototype.wrapRichTextStyle=function(t,e){var n={};cn(e)?en(e,function(t){return Ze(n,t)}):Ze(n,e);var i=this._generateStyleName();return this.richTextStyles[i]=n,"{"+i+"|"+t+"}"},t}();function hm(t){var e,n,i,r,o=t.series,a=t.dataIndex,s=t.multipleSeries,l=o.getData(),c=l.mapDimensionsAll("defaultedTooltip"),u=c.length,h=o.getRawValue(a),d=cn(h),p=function(t,e){return vf(t.getData().getItemVisual(e,"style")[t.visualDrawType])}(o,a);if(u>1||d&&!u){var f=function(t,e,n,i,r){var o=e.getData(),a=rn(t,function(t,e,n){var i=o.getDimensionInfo(n);return t||i&&!1!==i.tooltip&&null!=i.displayName},!1),s=[],l=[],c=[];function u(t,e){var n=o.getDimensionInfo(e);n&&!1!==n.otherDims.tooltip&&(a?c.push(tm("nameValue",{markerType:"subItem",markerColor:r,name:n.displayName,value:t,valueType:n.type})):(s.push(t),l.push(n.type)))}return i.length?en(i,function(t){u(yv(o,n,t),t)}):en(t,u),{inlineValues:s,inlineValueTypes:l,blocks:c}}(h,o,a,c,p);e=f.inlineValues,n=f.inlineValueTypes,i=f.blocks,r=f.inlineValues[0]}else if(u){var g=l.getDimensionInfo(c[0]);r=e=yv(l,a,c[0]),n=g.type}else r=e=d?h[0]:h;var v=As(o),m=v&&o.name||"",y=l.getName(a),_=s?m:y;return tm("section",{header:m,noHeader:s||!v,sortParam:r,blocks:[tm("nameValue",{markerType:"item",markerColor:p,name:_,noName:!kn(_),value:e,valueType:n,dataIndex:a})].concat(i||[])})}var dm=Es();function pm(t,e){return t.getName(e)||t.getId(e)}var fm=function(t){function e(){var e=null!==t&&t.apply(this,arguments)||this;return e._selectedDataIndicesMap={},e}var n;return _(e,t),e.prototype.init=function(t,e,n){this.seriesIndex=this.componentIndex,this.dataTask=wv({count:vm,reset:mm}),this.dataTask.context={model:this},this.mergeDefaultAndTheme(t,n),(dm(this).sourceManager=new jv(this)).prepareSource();var i=this.getInitialData(t,n);_m(i,this),this.dataTask.context.data=i,dm(this).dataBeforeProcessed=i,gm(this),this._initSelectedMapFromData(i)},e.prototype.mergeDefaultAndTheme=function(t,e){var n=zf(this),i=n?Nf(t):{},r=this.subType;Hf.hasClass(r)&&(r+="Series"),Ye(t,e.getTheme().get(this.subType)),Ye(t,this.getDefaultOption()),ws(t,"label",["show"]),this.fillDataTextStyle(t.data),n&&Of(t,i,n)},e.prototype.mergeOption=function(t,e){t=Ye(this.option,t,!0),this.fillDataTextStyle(t.data);var n=zf(this);n&&Of(this.option,t,n);var i=dm(this).sourceManager;i.dirty(),i.prepareSource();var r=this.getInitialData(t,e);_m(r,this),this.dataTask.dirty(),this.dataTask.context.data=r,dm(this).dataBeforeProcessed=r,gm(this),this._initSelectedMapFromData(r)},e.prototype.fillDataTextStyle=function(t){if(t&&!vn(t))for(var e=["show"],n=0;n=0&&u<0)&&(c=o,u=r,h=0),r===u&&(l[h++]=e))}),l.length=h,l},e.prototype.formatTooltip=function(t,e,n){return hm({series:this,dataIndex:t,multipleSeries:e})},e.prototype.isAnimationEnabled=function(){var t=this.ecModel;if(Te.node&&(!t||!t.ssr))return!1;var e=this.getShallow("animation");return e&&this.getData().count()>this.getShallow("animationThreshold")&&(e=!1),!!e},e.prototype.restoreData=function(){this.dataTask.dirty()},e.prototype.getColorFromPalette=function(t,e,n){var i=this.ecModel,r=gg.prototype.getColorFromPalette.call(this,t,e,n);return r||(r=i.getColorFromPalette(t,e,n)),r},e.prototype.coordDimToDataDim=function(t){return this.getRawData().mapDimensionsAll(t)},e.prototype.getProgressive=function(){return this.get("progressive")},e.prototype.getProgressiveThreshold=function(){return this.get("progressiveThreshold")},e.prototype.select=function(t,e){this._innerSelect(this.getData(e),t)},e.prototype.unselect=function(t,e){var n=this.option.selectedMap;if(n){var i=this.option.selectedMode,r=this.getData(e);if("series"===i||"all"===n)return this.option.selectedMap={},void(this._selectedDataIndicesMap={});for(var o=0;o=0&&n.push(r)}return n},e.prototype.isSelected=function(t,e){var n=this.option.selectedMap;if(!n)return!1;var i=this.getData(e);return("all"===n||n[pm(i,t)])&&!i.getItemModel(t).get(["select","disabled"])},e.prototype.isUniversalTransitionEnabled=function(){if(this.__universalTransitionEnabled)return!0;var t=this.option.universalTransition;return!!t&&(!0===t||t&&t.enabled)},e.prototype._innerSelect=function(t,e){var n,i,r=this.option,o=r.selectedMode,a=e.length;if(o&&a)if("series"===o)r.selectedMap="all";else if("multiple"===o){fn(r.selectedMap)||(r.selectedMap={});for(var s=r.selectedMap,l=0;l0&&this._innerSelect(t,e)}},e.registerClass=function(t){return Hf.registerClass(t)},e.protoInitialize=((n=e.prototype).type="series.__base__",n.seriesIndex=0,n.ignoreStyleOnData=!1,n.hasSymbolVisual=!1,n.defaultSymbol="circle",n.visualStyleAccessPath="itemStyle",void(n.visualDrawType="fill")),e}(Hf);function gm(t){var e=t.name;As(t)||(t.name=function(t){var e=t.getRawData(),n=e.mapDimensionsAll("seriesName"),i=[];return en(n,function(t){var n=e.getDimensionInfo(t);n.displayName&&i.push(n.displayName)}),i.join(" ")}(t)||e)}function vm(t){return t.model.getRawData().count()}function mm(t){var e=t.model;return e.setData(e.getRawData().cloneShallow()),ym}function ym(t,e){e.outputData&&t.end>e.outputData.count()&&e.model.getRawData().cloneShallow(e.outputData)}function _m(t,e){en(function(t,e){for(var n=new t.constructor(t.length+e.length),i=0;i=0?h():u=setTimeout(h,-r),l=i};return d.clear=function(){u&&(clearTimeout(u),u=null)},d.debounceNextCall=function(t){s=t},d}function Nm(t,e,n,i){var r=t[e];if(r){var o=r[Lm]||r,a=r[zm];if(r[Em]!==n||a!==i){if(null==n||!i)return t[e]=o;(r=t[e]=Om(o,n,"debounce"===i))[Lm]=o,r[zm]=i,r[Em]=n}return r}}function Rm(t,e){var n=t[e];n&&n[Lm]&&(n.clear&&n.clear(),t[e]=n[Lm])}var Hm=Es(),Bm={itemStyle:Ys(vp,!0),lineStyle:Ys(pp,!0)},Fm={lineStyle:"stroke",itemStyle:"fill"};function $m(t,e){var n=t.visualStyleMapper||Bm[e];return n||(console.warn("Unknown style type '"+e+"'."),Bm.itemStyle)}function Vm(t,e){var n=t.visualDrawType||Fm[e];return n||(console.warn("Unknown style type '"+e+"'."),"fill")}var Wm={createOnAllSeries:!0,performRawSeries:!0,reset:function(t,e){var n=t.getData(),i=t.visualStyleAccessPath||"itemStyle",r=t.getModel(i),o=$m(t,i)(r),a=r.getShallow("decal");a&&(n.setVisual("decal",a),a.dirty=!0);var s=Vm(t,i),l=o[s],c=un(l)?l:null,u="auto"===o.fill||"auto"===o.stroke;if(!o[s]||c||u){var h=t.getColorFromPalette(t.name,null,e.getSeriesCount());o[s]||(o[s]=h,n.setVisual("colorFromPalette",!0)),o.fill="auto"===o.fill||un(o.fill)?h:o.fill,o.stroke="auto"===o.stroke||un(o.stroke)?h:o.stroke}if(n.setVisual("style",o),n.setVisual("drawType",s),!e.isSeriesFiltered(t)&&c)return n.setVisual("colorFromPalette",!1),{dataEach:function(e,n){var i=t.getDataParams(n),r=Ze({},o);r[s]=c(i),e.setItemVisual(n,"style",r)}}}},Um=new _p,Gm={createOnAllSeries:!0,performRawSeries:!0,reset:function(t,e){if(!t.ignoreStyleOnData&&!e.isSeriesFiltered(t)){var n=t.getData(),i=t.visualStyleAccessPath||"itemStyle",r=$m(t,i),o=n.getVisual("drawType");return{dataEach:n.hasItemOption?function(t,e){var n=t.getRawDataItem(e);if(n&&n[i]){Um.option=n[i];var a=r(Um);Ze(t.ensureUniqueItemVisual(e,"style"),a),Um.option.decal&&(t.setItemVisual(e,"decal",Um.option.decal),Um.option.decal.dirty=!0),o in a&&t.setItemVisual(e,"colorFromPalette",!1)}}:null}}}},qm={performRawSeries:!0,overallReset:function(t){var e=En();t.eachSeries(function(t){var n=t.getColorBy();if(!t.isColorBySeries()){var i=t.type+"-"+n,r=e.get(i);r||(r={},e.set(i,r)),Hm(t).scope=r}}),t.eachSeries(function(e){if(!e.isColorBySeries()&&!t.isSeriesFiltered(e)){var n=e.getRawData(),i={},r=e.getData(),o=Hm(e).scope,a=e.visualStyleAccessPath||"itemStyle",s=Vm(e,a);r.each(function(t){var e=r.getRawIndex(t);i[e]=t}),n.each(function(t){var a=i[t];if(r.getItemVisual(a,"colorFromPalette")){var l=r.ensureUniqueItemVisual(a,"style"),c=n.getName(t)||t+"",u=n.count();l[s]=e.getColorFromPalette(c,o,u)}})}})}},jm=Math.PI;var Xm=function(){function t(t,e,n,i){this._stageTaskMap=En(),this.ecInstance=t,this.api=e,n=this._dataProcessorHandlers=n.slice(),i=this._visualHandlers=i.slice(),this._allHandlers=n.concat(i)}return t.prototype.restoreData=function(t,e){t.restoreData(e),this._stageTaskMap.each(function(t){var e=t.overallTask;e&&e.dirty()})},t.prototype.getPerformArgs=function(t,e){if(t.__pipeline){var n=this._pipelineMap.get(t.__pipeline.id),i=n.context,r=!e&&n.progressiveEnabled&&(!i||i.progressiveRender)&&t.__idxInPipeline>n.blockIndex?n.step:null,o=i&&i.modDataCount;return{step:r,modBy:null!=o?Math.ceil(o/r):null,modDataCount:o}}},t.prototype.getPipeline=function(t){return this._pipelineMap.get(t)},t.prototype.updateStreamModes=function(t,e){var n=this._pipelineMap.get(t.uid),i=t.getData().count(),r=n.progressiveEnabled&&e.incrementalPrepareRender&&i>=n.threshold,o=t.get("large")&&i>=t.get("largeThreshold"),a="mod"===t.get("progressiveChunkMode")?i:null;t.pipelineContext=n.context={progressiveRender:r,modDataCount:a,large:o}},t.prototype.restorePipelines=function(t){var e=this,n=e._pipelineMap=En();t.eachSeries(function(t){var i=t.getProgressive(),r=t.uid;n.set(r,{id:r,head:null,tail:null,threshold:t.getProgressiveThreshold(),progressiveEnabled:i&&!(t.preventIncremental&&t.preventIncremental()),blockIndex:-1,step:Math.round(i||700),count:0}),e._pipe(t,t.dataTask)})},t.prototype.prepareStageTasks=function(){var t=this._stageTaskMap,e=this.api.getModel(),n=this.api;en(this._allHandlers,function(i){var r=t.get(i.uid)||t.set(i.uid,{});Mn(!(i.reset&&i.overallReset),""),i.reset&&this._createSeriesStageTask(i,r,e,n),i.overallReset&&this._createOverallStageTask(i,r,e,n)},this)},t.prototype.prepareView=function(t,e,n,i){var r=t.renderTask,o=r.context;o.model=e,o.ecModel=n,o.api=i,r.__block=!t.incrementalPrepareRender,this._pipe(e,r)},t.prototype.performDataProcessorTasks=function(t,e){this._performStageTasks(this._dataProcessorHandlers,t,e,{block:!0})},t.prototype.performVisualTasks=function(t,e,n){this._performStageTasks(this._visualHandlers,t,e,n)},t.prototype._performStageTasks=function(t,e,n,i){i=i||{};var r=!1,o=this;function a(t,e){return t.setDirty&&(!t.dirtyMap||t.dirtyMap.get(e.__pipeline.id))}en(t,function(t,s){if(!i.visualType||i.visualType===t.visualType){var l=o._stageTaskMap.get(t.uid),c=l.seriesTaskMap,u=l.overallTask;if(u){var h,d=u.agentStubMap;d.each(function(t){a(i,t)&&(t.dirty(),h=!0)}),h&&u.dirty(),o.updatePayload(u,n);var p=o.getPerformArgs(u,i.block);d.each(function(t){t.perform(p)}),u.perform(p)&&(r=!0)}else c&&c.each(function(s,l){a(i,s)&&s.dirty();var c=o.getPerformArgs(s,i.block);c.skip=!t.performRawSeries&&e.isSeriesFiltered(s.context.model),o.updatePayload(s,n),s.perform(c)&&(r=!0)})}}),this.unfinished=r||this.unfinished},t.prototype.performSeriesTasks=function(t){var e;t.eachSeries(function(t){e=t.dataTask.perform()||e}),this.unfinished=e||this.unfinished},t.prototype.plan=function(){this._pipelineMap.each(function(t){var e=t.tail;do{if(e.__block){t.blockIndex=e.__idxInPipeline;break}e=e.getUpstream()}while(e)})},t.prototype.updatePayload=function(t,e){"remain"!==e&&(t.context.payload=e)},t.prototype._createSeriesStageTask=function(t,e,n,i){var r=this,o=e.seriesTaskMap,a=e.seriesTaskMap=En(),s=t.seriesType,l=t.getTargetSeries;function c(e){var s=e.uid,l=a.set(s,o&&o.get(s)||wv({plan:Jm,reset:ty,count:iy}));l.context={model:e,ecModel:n,api:i,useClearVisual:t.isVisual&&!t.isLayout,plan:t.plan,reset:t.reset,scheduler:r},r._pipe(e,l)}t.createOnAllSeries?n.eachRawSeries(c):s?n.eachRawSeriesByType(s,c):l&&l(n,i).each(c)},t.prototype._createOverallStageTask=function(t,e,n,i){var r=this,o=e.overallTask=e.overallTask||wv({reset:Ym});o.context={ecModel:n,api:i,overallReset:t.overallReset,scheduler:r};var a=o.agentStubMap,s=o.agentStubMap=En(),l=t.seriesType,c=t.getTargetSeries,u=!0,h=!1;function d(t){var e=t.uid,n=s.set(e,a&&a.get(e)||(h=!0,wv({reset:Zm,onDirty:Qm})));n.context={model:t,overallProgress:u},n.agent=o,n.__block=u,r._pipe(t,n)}Mn(!t.createOnAllSeries,""),l?n.eachRawSeriesByType(l,d):c?c(n,i).each(d):(u=!1,en(n.getSeries(),d)),h&&o.dirty()},t.prototype._pipe=function(t,e){var n=t.uid,i=this._pipelineMap.get(n);!i.head&&(i.head=e),i.tail&&i.tail.pipe(e),i.tail=e,e.__idxInPipeline=i.count++,e.__pipeline=i},t.wrapStageHandler=function(t,e){return un(t)&&(t={overallReset:t,seriesType:ry(t)}),t.uid=bp("stageHandler"),e&&(t.visualType=e),t},t}();function Ym(t){t.overallReset(t.ecModel,t.api,t.payload)}function Zm(t){return t.overallProgress&&Km}function Km(){this.agent.dirty(),this.getDownstream().dirty()}function Qm(){this.agent&&this.agent.dirty()}function Jm(t){return t.plan?t.plan(t.model,t.ecModel,t.api,t.payload):null}function ty(t){t.useClearVisual&&t.data.clearAllVisual();var e=t.resetDefines=bs(t.reset(t.model,t.ecModel,t.api,t.payload));return e.length>1?nn(e,function(t,e){return ny(e)}):ey}var ey=ny(0);function ny(t){return function(e,n){var i=n.data,r=n.resetDefines[t];if(r&&r.dataEach)for(var o=e.start;o0&&u===r.length-c.length){var h=r.slice(0,u);"data"!==h&&(e.mainType=h,e[c.toLowerCase()]=t,s=!0)}}a.hasOwnProperty(r)&&(n[r]=t,s=!0),s||(i[r]=t)})}return{cptQuery:e,dataQuery:n,otherQuery:i}},t.prototype.filter=function(t,e){var n=this.eventInfo;if(!n)return!0;var i=n.targetEl,r=n.packedEvent,o=n.model,a=n.view;if(!o||!a)return!0;var s=e.cptQuery,l=e.dataQuery;return c(s,o,"mainType")&&c(s,o,"subType")&&c(s,o,"index","componentIndex")&&c(s,o,"name")&&c(s,o,"id")&&c(l,r,"name")&&c(l,r,"dataIndex")&&c(l,r,"dataType")&&(!a.filterForExposedEvent||a.filterForExposedEvent(t,e.otherQuery,i,r));function c(t,e,n,i){return null==t[n]||e[i||n]===t[n]}},t.prototype.afterTrigger=function(){this.eventInfo=null},t}(),vy=["symbol","symbolSize","symbolRotate","symbolOffset"],my=vy.concat(["symbolKeepAspect"]),yy={createOnAllSeries:!0,performRawSeries:!0,reset:function(t,e){var n=t.getData();if(t.legendIcon&&n.setVisual("legendIcon",t.legendIcon),t.hasSymbolVisual){for(var i={},r={},o=!1,a=0;a=0&&Oy(l)?l:.5,t.createRadialGradient(a,s,0,a,s,l)}(t,e,n):function(t,e,n){var i=null==e.x?0:e.x,r=null==e.x2?1:e.x2,o=null==e.y?0:e.y,a=null==e.y2?0:e.y2;return e.global||(i=i*n.width+n.x,r=r*n.width+n.x,o=o*n.height+n.y,a=a*n.height+n.y),i=Oy(i)?i:0,r=Oy(r)?r:1,o=Oy(o)?o:0,a=Oy(a)?a:0,t.createLinearGradient(i,o,r,a)}(t,e,n),r=e.colorStops,o=0;o0&&(e=i.lineDash,n=i.lineWidth,e&&"solid"!==e&&n>0?"dashed"===e?[4*n,2*n]:"dotted"===e?[n]:pn(e)?[e]:cn(e)?e:null:null),o=i.lineDashOffset;if(r){var a=i.strokeNoScale&&t.getLineScale?t.getLineScale():1;a&&1!==a&&(r=nn(r,function(t){return t/a}),o/=a)}return[r,o]}var Fy=new ac(!0);function $y(t){var e=t.stroke;return!(null==e||"none"===e||!(t.lineWidth>0))}function Vy(t){return"string"==typeof t&&"none"!==t}function Wy(t){var e=t.fill;return null!=e&&"none"!==e}function Uy(t,e){if(null!=e.fillOpacity&&1!==e.fillOpacity){var n=t.globalAlpha;t.globalAlpha=e.fillOpacity*e.opacity,t.fill(),t.globalAlpha=n}else t.fill()}function Gy(t,e){if(null!=e.strokeOpacity&&1!==e.strokeOpacity){var n=t.globalAlpha;t.globalAlpha=e.strokeOpacity*e.opacity,t.stroke(),t.globalAlpha=n}else t.stroke()}function qy(t,e,n){var i=tl(e.image,e.__image,n);if(nl(i)){var r=t.createPattern(i,e.repeat||"repeat");if("function"==typeof DOMMatrix&&r&&r.setTransform){var o=new DOMMatrix;o.translateSelf(e.x||0,e.y||0),o.rotateSelf(0,0,(e.rotation||0)*Rn),o.scaleSelf(e.scaleX||1,e.scaleY||1),r.setTransform(o)}return r}}var jy=["shadowBlur","shadowOffsetX","shadowOffsetY"],Xy=[["lineCap","butt"],["lineJoin","miter"],["miterLimit",10]];function Yy(t,e,n,i,r){var o=!1;if(!i&&e===(n=n||{}))return!1;if(i||e.opacity!==n.opacity){Qy(t,r),o=!0;var a=Math.max(Math.min(e.opacity,1),0);t.globalAlpha=isNaN(a)?wl.opacity:a}(i||e.blend!==n.blend)&&(o||(Qy(t,r),o=!0),t.globalCompositeOperation=e.blend||wl.blend);for(var s=0;s0&&t.unfinished);t.unfinished||this._zr.flush()}}},e.prototype.getDom=function(){return this._dom},e.prototype.getId=function(){return this.id},e.prototype.getZr=function(){return this._zr},e.prototype.isSSR=function(){return this._ssr},e.prototype.setOption=function(t,e,n){if(!this[p_])if(this._disposed)this.id;else{var i,r,o;if(fn(e)&&(n=e.lazyUpdate,i=e.silent,r=e.replaceMerge,o=e.transition,e=e.notMerge),this[p_]=!0,F_(this),!this._model||e){var a=new Cg(this._api),s=this._theme,l=this._model=new mg;l.scheduler=this._scheduler,l.ssr=this._ssr,l.init(null,null,null,s,this._locale,a)}this._model.setOption(t,{replaceMerge:r},Z_);var c={seriesTransition:o,optionChanged:!0};if(n)this[g_]={silent:i,updateParams:c},this[p_]=!1,this.getZr().wakeUp();else{try{w_(this),M_.update.call(this,null,c)}catch(t){throw this[g_]=null,this[p_]=!1,t}this._ssr||this._zr.flush(),this[g_]=null,this[p_]=!1,I_.call(this,i),A_.call(this,i)}}},e.prototype.setTheme=function(t,e){if(!this[p_])if(this._disposed)this.id;else{var n=this._model;if(n){var i=e&&e.silent,r=null;this[g_]&&(null==i&&(i=this[g_].silent),r=this[g_].updateParams,this[g_]=null),this[p_]=!0,F_(this);try{this._updateTheme(t),n.setTheme(this._theme),w_(this),M_.update.call(this,{type:"setTheme"},r)}catch(t){throw this[p_]=!1,t}this[p_]=!1,I_.call(this,i),A_.call(this,i)}}},e.prototype._updateTheme=function(t){hn(t)&&(t=Q_[t]),t&&((t=Xe(t))&&Gg(t,!0),this._theme=t)},e.prototype.getModel=function(){return this._model},e.prototype.getOption=function(){return this._model&&this._model.getOption()},e.prototype.getWidth=function(){return this._zr.getWidth()},e.prototype.getHeight=function(){return this._zr.getHeight()},e.prototype.getDevicePixelRatio=function(){return this._zr.painter.dpr||Te.hasGlobalWindow&&window.devicePixelRatio||1},e.prototype.getRenderedCanvas=function(t){return this.renderToCanvas(t)},e.prototype.renderToCanvas=function(t){return t=t||{},this._zr.painter.getRenderedCanvas({backgroundColor:t.backgroundColor||this._model.get("backgroundColor"),pixelRatio:t.pixelRatio||this.getDevicePixelRatio()})},e.prototype.renderToSVGString=function(t){return t=t||{},this._zr.painter.renderToString({useViewBox:t.useViewBox})},e.prototype.getSvgDataURL=function(){var t=this._zr;return en(t.storage.getDisplayList(),function(t){t.stopAnimation(null,!0)}),t.painter.toDataURL()},e.prototype.getDataURL=function(t){if(!this._disposed){var e=(t=t||{}).excludeComponents,n=this._model,i=[],r=this;en(e,function(t){n.eachComponent({mainType:t},function(t){var e=r._componentsMap[t.__viewId];e.group.ignore||(i.push(e),e.group.ignore=!0)})});var o="svg"===this._zr.painter.getType()?this.getSvgDataURL():this.renderToCanvas(t).toDataURL("image/"+(t&&t.type||"png"));return en(i,function(t){t.group.ignore=!1}),o}this.id},e.prototype.getConnectedDataURL=function(t){if(!this._disposed){var e="svg"===t.type,n=this.group,i=Math.min,r=Math.max,o=1/0;if(ex[n]){var a=o,s=o,l=-1/0,c=-1/0,u=[],h=t&&t.pixelRatio||this.getDevicePixelRatio();en(tx,function(o,h){if(o.group===n){var d=e?o.getZr().painter.getSvgDom().innerHTML:o.renderToCanvas(Xe(t)),p=o.getDom().getBoundingClientRect();a=i(p.left,a),s=i(p.top,s),l=r(p.right,l),c=r(p.bottom,c),u.push({dom:d,left:p.left,top:p.top})}});var d=(l*=h)-(a*=h),p=(c*=h)-(s*=h),f=Ee.createCanvas(),g=Ya(f,{renderer:e?"svg":"canvas"});if(g.resize({width:d,height:p}),e){var v="";return en(u,function(t){var e=t.left-a,n=t.top-s;v+=''+t.dom+""}),g.painter.getSvgRoot().innerHTML=v,t.connectedBackgroundColor&&g.painter.setBackgroundColor(t.connectedBackgroundColor),g.refreshImmediately(),g.painter.toDataURL()}return t.connectedBackgroundColor&&g.add(new Bc({shape:{x:0,y:0,width:d,height:p},style:{fill:t.connectedBackgroundColor}})),en(u,function(t){var e=new Lc({style:{x:t.left*h-a,y:t.top*h-s,image:t.dom}});g.add(e)}),g.refreshImmediately(),f.toDataURL("image/"+(t&&t.type||"png"))}return this.getDataURL(t)}this.id},e.prototype.convertToPixel=function(t,e,n){return k_(this,"convertToPixel",t,e,n)},e.prototype.convertToLayout=function(t,e,n){return k_(this,"convertToLayout",t,e,n)},e.prototype.convertFromPixel=function(t,e,n){return k_(this,"convertFromPixel",t,e,n)},e.prototype.containPixel=function(t,e){var n;if(!this._disposed)return en(Os(this._model,t),function(t,i){i.indexOf("Models")>=0&&en(t,function(t){var r=t.coordinateSystem;if(r&&r.containPoint)n=n||!!r.containPoint(e);else if("seriesModels"===i){var o=this._chartsMap[t.__viewId];o&&o.containPoint&&(n=n||o.containPoint(e,t))}},this)},this),!!n;this.id},e.prototype.getVisual=function(t,e){var n=Os(this._model,t,{defaultMainType:"series"}),i=n.seriesModel.getData(),r=n.hasOwnProperty("dataIndexInside")?n.dataIndexInside:n.hasOwnProperty("dataIndex")?i.indexOfRawIndex(n.dataIndex):null;return null!=r?function(t,e,n){switch(n){case"color":return t.getItemVisual(e,"style")[t.getVisual("drawType")];case"opacity":return t.getItemVisual(e,"style").opacity;case"symbol":case"symbolSize":case"liftZ":return t.getItemVisual(e,n)}}(i,r,e):function(t,e){switch(e){case"color":return t.getVisual("style")[t.getVisual("drawType")];case"opacity":return t.getVisual("style").opacity;case"symbol":case"symbolSize":case"liftZ":return t.getVisual(e)}}(i,e)},e.prototype.getViewOfComponentModel=function(t){return this._componentsMap[t.__viewId]},e.prototype.getViewOfSeriesModel=function(t){return this._chartsMap[t.__viewId]},e.prototype._initEvents=function(){var t=this;en(G_,function(e){var n=function(n){var i,r=t.getModel(),o=n.target;if("globalout"===e?i={}:o&&by(o,function(t){var e=nu(t);if(e&&null!=e.dataIndex){var n=e.dataModel||r.getSeriesByIndex(e.seriesIndex);return i=n&&n.getDataParams(e.dataIndex,e.dataType,o)||{},!0}if(e.eventData)return i=Ze({},e.eventData),!0},!0),i){var a=i.componentType,s=i.componentIndex;"markLine"!==a&&"markPoint"!==a&&"markArea"!==a||(a="series",s=i.seriesIndex);var l=a&&null!=s&&r.getComponent(a,s),c=l&&t["series"===l.mainType?"_chartsMap":"_componentsMap"][l.__viewId];i.event=n,i.type=e,t._$eventProcessor.eventInfo={targetEl:o,packedEvent:i,model:l,view:c},t.trigger(e,i)}};n.zrEventfulCallAtLast=!0,t._zr.on(e,n,t)});var e=this._messageCenter;en(X_,function(n,i){e.on(i,function(e){t.trigger(i,e)})}),function(t,e,n){t.on("selectchanged",function(t){var i=n.getModel();t.isFromClick?(xy("map","selectchanged",e,i,t),xy("pie","selectchanged",e,i,t)):"select"===t.fromAction?(xy("map","selected",e,i,t),xy("pie","selected",e,i,t)):"unselect"===t.fromAction&&(xy("map","unselected",e,i,t),xy("pie","unselected",e,i,t))})}(e,this,this._api)},e.prototype.isDisposed=function(){return this._disposed},e.prototype.clear=function(){this._disposed?this.id:this.setOption({series:[]},!0)},e.prototype.dispose=function(){if(this._disposed)this.id;else{this._disposed=!0,this.getDom()&&Bs(this.getDom(),ix,"");var t=this,e=t._api,n=t._model;en(t._componentsViews,function(t){t.dispose(n,e)}),en(t._chartsViews,function(t){t.dispose(n,e)}),t._zr.dispose(),t._dom=t._model=t._chartsMap=t._componentsMap=t._chartsViews=t._componentsViews=t._scheduler=t._api=t._zr=t._throttledZrFlush=t._theme=t._coordSysMgr=t._messageCenter=null,delete tx[t.id]}},e.prototype.resize=function(t){if(!this[p_])if(this._disposed)this.id;else{this._zr.resize(t);var e=this._model;if(this._loadingFX&&this._loadingFX.resize(),e){var n=e.resetOption("media"),i=t&&t.silent;this[g_]&&(null==i&&(i=this[g_].silent),n=!0,this[g_]=null),this[p_]=!0,F_(this);try{n&&w_(this),M_.update.call(this,{type:"resize",animation:Ze({duration:0},t&&t.animation)})}catch(t){throw this[p_]=!1,t}this[p_]=!1,I_.call(this,i),A_.call(this,i)}}},e.prototype.showLoading=function(t,e){if(this._disposed)this.id;else if(fn(t)&&(e=t,t=""),t=t||"default",this.hideLoading(),J_[t]){var n=J_[t](this._api,e),i=this._zr;this._loadingFX=n,i.add(n)}},e.prototype.hideLoading=function(){this._disposed?this.id:(this._loadingFX&&this._zr.remove(this._loadingFX),this._loadingFX=null)},e.prototype.makeActionFromEvent=function(t){var e=Ze({},t);return e.type=j_[t.type],e},e.prototype.dispatchAction=function(t,e){if(this._disposed)this.id;else if(fn(e)||(e={silent:!!e}),q_[t.type]&&this._model)if(this[p_])this._pendingActions.push(t);else{var n=e.silent;D_.call(this,t,n);var i=e.flush;i?this._zr.flush():!1!==i&&Te.browser.weChat&&this._throttledZrFlush(),I_.call(this,n),A_.call(this,n)}},e.prototype.updateLabelLayout=function(){l_.trigger("series:layoutlabels",this._model,this._api,{updatedSeries:[]})},e.prototype.appendData=function(t){if(this._disposed)this.id;else{var e=t.seriesIndex;this.getModel().getSeriesByIndex(e).appendData(t),this._scheduler.unfinished=!0,this.getZr().wakeUp()}},e.internalField=function(){function t(t){t.clearColorPalette(),t.eachSeries(function(t){t.clearColorPalette()})}function e(t){for(var e=[],n=t.currentStates,i=0;i0?{duration:o,delay:i.get("delay"),easing:i.get("easing")}:null;n.eachRendered(function(t){if(t.states&&t.states.emphasis){if(fd(t))return;if(t instanceof Tc&&function(t){var e=ou(t);e.normalFill=t.style.fill,e.normalStroke=t.style.stroke;var n=t.states.select||{};e.selectFill=n.style&&n.style.fill||null,e.selectStroke=n.style&&n.style.stroke||null}(t),t.__dirty){var n=t.prevStates;n&&t.useStates(n)}if(r){t.stateTransition=a;var i=t.getTextContent(),o=t.getTextGuideLine();i&&(i.stateTransition=a),o&&(o.stateTransition=a)}t.__dirty&&e(t)}})}w_=function(t){var e=t._scheduler;e.restorePipelines(t._model),e.prepareStageTasks(),S_(t,!0),S_(t,!1),e.plan()},S_=function(t,e){for(var n=t._model,i=t._scheduler,r=e?t._componentsViews:t._chartsViews,o=e?t._componentsMap:t._chartsMap,a=t._zr,s=t._api,l=0;le.get("hoverLayerThreshold")&&!Te.node&&!Te.worker&&e.eachSeries(function(e){if(!e.preventUsingHoverLayer){var n=t._chartsMap[e.__viewId];n.__alive&&n.eachRendered(function(t){t.states.emphasis&&(t.states.emphasis.hoverLayer=!0)})}})}(t,e),l_.trigger("series:afterupdate",e,o,s)},H_=function(t){t[v_]=!0,t.getZr().wakeUp()},F_=function(t){t[f_]=(t[f_]+1)%1e3},B_=function(t){t[v_]&&(t.getZr().storage.traverse(function(t){fd(t)||e(t)}),t[v_]=!1)},N_=function(t){return new(function(e){function n(){return null!==e&&e.apply(this,arguments)||this}return _(n,e),n.prototype.getCoordinateSystems=function(){return t._coordSysMgr.getCoordinateSystems()},n.prototype.getComponentByElement=function(e){for(;e;){var n=e.__ecComponentInfo;if(null!=n)return t._model.getComponent(n.mainType,n.index);e=e.parent}},n.prototype.enterEmphasis=function(e,n){Au(e,n),H_(t)},n.prototype.leaveEmphasis=function(e,n){Pu(e,n),H_(t)},n.prototype.enterBlur=function(e){!function(t){Cu(t,_u)}(e),H_(t)},n.prototype.leaveBlur=function(e){Lu(e),H_(t)},n.prototype.enterSelect=function(e){Eu(e),H_(t)},n.prototype.leaveSelect=function(e){zu(e),H_(t)},n.prototype.getModel=function(){return t.getModel()},n.prototype.getViewOfComponentModel=function(e){return t.getViewOfComponentModel(e)},n.prototype.getViewOfSeriesModel=function(e){return t.getViewOfSeriesModel(e)},n.prototype.getMainProcessVersion=function(){return t[f_]},n}(wg))(t)},R_=function(t){function e(t,e){for(var n=0;n=0)){hx.push(n);var o=Xm.wrapStageHandler(n,r);o.__prio=e,o.__raw=n,t.push(o)}}function px(t,e){J_[t]=e}var fx=function(t){var e=(t=Xe(t)).type;e||ys("");var n=e.split(":");2!==n.length&&ys("");var i=!1;"echarts"===n[0]&&(e=n[1],i=!0),t.__isBuiltIn=i,Lv.set(e,t)};function gx(t,e,n,i){return{eventContent:{selected:$u(n),isFromClick:e.isFromClick||!1}}}function vx(t){return null==t?0:t.length||1}function mx(t){return t}ux(u_,Wm),ux(h_,Gm),ux(h_,qm),ux(u_,yy),ux(h_,_y),ux(7e3,function(t,e){t.eachRawSeries(function(n){if(!t.isSeriesFiltered(n)){var i=n.getData();i.hasItemVisual()&&i.each(function(t){var n=i.getItemVisual(t,"decal");n&&(i.ensureUniqueItemVisual(t,"style").decal=r_(n,e))});var r=i.getVisual("decal");if(r)i.getVisual("style").decal=r_(r,e)}})}),ax(Gg),sx(900,function(t){var e=En();t.eachSeries(function(t){var n=t.get("stack");if(n){var i=e.get(n)||e.set(n,[]),r=t.getData(),o={stackResultDimension:r.getCalculationInfo("stackResultDimension"),stackedOverDimension:r.getCalculationInfo("stackedOverDimension"),stackedDimension:r.getCalculationInfo("stackedDimension"),stackedByDimension:r.getCalculationInfo("stackedByDimension"),isStackedByIndex:r.getCalculationInfo("isStackedByIndex"),data:r,seriesModel:t};if(!o.stackedDimension||!o.isStackedByIndex&&!o.stackedByDimension)return;i.push(o)}}),e.each(function(t){0!==t.length&&("seriesDesc"===(t[0].seriesModel.get("stackOrder")||"seriesAsc")&&t.reverse(),en(t,function(e,n){e.data.setCalculationInfo("stackedOnSeries",n>0?t[n-1].seriesModel:null)}),function(t){en(t,function(e,n){var i=[],r=[NaN,NaN],o=[e.stackResultDimension,e.stackedOverDimension],a=e.data,s=e.isStackedByIndex,l=e.seriesModel.get("stackStrategy")||"samesign";a.modify(o,function(o,c,u){var h,d,p=a.get(e.stackedDimension,u);if(isNaN(p))return r;s?d=a.getRawIndex(u):h=a.get(e.stackedByDimension,u);for(var f=NaN,g=n-1;g>=0;g--){var v=t[g];if(s||(d=v.data.rawIndexOf(v.stackedByDimension,h)),d>=0){var m=v.data.getByRawIndex(v.stackResultDimension,d);if("all"===l||"positive"===l&&m>0||"negative"===l&&m<0||"samesign"===l&&p>=0&&m>0||"samesign"===l&&p<=0&&m<0){p=os(p,m),f=m;break}}}return i[0]=p,i[1]=f,i})})}(t))})}),px("default",function(t,e){Ke(e=e||{},{text:"loading",textColor:Bf.color.primary,fontSize:12,fontWeight:"normal",fontStyle:"normal",fontFamily:"sans-serif",maskColor:"rgba(255,255,255,0.8)",showSpinner:!0,color:Bf.color.theme[0],spinnerRadius:10,lineWidth:5,zlevel:0});var n=new Ua,i=new Bc({style:{fill:e.maskColor},zlevel:e.zlevel,z:1e4});n.add(i);var r,o=new Wc({style:{text:e.text,fill:e.textColor,fontSize:e.fontSize,fontWeight:e.fontWeight,fontStyle:e.fontStyle,fontFamily:e.fontFamily},zlevel:e.zlevel,z:10001}),a=new Bc({style:{fill:"none"},textContent:o,textConfig:{position:"right",distance:10},zlevel:e.zlevel,z:10001});return n.add(a),e.showSpinner&&((r=new Xh({shape:{startAngle:-jm/2,endAngle:-jm/2+.1,r:e.spinnerRadius},style:{stroke:e.color,lineCap:"round",lineWidth:e.lineWidth},zlevel:e.zlevel,z:10001})).animateShape(!0).when(1e3,{endAngle:3*jm/2}).start("circularInOut"),r.animateShape(!0).when(1e3,{startAngle:3*jm/2}).delay(300).start("circularInOut"),n.add(r)),n.resize=function(){var n=o.getBoundingRect().width,s=e.showSpinner?e.spinnerRadius:0,l=(t.getWidth()-2*s-(e.showSpinner&&n?10:0)-n)/2-(e.showSpinner&&n?0:5+n/2)+(e.showSpinner?0:n/2)+(n?0:s),c=t.getHeight()/2;e.showSpinner&&r.setShape({cx:l,cy:c}),a.setShape({x:l-s,y:c-s,width:2*s,height:2*s}),i.setShape({x:0,y:0,width:t.getWidth(),height:t.getHeight()})},n.resize(),n}),cx({type:cu,event:cu,update:cu},Nn),cx({type:uu,event:uu,update:uu},Nn),cx({type:hu,event:fu,update:hu,action:Nn,refineEvent:gx,publishNonRefinedEvent:!0}),cx({type:du,event:fu,update:du,action:Nn,refineEvent:gx,publishNonRefinedEvent:!0}),cx({type:pu,event:fu,update:pu,action:Nn,refineEvent:gx,publishNonRefinedEvent:!0}),ox("default",{}),ox("dark",fy);var yx=function(){function t(t,e,n,i,r,o){this._old=t,this._new=e,this._oldKeyGetter=n||mx,this._newKeyGetter=i||mx,this.context=r,this._diffModeMultiple="multiple"===o}return t.prototype.add=function(t){return this._add=t,this},t.prototype.update=function(t){return this._update=t,this},t.prototype.updateManyToOne=function(t){return this._updateManyToOne=t,this},t.prototype.updateOneToMany=function(t){return this._updateOneToMany=t,this},t.prototype.updateManyToMany=function(t){return this._updateManyToMany=t,this},t.prototype.remove=function(t){return this._remove=t,this},t.prototype.execute=function(){this[this._diffModeMultiple?"_executeMultiple":"_executeOneToOne"]()},t.prototype._executeOneToOne=function(){var t=this._old,e=this._new,n={},i=new Array(t.length),r=new Array(e.length);this._initIndexMap(t,null,i,"_oldKeyGetter"),this._initIndexMap(e,n,r,"_newKeyGetter");for(var o=0;o1){var c=s.shift();1===s.length&&(n[a]=s[0]),this._update&&this._update(c,o)}else 1===l?(n[a]=null,this._update&&this._update(s,o)):this._remove&&this._remove(o)}this._performRestAdd(r,n)},t.prototype._executeMultiple=function(){var t=this._old,e=this._new,n={},i={},r=[],o=[];this._initIndexMap(t,n,r,"_oldKeyGetter"),this._initIndexMap(e,i,o,"_newKeyGetter");for(var a=0;a1&&1===h)this._updateManyToOne&&this._updateManyToOne(c,l),i[s]=null;else if(1===u&&h>1)this._updateOneToMany&&this._updateOneToMany(c,l),i[s]=null;else if(1===u&&1===h)this._update&&this._update(c,l),i[s]=null;else if(u>1&&h>1)this._updateManyToMany&&this._updateManyToMany(c,l),i[s]=null;else if(u>1)for(var d=0;d1)for(var a=0;a30}var Ax,Px,Lx,Ex,zx,Ox,Nx,Rx=fn,Hx=nn,Bx="undefined"==typeof Int32Array?Array:Int32Array,Fx=["hasItemOption","_nameList","_idList","_invertedIndicesMap","_dimSummary","userOutput","_rawData","_dimValueGetter","_nameDimIdx","_idDimIdx","_nameRepeatCount"],$x=["_approximateExtent"],Vx=function(){function t(t,e){var n;this.type="list",this._dimOmitted=!1,this._nameList=[],this._idList=[],this._visual={},this._layout={},this._itemVisuals=[],this._itemLayouts=[],this._graphicEls=[],this._approximateExtent={},this._calculationInfo={},this.hasItemOption=!1,this.TRANSFERABLE_METHODS=["cloneShallow","downSample","minmaxDownSample","lttbDownSample","map"],this.CHANGABLE_METHODS=["filterSelf","selectRange"],this.DOWNSAMPLE_METHODS=["downSample","minmaxDownSample","lttbDownSample"];var i=!1;kx(t)?(n=t.dimensions,this._dimOmitted=t.isDimensionOmitted(),this._schema=t):(i=!0,n=t),n=n||["x","y"];for(var r={},o=[],a={},s=!1,l={},c=0;c=e)){var n=this._store.getProvider();this._updateOrdinalMeta();var i=this._nameList,r=this._idList;if(n.getSource().sourceFormat===Yf&&!n.pure)for(var o=[],a=t;a0},t.prototype.ensureUniqueItemVisual=function(t,e){var n=this._itemVisuals,i=n[t];i||(i=n[t]={});var r=i[e];return null==r&&(cn(r=this.getVisual(e))?r=r.slice():Rx(r)&&(r=Ze({},r)),i[e]=r),r},t.prototype.setItemVisual=function(t,e,n){var i=this._itemVisuals[t]||{};this._itemVisuals[t]=i,Rx(e)?Ze(i,e):i[e]=n},t.prototype.clearAllVisual=function(){this._visual={},this._itemVisuals=[]},t.prototype.setLayout=function(t,e){Rx(t)?Ze(this._layout,t):this._layout[t]=e},t.prototype.getLayout=function(t){return this._layout[t]},t.prototype.getItemLayout=function(t){return this._itemLayouts[t]},t.prototype.setItemLayout=function(t,e,n){this._itemLayouts[t]=n?Ze(this._itemLayouts[t]||{},e):e},t.prototype.clearItemLayouts=function(){this._itemLayouts.length=0},t.prototype.setItemGraphicEl=function(t,e){!function(t,e,n,i){if(i){var r=nu(i);r.dataIndex=n,r.dataType=e,r.seriesIndex=t,r.ssrType="chart","group"===i.type&&i.traverse(function(i){var r=nu(i);r.seriesIndex=t,r.dataIndex=n,r.dataType=e,r.ssrType="chart"})}}(this.hostModel&&this.hostModel.seriesIndex,this.dataType,t,e),this._graphicEls[t]=e},t.prototype.getItemGraphicEl=function(t){return this._graphicEls[t]},t.prototype.eachItemGraphicEl=function(t,e){en(this._graphicEls,function(n,i){n&&t&&t.call(e,n,i)})},t.prototype.cloneShallow=function(e){return e||(e=new t(this._schema?this._schema:Hx(this.dimensions,this._getDimInfo,this),this.hostModel)),zx(e,this),e._store=this._store,e},t.prototype.wrapMethod=function(t,e){var n=this[t];un(n)&&(this.__wrappedMethods=this.__wrappedMethods||[],this.__wrappedMethods.push(t),this[t]=function(){var t=n.apply(this,arguments);return e.apply(this,[t].concat(Sn(arguments)))})},t.internalField=(Ax=function(t){var e=t._invertedIndicesMap;en(e,function(n,i){var r=t._dimInfos[i],o=r.ordinalMeta,a=t._store;if(o){n=e[i]=new Bx(o.categories.length);for(var s=0;s1&&(s+="__ec__"+c),i[e]=s}})),t}();function Wx(t,e){Jg(t)||(t=ev(t));var n=(e=e||{}).coordDimensions||[],i=e.dimensionsDefine||t.dimensionsDefine||[],r=En(),o=[],a=function(t,e,n,i){var r=Math.max(t.dimensionsDetectedCount||1,e.length,n.length,i||0);return en(e,function(t){var e;fn(t)&&(e=t.dimsDef)&&(r=Math.max(r,e.length))}),r}(t,n,i,e.dimensionsCount),s=e.canOmitUnusedDimensions&&Ix(a),l=i===t.dimensionsDefine,c=l?Dx(t):Tx(i),u=e.encodeDefine;!u&&e.encodeDefaulter&&(u=e.encodeDefaulter(t,a));for(var h=En(u),d=new Bv(a),p=0;p0&&(i.name=r+(o-1)),o++,e.set(r,o)}}(o),new Mx({source:t,dimensions:o,fullDimensionCount:a,dimensionOmitted:s})}function Ux(t,e,n){if(n||e.hasKey(t)){for(var i=0;e.hasKey(t+i);)i++;t+=i}return e.set(t,!0),t}var Gx=function(t){this.coordSysDims=[],this.axisMap=En(),this.categoryAxisMap=En(),this.coordSysName=t};var qx={cartesian2d:function(t,e,n,i){var r=t.getReferringComponents("xAxis",Rs).models[0],o=t.getReferringComponents("yAxis",Rs).models[0];e.coordSysDims=["x","y"],n.set("x",r),n.set("y",o),jx(r)&&(i.set("x",r),e.firstCategoryDimIndex=0),jx(o)&&(i.set("y",o),null==e.firstCategoryDimIndex&&(e.firstCategoryDimIndex=1))},singleAxis:function(t,e,n,i){var r=t.getReferringComponents("singleAxis",Rs).models[0];e.coordSysDims=["single"],n.set("single",r),jx(r)&&(i.set("single",r),e.firstCategoryDimIndex=0)},polar:function(t,e,n,i){var r=t.getReferringComponents("polar",Rs).models[0],o=r.findAxisModel("radiusAxis"),a=r.findAxisModel("angleAxis");e.coordSysDims=["radius","angle"],n.set("radius",o),n.set("angle",a),jx(o)&&(i.set("radius",o),e.firstCategoryDimIndex=0),jx(a)&&(i.set("angle",a),null==e.firstCategoryDimIndex&&(e.firstCategoryDimIndex=1))},geo:function(t,e,n,i){e.coordSysDims=["lng","lat"]},parallel:function(t,e,n,i){var r=t.ecModel,o=r.getComponent("parallel",t.get("parallelIndex")),a=e.coordSysDims=o.dimensions.slice();en(o.parallelAxisIndex,function(t,o){var s=r.getComponent("parallelAxis",t),l=a[o];n.set(l,s),jx(s)&&(i.set(l,s),null==e.firstCategoryDimIndex&&(e.firstCategoryDimIndex=o))})},matrix:function(t,e,n,i){var r=t.getReferringComponents("matrix",Rs).models[0];e.coordSysDims=["x","y"];var o=r.getDimensionModel("x"),a=r.getDimensionModel("y");n.set("x",o),n.set("y",a),i.set("x",o),i.set("y",a)}};function jx(t){return"category"===t.get("type")}function Xx(t,e,n){var i,r,o,a=(n=n||{}).byIndex,s=n.stackedCoordDimension;!function(t){return!kx(t.schema)}(e)?(r=e.schema,i=r.dimensions,o=e.store):i=e;var l,c,u,h,d=!(!t||!t.get("stack"));if(en(i,function(t,e){hn(t)&&(i[e]=t={name:t}),d&&!t.isExtraCoord&&(a||l||!t.ordinalMeta||(l=t),c||"ordinal"===t.type||"time"===t.type||s&&s!==t.coordDim||(c=t))}),!c||a||l||(a=!0),c){u="__\0ecstackresult_"+t.id,h="__\0ecstackedover_"+t.id,l&&(l.createInvertedIndices=!0);var p=c.coordDim,f=c.type,g=0;en(i,function(t){t.coordDim===p&&g++});var v={name:u,coordDim:p,coordDimIndex:g,type:f,isExtraCoord:!0,isCalculationCoord:!0,storeDimIndex:i.length},m={name:h,coordDim:h,coordDimIndex:g+1,type:f,isExtraCoord:!0,isCalculationCoord:!0,storeDimIndex:i.length+1};r?(o&&(v.storeDimIndex=o.ensureCalculationDimension(h,f),m.storeDimIndex=o.ensureCalculationDimension(u,f)),r.appendCalculationDimension(v),r.appendCalculationDimension(m)):(i.push(v),i.push(m))}return{stackedDimension:c&&c.name,stackedByDimension:l&&l.name,isStackedByIndex:a,stackedOverDimension:h,stackResultDimension:u}}function Yx(t,e){return!!e&&e===t.getCalculationInfo("stackedDimension")}function Zx(t,e,n){n=n||{};var i,r,o=e.getSourceManager();r=(i=o.getSource()).sourceFormat===Yf;var a=function(t){var e=t.get("coordinateSystem"),n=new Gx(e),i=qx[e];if(i)return i(t,n,n.axisMap,n.categoryAxisMap),n}(e),s=function(t,e){var n,i=t.get("coordinateSystem"),r=_f.get(i);return e&&e.coordSysDims&&(n=nn(e.coordSysDims,function(t){var n={name:t},i=e.axisMap.get(t);if(i){var r=i.get("type");n.type=function(t){return"category"===t?"ordinal":"time"===t?"time":"float"}(r)}return n})),n||(n=r&&(r.getDimensionsInfo?r.getDimensionsInfo():r.dimensions.slice())||["x","y"]),n}(e,a),l=n.useEncodeDefaulter,c=un(l)?l:l?ln(sg,s,e):null,u=Wx(i,{coordDimensions:s,generateCoord:n.generateCoord,encodeDefine:e.getEncode(),encodeDefaulter:c,canOmitUnusedDimensions:!r}),h=function(t,e,n){var i,r;return n&&en(t,function(t,o){var a=t.coordDim,s=n.categoryAxisMap.get(a);s&&(null==i&&(i=o),t.ordinalMeta=s.getOrdinalMeta(),e&&(t.createInvertedIndices=!0)),null!=t.otherDims.itemName&&(r=!0)}),r||null==i||(t[i].otherDims.itemName=0),i}(u.dimensions,n.createInvertedIndices,a),d=r?null:o.getSharedDataStore(u),p=Xx(e,{schema:u,store:d}),f=new Vx(u,e);f.setCalculationInfo(p);var g=null!=h&&function(t){if(t.sourceFormat===Yf){var e=function(t){var e=0;for(;er&&(a=o.interval=r);var s=o.intervalPrecision=tb(a);return function(t,e){!isFinite(t[0])&&(t[0]=e[0]),!isFinite(t[1])&&(t[1]=e[1]),eb(t,0,e),eb(t,1,e),t[0]>t[1]&&(t[0]=t[1])}(o.niceTickExtent=[is(Math.ceil(t[0]/a)*a,s),is(Math.floor(t[1]/a)*a,s)],t),o}function Jx(t){var e=Math.pow(10,us(t)),n=t/e;return n?2===n?n=3:3===n?n=5:n*=2:n=1,is(n*e)}function tb(t){return rs(t)+2}function eb(t,e,n){t[e]=Math.max(Math.min(t[e],n[1]),n[0])}function nb(t,e){return t>=e[0]&&t<=e[1]}var ib=function(){function t(){this.normalize=rb,this.scale=ob}return t.prototype.updateMethods=function(t){t.hasBreaks()?(this.normalize=sn(t.normalize,t),this.scale=sn(t.scale,t)):(this.normalize=rb,this.scale=ob)},t}();function rb(t,e){return e[1]===e[0]?.5:(t-e[0])/(e[1]-e[0])}function ob(t,e){return t*(e[1]-e[0])+e[0]}function ab(t,e,n){var i=Math.log(t);return[Math.log(n?e[0]:Math.max(0,e[0]))/i,Math.log(n?e[1]:Math.max(0,e[1]))/i]}var sb=function(){function t(t){this._calculator=new ib,this._setting=t||{},this._extent=[1/0,-1/0]}return t.prototype.getSetting=function(t){return this._setting[t]},t.prototype._innerUnionExtent=function(t){var e=this._extent;this._innerSetExtent(t[0]e[1]?t[1]:e[1])},t.prototype.unionExtentFromData=function(t,e){this._innerUnionExtent(t.getApproximateExtent(e))},t.prototype.getExtent=function(){return this._extent.slice()},t.prototype.setExtent=function(t,e){this._innerSetExtent(t,e)},t.prototype._innerSetExtent=function(t,e){var n=this._extent;isNaN(t)||(n[0]=t),isNaN(e)||(n[1]=e),this._brkCtx&&this._brkCtx.update(n)},t.prototype.setBreaksFromOption=function(t){},t.prototype._innerSetBreak=function(t){this._brkCtx&&(this._brkCtx.setBreaks(t),this._calculator.updateMethods(this._brkCtx),this._brkCtx.update(this._extent))},t.prototype._innerGetBreaks=function(){return this._brkCtx?this._brkCtx.breaks:[]},t.prototype.hasBreaks=function(){return!!this._brkCtx&&this._brkCtx.hasBreaks()},t.prototype._getExtentSpanWithBreaks=function(){return this._brkCtx&&this._brkCtx.hasBreaks()?this._brkCtx.getExtentSpan():this._extent[1]-this._extent[0]},t.prototype.isInExtentRange=function(t){return this._extent[0]<=t&&this._extent[1]>=t},t.prototype.isBlank=function(){return this._isBlank},t.prototype.setBlank=function(t){this._isBlank=t},t}();Xs(sb);var lb=0,cb=function(){function t(t){this.categories=t.categories||[],this._needCollect=t.needCollect,this._deduplication=t.deduplication,this.uid=++lb,this._onCollect=t.onCollect}return t.createByAxisModel=function(e){var n=e.option,i=n.data,r=i&&nn(i,ub);return new t({categories:r,needCollect:!r,deduplication:!1!==n.dedplication})},t.prototype.getOrdinal=function(t){return this._getOrCreateMap().get(t)},t.prototype.parseAndCollect=function(t){var e,n=this._needCollect;if(!hn(t)&&!n)return t;if(n&&!this._deduplication)return e=this.categories.length,this.categories[e]=t,this._onCollect&&this._onCollect(t,e),e;var i=this._getOrCreateMap();return null==(e=i.get(t))&&(n?(e=this.categories.length,this.categories[e]=t,i.set(t,e),this._onCollect&&this._onCollect(t,e)):e=NaN),e},t.prototype._getOrCreateMap=function(){return this._map||(this._map=En(this.categories))},t}();function ub(t){return fn(t)&&null!=t.value?t.value:t+""}var hb=function(t){function e(e){var n=t.call(this,e)||this;n.type="ordinal";var i=n.getSetting("ordinalMeta");return i||(i=new cb({})),cn(i)&&(i=new cb({categories:nn(i,function(t){return fn(t)?t.value:t})})),n._ordinalMeta=i,n._extent=n.getSetting("extent")||[0,i.categories.length-1],n}return _(e,t),e.prototype.parse=function(t){return null==t?NaN:hn(t)?this._ordinalMeta.getOrdinal(t):Math.round(t)},e.prototype.contain=function(t){return nb(t,this._extent)&&t>=0&&t=0&&t=0&&t=t},e.prototype.getOrdinalMeta=function(){return this._ordinalMeta},e.prototype.calcNiceTicks=function(){},e.prototype.calcNiceExtent=function(){},e.type="ordinal",e}(sb);sb.registerClass(hb);var db=is,pb=function(t){function e(){var e=null!==t&&t.apply(this,arguments)||this;return e.type="interval",e._interval=0,e._intervalPrecision=2,e}return _(e,t),e.prototype.parse=function(t){return null==t||""===t?NaN:Number(t)},e.prototype.contain=function(t){return nb(t,this._extent)},e.prototype.normalize=function(t){return this._calculator.normalize(t,this._extent)},e.prototype.scale=function(t){return this._calculator.scale(t,this._extent)},e.prototype.getInterval=function(){return this._interval},e.prototype.setInterval=function(t){this._interval=t,this._niceExtent=this._extent.slice(),this._intervalPrecision=tb(t)},e.prototype.getTicks=function(t){t=t||{};var e=this._interval,n=this._extent,i=this._niceExtent,r=this._intervalPrecision,o=[];if(!e)return o;t.breakTicks;n[0]=0&&(s=db(s+l*e,r))}if(o.length>0&&s===o[o.length-1].value)break;if(o.length>1e4)return[]}var c=o.length?o[o.length-1].value:i[1];return n[1]>c&&(t.expandToNicedExtent?o.push({value:db(c+e,r)}):o.push({value:n[1]})),t.breakTicks,o},e.prototype.getMinorTicks=function(t){for(var e=this.getTicks({expandToNicedExtent:!0}),n=[],i=this.getExtent(),r=1;ri[0]&&h0&&(o=null===o?s:Math.min(o,s))}n[i]=o}}return n}(t),n=[];return en(t,function(t){var i,r=t.coordinateSystem.getBaseAxis(),o=r.getExtent();if("category"===r.type)i=r.getBandWidth();else if("value"===r.type||"time"===r.type){var a=r.dim+"_"+r.index,s=e[a],l=Math.abs(o[1]-o[0]),c=r.scale.getExtent(),u=Math.abs(c[1]-c[0]);i=s?l/u*s:l}else{var h=t.getData();i=Math.abs(o[1]-o[0])/h.count()}var d=es(t.get("barWidth"),i),p=es(t.get("barMaxWidth"),i),f=es(t.get("barMinWidth")||(function(t){return t.pipelineContext&&t.pipelineContext.large}(t)?.5:1),i),g=t.get("barGap"),v=t.get("barCategoryGap"),m=t.get("defaultBarGap");n.push({bandWidth:i,barWidth:d,barMaxWidth:p,barMinWidth:f,barGap:g,barCategoryGap:v,defaultBarGap:m,axisKey:yb(r),stackId:mb(t)})}),function(t){var e={};en(t,function(t,n){var i=t.axisKey,r=t.bandWidth,o=e[i]||{bandWidth:r,remainedWidth:r,autoWidthCount:0,categoryGap:null,gap:t.defaultBarGap||0,stacks:{}},a=o.stacks;e[i]=o;var s=t.stackId;a[s]||o.autoWidthCount++,a[s]=a[s]||{width:0,maxWidth:0};var l=t.barWidth;l&&!a[s].width&&(a[s].width=l,l=Math.min(o.remainedWidth,l),o.remainedWidth-=l);var c=t.barMaxWidth;c&&(a[s].maxWidth=c);var u=t.barMinWidth;u&&(a[s].minWidth=u);var h=t.barGap;null!=h&&(o.gap=h);var d=t.barCategoryGap;null!=d&&(o.categoryGap=d)});var n={};return en(e,function(t,e){n[e]={};var i=t.stacks,r=t.bandWidth,o=t.categoryGap;if(null==o){var a=an(i).length;o=Math.max(35-4*a,15)+"%"}var s=es(o,r),l=es(t.gap,1),c=t.remainedWidth,u=t.autoWidthCount,h=(c-s)/(u+(u-1)*l);h=Math.max(h,0),en(i,function(t){var e=t.maxWidth,n=t.minWidth;if(t.width){i=t.width;e&&(i=Math.min(i,e)),n&&(i=Math.max(i,n)),t.width=i,c-=i+l*i,u--}else{var i=h;e&&ei&&(i=n),i!==h&&(t.width=i,c-=i+l*i,u--)}}),h=(c-s)/(u+(u-1)*l),h=Math.max(h,0);var d,p=0;en(i,function(t,e){t.width||(t.width=h),d=t,p+=t.width*(1+l)}),d&&(p-=d.width*l);var f=-p/2;en(i,function(t,i){n[e][i]=n[e][i]||{bandWidth:r,offset:f,width:t.width},f+=t.width*(1+l)})}),n}(n)}var xb=function(t){function e(e){var n=t.call(this,e)||this;return n.type="time",n}return _(e,t),e.prototype.getLabel=function(t){var e=this.getSetting("useUTC");return Gp(t.value,Hp[function(t){switch(t){case"year":case"month":return"day";case"millisecond":return"millisecond";default:return"second"}}(Wp(this._minLevelUnit))]||Hp.second,e,this.getSetting("locale"))},e.prototype.getFormattedLabel=function(t,e,n){var i=this.getSetting("useUTC");return function(t,e,n,i,r){var o=null;if(hn(n))o=n;else if(un(n)){var a={time:t.time,level:t.time.level},s=null;s&&s.makeAxisLabelFormatterParamBreak(a,t.break),o=n(t.value,e,a)}else{var l=t.time;if(l){var c=n[l.lowerTimeUnit][l.upperTimeUnit];o=c[Math.min(l.level,c.length-1)]||""}else{var u=qp(t.value,r);o=n[u][u][0]}}return Gp(new Date(t.value),o,r,i)}(t,e,n,this.getSetting("locale"),i)},e.prototype.getTicks=function(t){var e=this._interval,n=this._extent,i=[];if(!e)return i;var r=this.getSetting("useUTC"),o=qp(n[1],r);i.push({value:n[0],time:{level:0,upperTimeUnit:o,lowerTimeUnit:o}});var a=function(t,e,n,i,r,o){var a=1e4,s=Fp,l=0;function c(t,e,n,r,s,c,u){for(var h=function(t,e){var n=new Date(0);n[t](1);var i=n.getTime();n[t](1+e);var r=n.getTime()-i;return function(t,e){return Math.max(0,Math.round((e-t)/r))}}(s,t),d=e,p=new Date(d);da));)if(p[s](p[r]()+t),d=p.getTime(),o){var f=o.calcNiceTickMultiple(d,h);f>0&&(p[s](p[r]()+f*t),d=p.getTime())}u.push({value:d,notAdd:!0})}function u(t,r,o){var a=[],s=!r.length;if(!function(t,e,n,i){return jp(new Date(e),t,i).getTime()===jp(new Date(n),t,i).getTime()}(Wp(t),i[0],i[1],n)){s&&(r=[{value:Tb(i[0],t,n)},{value:i[1]}]);for(var l=0;l=i[0]&&u<=i[1]&&c(d,u,h,p,f,g,a),"year"===t&&o.length>1&&0===l&&o.unshift({value:o[0].value-d})}}for(l=0;l=i[0]&&_<=i[1]&&p++)}var x=r/e;if(p>1.5*x&&f>x/1.5)break;if(h.push(m),p>x||t===s[g])break}d=[]}}var b=on(nn(h,function(t){return on(t,function(t){return t.value>=i[0]&&t.value<=i[1]&&!t.notAdd})}),function(t){return t.length>0}),w=[],S=b.length-1;for(g=0;gn&&(this._approxInterval=n);var r=bb.length,o=Math.min(function(t,e,n,i){for(;n>>1;t[r][1]16?16:t>7.5?7:t>3.5?4:t>1.5?2:1}function Sb(t){return(t/=2592e6)>6?6:t>3?3:t>2?2:1}function Cb(t){return(t/=Lp)>12?12:t>6?6:t>3.5?4:t>2?2:1}function Mb(t,e){return(t/=e?Pp:Ap)>30?30:t>20?20:t>15?15:t>10?10:t>5?5:t>2?2:1}function kb(t){return hs(t)}function Tb(t,e,n){var i=Math.max(0,Qe(Bp,e)-1);return jp(new Date(t),Bp[i],n).getTime()}sb.registerClass(xb);var Db=is,Ib=Math.floor,Ab=Math.ceil,Pb=Math.pow,Lb=Math.log,Eb=function(t){function e(){var e=null!==t&&t.apply(this,arguments)||this;return e.type="log",e.base=10,e._originalScale=new pb,e}return _(e,t),e.prototype.getTicks=function(e){e=e||{};var n=this._extent.slice(),i=this._originalScale.getExtent(),r=t.prototype.getTicks.call(this,e),o=this.base;return this._originalScale._innerGetBreaks(),nn(r,function(t){var e=t.value,r=null,a=Pb(o,e);return e===n[0]&&this._fixMin?r=i[0]:e===n[1]&&this._fixMax&&(r=i[1]),null!=r&&(a=zb(a,r)),{value:a,break:void 0}},this)},e.prototype._getNonTransBreaks=function(){return this._originalScale._innerGetBreaks()},e.prototype.setExtent=function(e,n){this._originalScale.setExtent(e,n);var i=ab(this.base,[e,n]);t.prototype.setExtent.call(this,i[0],i[1])},e.prototype.getExtent=function(){var e=this.base,n=t.prototype.getExtent.call(this);n[0]=Pb(e,n[0]),n[1]=Pb(e,n[1]);var i=this._originalScale.getExtent();return this._fixMin&&(n[0]=zb(n[0],i[0])),this._fixMax&&(n[1]=zb(n[1],i[1])),n},e.prototype.unionExtentFromData=function(t,e){this._originalScale.unionExtentFromData(t,e);var n=ab(this.base,t.getApproximateExtent(e),!0);this._innerUnionExtent(n)},e.prototype.calcNiceTicks=function(t){t=t||10;var e=this._extent.slice(),n=this._getExtentSpanWithBreaks();if(isFinite(n)&&!(n<=0)){var i,r=(i=n,Math.pow(10,us(i)));for(t/n*r<=.5&&(r*=10);!isNaN(r)&&Math.abs(r)<1&&Math.abs(r)>0;)r*=10;var o=[Db(Ab(e[0]/r)*r),Db(Ib(e[1]/r)*r)];this._interval=r,this._intervalPrecision=tb(r),this._niceExtent=o}},e.prototype.calcNiceExtent=function(e){t.prototype.calcNiceExtent.call(this,e),this._fixMin=e.fixMin,this._fixMax=e.fixMax},e.prototype.contain=function(e){return e=Lb(e)/Lb(this.base),t.prototype.contain.call(this,e)},e.prototype.normalize=function(e){return e=Lb(e)/Lb(this.base),t.prototype.normalize.call(this,e)},e.prototype.scale=function(e){return e=t.prototype.scale.call(this,e),Pb(this.base,e)},e.prototype.setBreaksFromOption=function(t){},e.type="log",e}(pb);function zb(t,e){return Db(t,rs(e))}sb.registerClass(Eb);var Ob=function(){function t(t,e,n){this._prepareParams(t,e,n)}return t.prototype._prepareParams=function(t,e,n){n[1]0&&s>0&&!l&&(a=0),a<0&&s<0&&!c&&(s=0));var h=this._determinedMin,d=this._determinedMax;return null!=h&&(a=h,l=!0),null!=d&&(s=d,c=!0),{min:a,max:s,minFixed:l,maxFixed:c,isBlank:u}},t.prototype.modifyDataMinMax=function(t,e){this[Rb[t]]=e},t.prototype.setDeterminedMinMax=function(t,e){this[Nb[t]]=e},t.prototype.freeze=function(){this.frozen=!0},t}(),Nb={min:"_determinedMin",max:"_determinedMax"},Rb={min:"_dataMin",max:"_dataMax"};function Hb(t,e){return null==e?null:_n(e)?NaN:t.parse(e)}function Bb(t,e){var n=t.type,i=function(t,e,n){var i=t.rawExtentInfo;return i||(i=new Ob(t,e,n),t.rawExtentInfo=i,i)}(t,e,t.getExtent()).calculate();t.setBlank(i.isBlank);var r=i.min,o=i.max,a=e.ecModel;if(a&&"time"===n){var s=function(t,e){var n=[];return e.eachSeriesByType(t,function(t){(function(t){return t.coordinateSystem&&"cartesian2d"===t.coordinateSystem.type})(t)&&n.push(t)}),n}("bar",a),l=!1;if(en(s,function(t){l=l||t.getBaseAxis()===e.axis}),l){var c=_b(s),u=function(t,e,n,i){var r=n.axis.getExtent(),o=Math.abs(r[1]-r[0]),a=function(t,e){if(t&&e)return t[yb(e)]}(i,n.axis);if(void 0===a)return{min:t,max:e};var s=1/0;en(a,function(t){s=Math.min(t.offset,s)});var l=-1/0;en(a,function(t){l=Math.max(t.offset+t.width,l)}),s=Math.abs(s),l=Math.abs(l);var c=s+l,u=e-t,h=u/(1-(s+l)/o)-u;return e+=h*(l/c),t-=h*(s/c),{min:t,max:e}}(r,o,e,c);r=u.min,o=u.max}}return{extent:[r,o],fixMin:i.minFixed,fixMax:i.maxFixed}}function Fb(t,e){var n=e,i=Bb(t,n),r=i.extent,o=n.get("splitNumber");t instanceof Eb&&(t.base=n.get("logBase"));var a=t.type,s=n.get("interval"),l="interval"===a||"time"===a;t.setBreaksFromOption(Xb(n)),t.setExtent(r[0],r[1]),t.calcNiceExtent({splitNumber:o,fixMin:i.fixMin,fixMax:i.fixMax,minInterval:l?n.get("minInterval"):null,maxInterval:l?n.get("maxInterval"):null}),null!=s&&t.setInterval&&t.setInterval(s)}function $b(t){var e=t.getLabelModel().get("formatter");if("time"===t.type){var n=$p(e);return function(e,i){return t.scale.getFormattedLabel(e,i,n)}}if(hn(e))return function(n){var i=t.scale.getLabel(n);return e.replace("{value}",null!=i?i:"")};if(un(e)){if("category"===t.type)return function(n,i){return e(Vb(t,n),n.value-t.scale.getExtent()[0],null)};var i=null;return function(n,r){var o=null;return i&&(o=i.makeAxisLabelFormatterParamBreak(o,n.break)),e(Vb(t,n),r,o)}}return function(e){return t.scale.getLabel(e)}}function Vb(t,e){return"category"===t.type?t.scale.getLabel(e):e.value}function Wb(t){var e=t.get("interval");return null==e?"auto":e}function Ub(t){return"category"===t.type&&0===Wb(t.getLabelModel())}function Gb(t,e){var n={};return en(t.mapDimensionsAll(e),function(e){n[function(t,e){return Yx(t,e)?t.getCalculationInfo("stackResultDimension"):e}(t,e)]=!0}),an(n)}function qb(t){return"middle"===t||"center"===t}function jb(t){return t.getShallow("show")}function Xb(t){t.get("breaks",!0)}var Yb=function(){function t(){}return t.prototype.getNeedCrossZero=function(){return!this.option.scale},t.prototype.getCoordSysModel=function(){},t}(),Zb=[],Kb={registerPreprocessor:ax,registerProcessor:sx,registerPostInit:function(t){lx("afterinit",t)},registerPostUpdate:function(t){lx("afterupdate",t)},registerUpdateLifecycle:lx,registerAction:cx,registerCoordinateSystem:function(t,e){_f.register(t,e)},registerLayout:function(t,e){dx(K_,t,e,1e3,"layout")},registerVisual:ux,registerTransform:fx,registerLoading:px,registerMap:function(t,e,n){var i=c_["registerMap"];i&&i(t,e,n)},registerImpl:function(t,e){c_[t]=e},PRIORITY:d_,ComponentModel:Hf,ComponentView:wm,SeriesModel:fm,ChartView:km,registerComponentModel:function(t){Hf.registerClass(t)},registerComponentView:function(t){wm.registerClass(t)},registerSeriesModel:function(t){fm.registerClass(t)},registerChartView:function(t){km.registerClass(t)},registerCustomSeries:function(t,e){},registerSubTypeDefaulter:function(t,e){Hf.registerSubTypeDefaulter(t,e)},registerPainter:function(t,e){var n;n=e,Ga[t]=n}};function Qb(t){cn(t)?en(t,function(t){Qb(t)}):Qe(Zb,t)>=0||(Zb.push(t),un(t)&&(t={install:t}),t.install(Kb))}var Jb=Es(),tw=Es(),ew=1,nw=2;function iw(t){return{out:{noPxChangeTryDetermine:[]},kind:t}}function rw(t,e){var n=nn(e,function(e){return t.scale.parse(e)});return"time"===t.type&&n.length>0&&(n.sort(),n.unshift(n[0]),n.push(n[n.length-1])),n}function ow(t,e){var n=t.getLabelModel().get("customValues");if(n){var i=$b(t),r=t.scale.getExtent();return{labels:nn(on(rw(t,n),function(t){return t>=r[0]&&t<=r[1]}),function(e){var n={value:e};return{formattedLabel:i(n),rawLabel:t.scale.getLabel(n),tickValue:e,time:void 0,break:void 0}})}}return"category"===t.type?function(t,e){var n=t.getLabelModel(),i=sw(t,n,e);return!n.get("show")||t.scale.isBlank()?{labels:[]}:i}(t,e):function(t){var e=t.scale.getTicks(),n=$b(t);return{labels:nn(e,function(e,i){return{formattedLabel:n(e,i),rawLabel:t.scale.getLabel(e),tickValue:e.value,time:e.time,break:e.break}})}}(t)}function aw(t,e,n){var i=t.getTickModel().get("customValues");if(i){var r=t.scale.getExtent();return{ticks:on(rw(t,i),function(t){return t>=r[0]&&t<=r[1]})}}return"category"===t.type?function(t,e){var n,i,r=lw(t),o=Wb(e),a=hw(r,o);if(a)return a;e.get("show")&&!t.scale.isBlank()||(n=[]);if(un(o))n=vw(t,o,!0);else if("auto"===o){var s=sw(t,t.getLabelModel(),iw(nw));i=s.labelCategoryInterval,n=nn(s.labels,function(t){return t.tickValue})}else n=gw(t,i=o,!0);return dw(r,o,{ticks:n,tickCategoryInterval:i})}(t,e):{ticks:nn(t.scale.getTicks(n),function(t){return t.value})}}function sw(t,e,n){var i,r,o=cw(t),a=Wb(e),s=n.kind===ew;if(!s){var l=hw(o,a);if(l)return l}un(a)?i=vw(t,a):(r="auto"===a?function(t,e){if(e.kind===ew){var n=t.calculateCategoryInterval(e);return e.out.noPxChangeTryDetermine.push(function(){return tw(t).autoInterval=n,!0}),n}var i=tw(t).autoInterval;return null!=i?i:tw(t).autoInterval=t.calculateCategoryInterval(e)}(t,n):a,i=gw(t,r));var c={labels:i,labelCategoryInterval:r};return s?n.out.noPxChangeTryDetermine.push(function(){return dw(o,a,c),!0}):dw(o,a,c),c}var lw=uw("axisTick"),cw=uw("axisLabel");function uw(t){return function(e){return tw(e)[t]||(tw(e)[t]={list:[]})}}function hw(t,e){for(var n=0;ne&&i.axisExtent0===r[0]&&i.axisExtent1===r[1])return o;i.lastTickCount=n,i.lastAutoInterval=e,i.axisExtent0=r[0],i.axisExtent1=r[1]}function gw(t,e,n){var i=$b(t),r=t.scale,o=r.getExtent(),a=t.getLabelModel(),s=[],l=Math.max((e||0)+1,1),c=o[0],u=r.count();0!==c&&l>1&&u/l>2&&(c=Math.round(Math.ceil(c/l)*l));var h=Ub(t),d=a.get("showMinLabel")||h,p=a.get("showMaxLabel")||h;d&&c!==o[0]&&g(o[0]);for(var f=c;f<=o[1];f+=l)g(f);function g(t){var e={value:t};s.push(n?t:{formattedLabel:i(e),rawLabel:r.getLabel(e),tickValue:t,time:void 0,break:void 0})}return p&&f-l!==o[1]&&g(o[1]),s}function vw(t,e,n){var i=t.scale,r=$b(t),o=[];return en(i.getTicks(),function(t){var a=i.getLabel(t),s=t.value;e(t.value,a)&&o.push(n?s:{formattedLabel:r(t),rawLabel:a,tickValue:s,time:void 0,break:void 0})}),o}var mw=[0,1],yw=function(){function t(t,e,n){this.onBand=!1,this.inverse=!1,this.dim=t,this.scale=e,this._extent=n||[0,0]}return t.prototype.contain=function(t){var e=this._extent,n=Math.min(e[0],e[1]),i=Math.max(e[0],e[1]);return t>=n&&t<=i},t.prototype.containData=function(t){return this.scale.contain(this.scale.parse(t))},t.prototype.getExtent=function(){return this._extent.slice()},t.prototype.getPixelPrecision=function(t){return function(t,e){var n=Math.log,i=Math.LN10,r=Math.floor(n(t[1]-t[0])/i),o=Math.round(n(Ja(e[1]-e[0]))/i),a=Math.min(Math.max(-r+o,0),20);return isFinite(a)?a:20}(t||this.scale.getExtent(),this._extent)},t.prototype.setExtent=function(t,e){var n=this._extent;n[0]=t,n[1]=e},t.prototype.dataToCoord=function(t,e){var n=this._extent,i=this.scale;return t=i.normalize(i.parse(t)),this.onBand&&"ordinal"===i.type&&_w(n=n.slice(),i.count()),ts(t,mw,n,e)},t.prototype.coordToData=function(t,e){var n=this._extent,i=this.scale;this.onBand&&"ordinal"===i.type&&_w(n=n.slice(),i.count());var r=ts(t,n,mw,e);return this.scale.scale(r)},t.prototype.pointToData=function(t,e){},t.prototype.getTicksCoords=function(t){var e=(t=t||{}).tickModel||this.getTickModel(),n=nn(aw(this,e,{breakTicks:t.breakTicks,pruneByBreak:t.pruneByBreak}).ticks,function(t){return{coord:this.dataToCoord("ordinal"===this.scale.type?this.scale.getRawOrdinalNumber(t):t),tickValue:t}},this);return function(t,e,n,i){var r=e.length;if(!t.onBand||n||!r)return;var o,a,s=t.getExtent();if(1===r)e[0].coord=s[0],e[0].onBand=!0,o=e[1]={coord:s[1],tickValue:e[0].tickValue,onBand:!0};else{var l=e[r-1].tickValue-e[0].tickValue,c=(e[r-1].coord-e[0].coord)/l;en(e,function(t){t.coord-=c/2,t.onBand=!0});var u=t.scale.getExtent();a=1+u[1]-e[r-1].tickValue,o={coord:e[r-1].coord+c*a,tickValue:u[1]+1,onBand:!0},e.push(o)}var h=s[0]>s[1];d(e[0].coord,s[0])&&(i?e[0].coord=s[0]:e.shift());i&&d(s[0],e[0].coord)&&e.unshift({coord:s[0],onBand:!0});d(s[1],o.coord)&&(i?o.coord=s[1]:e.pop());i&&d(o.coord,s[1])&&e.push({coord:s[1],onBand:!0});function d(t,e){return t=is(t),e=is(e),h?t>e:t0&&t<100||(t=5),nn(this.scale.getMinorTicks(t),function(t){return nn(t,function(t){return{coord:this.dataToCoord(t),tickValue:t}},this)},this)},t.prototype.getViewLabels=function(t){return ow(this,t=t||iw(nw)).labels},t.prototype.getLabelModel=function(){return this.model.getModel("axisLabel")},t.prototype.getTickModel=function(){return this.model.getModel("axisTick")},t.prototype.getBandWidth=function(){var t=this._extent,e=this.scale.getExtent(),n=e[1]-e[0]+(this.onBand?1:0);0===n&&(n=1);var i=Math.abs(t[1]-t[0]);return Math.abs(i)/n},t.prototype.calculateCategoryInterval=function(t){return function(t,e){var n=e.kind,i=function(t){var e=t.getLabelModel();return{axisRotate:t.getRotate?t.getRotate():t.isHorizontal&&!t.isHorizontal()?90:0,labelRotate:e.get("rotate")||0,font:e.getFont()}}(t),r=$b(t),o=(i.axisRotate-i.labelRotate)/180*Math.PI,a=t.scale,s=a.getExtent(),l=a.count();if(s[1]-s[0]<1)return 0;var c=1;l>40&&(c=Math.max(1,Math.floor(l/40)));for(var u=s[0],h=t.dataToCoord(u+1)-t.dataToCoord(u),d=Math.abs(h*Math.cos(o)),p=Math.abs(h*Math.sin(o)),f=0,g=0;u<=s[1];u+=c){var v,m,y=Ta(r({value:u}),i.font,"center","top");v=1.3*y.width,m=1.3*y.height,f=Math.max(f,v,7),g=Math.max(g,m,7)}var _=f/d,x=g/p;isNaN(_)&&(_=1/0),isNaN(x)&&(x=1/0);var b=Math.max(0,Math.floor(Math.min(_,x)));if(n===ew)return e.out.noPxChangeTryDetermine.push(sn(pw,null,t,b,l)),b;var w=fw(t,b,l);return null!=w?w:b}(this,t=t||iw(nw))},t}();function _w(t,e){var n=(t[1]-t[0])/e/2;t[0]+=n,t[1]-=n}var xw=["label","labelLine","layoutOption","priority","defaultAttr","marginForce","minMarginForce","marginDefault","suggestIgnore"];function bw(t,e,n){n=n||3,e?t.dirty|=n:t.dirty&=~n}function ww(t,e){return e=e||3,null==t.dirty||!!(t.dirty&e)}function Sw(t){if(t)return ww(t)&&function(t,e,n){var i=e.getComputedTransform();t.transform=Ud(t.transform,i);var r=t.localRect=Wd(t.localRect,e.getBoundingRect()),o=e.style,a=o.margin,s=n&&n.marginForce,l=n&&n.minMarginForce,c=n&&n.marginDefault,u=o.__marginType;null==u&&c&&(a=c,u=lp.textMargin);for(var h=0;h<4;h++)Cw[h]=u===lp.minMargin&&l&&null!=l[h]?l[h]:s&&null!=s[h]?s[h]:a?a[h]:0;u===lp.textMargin&&Od(r,Cw,!1,!1);var d=t.rect=Wd(t.rect,r);i&&d.applyTransform(i);u===lp.minMargin&&Od(d,Cw,!1,!1);t.axisAligned=$d(i),(t.label=t.label||{}).ignore=e.ignore,bw(t,!1),bw(t,!0,2)}(t,t.label,t),t}var Cw=[0,0,0,0];function Mw(t,e){for(var n=0;n-1&&(s.style.stroke=s.style.fill,s.style.fill=Bf.color.neutral00,s.style.lineWidth=2),e},e.type="series.line",e.dependencies=["grid","polar"],e.defaultOption={z:3,coordinateSystem:"cartesian2d",legendHoverLink:!0,clip:!0,label:{position:"top"},endLabel:{show:!1,valueAnimation:!0,distance:8},lineStyle:{width:2,type:"solid"},emphasis:{scale:!0},step:!1,smooth:!1,smoothMonotone:null,symbol:"emptyCircle",symbolSize:6,symbolRotate:null,showSymbol:!0,showAllSymbol:"auto",connectNulls:!1,sampling:"none",animationEasing:"linear",progressive:0,hoverLayerThreshold:1/0,universalTransition:{divideShape:"clone"},triggerLineEvent:!1},e}(fm);function Iw(t,e){var n=t.mapDimensionsAll("defaultedLabel"),i=n.length;if(1===i){var r=yv(t,e,n[0]);return null!=r?r+"":null}if(i){for(var o=[],a=0;a0?+d:1;k.scaleX=this._sizeX*T,k.scaleY=this._sizeY*T,this.setSymbolScale(1),Wu(this,l,c,u)},e.prototype.setSymbolScale=function(t){this.scaleX=this.scaleY=t},e.prototype.fadeOut=function(t,e,n){var i=this.childAt(0),r=nu(this).dataIndex,o=n&&n.animation;if(this.silent=i.silent=!0,n&&n.fadeLabel){var a=i.getTextContent();a&&gd(a,{style:{opacity:0}},e,{dataIndex:r,removeOpt:o,cb:function(){i.removeTextContent()}})}else i.removeTextContent();gd(i,{style:{opacity:0},scaleX:0,scaleY:0},e,{dataIndex:r,cb:t,removeOpt:o})},e.getSymbolSize=function(t,e){return cn(n=t.getItemVisual(e,"symbolSize"))||(n=[+n,+n]),[n[0]||0,n[1]||0];var n},e.getSymbolZ2=function(t,e){return t.getItemVisual(e,"z2")},e}(Ua);function Pw(t,e){this.parent.drift(t,e)}function Lw(t,e,n,i){return e&&!isNaN(e[0])&&!isNaN(e[1])&&!(i.isIgnore&&i.isIgnore(n))&&!(i.clipShape&&!i.clipShape.contain(e[0],e[1]))&&"none"!==t.getItemVisual(n,"symbol")}function Ew(t){return null==t||fn(t)||(t={isIgnore:t}),t||{}}function zw(t){var e=t.hostModel,n=e.getModel("emphasis");return{emphasisItemStyle:n.getModel("itemStyle").getItemStyle(),blurItemStyle:e.getModel(["blur","itemStyle"]).getItemStyle(),selectItemStyle:e.getModel(["select","itemStyle"]).getItemStyle(),focus:n.get("focus"),blurScope:n.get("blurScope"),emphasisDisabled:n.get("disabled"),hoverScale:n.get("scale"),labelStatesModels:Qd(e),cursorStyle:e.get("cursor")}}var Ow=function(){function t(t){this.group=new Ua,this._SymbolCtor=t||Aw}return t.prototype.updateData=function(t,e){this._progressiveEls=null,e=Ew(e);var n=this.group,i=t.hostModel,r=this._data,o=this._SymbolCtor,a=e.disableAnimation,s=zw(t),l={disableAnimation:a},c=e.getSymbolPoint||function(e){return t.getItemLayout(e)};r||n.removeAll(),t.diff(r).add(function(i){var r=c(i);if(Lw(t,r,i,e)){var a=new o(t,i,s,l);a.setPosition(r),t.setItemGraphicEl(i,a),n.add(a)}}).update(function(u,h){var d=r.getItemGraphicEl(h),p=c(u);if(Lw(t,p,u,e)){var f=t.getItemVisual(u,"symbol")||"circle",g=d&&d.getSymbolType&&d.getSymbolType();if(!d||g&&g!==f)n.remove(d),(d=new o(t,u,s,l)).setPosition(p);else{d.updateData(t,u,s,l);var v={x:p[0],y:p[1]};a?d.attr(v):dd(d,v,i)}n.add(d),t.setItemGraphicEl(u,d)}else n.remove(d)}).remove(function(t){var e=r.getItemGraphicEl(t);e&&e.fadeOut(function(){n.remove(e)},i)}).execute(),this._getSymbolPoint=c,this._data=t},t.prototype.updateLayout=function(){var t=this,e=this._data;e&&e.eachItemGraphicEl(function(e,n){var i=t._getSymbolPoint(n);e.setPosition(i),e.markRedraw()})},t.prototype.incrementalPrepareUpdate=function(t){this._seriesScope=zw(t),this._data=null,this.group.removeAll()},t.prototype.incrementalUpdate=function(t,e,n){function i(t){t.isGroup||(t.incremental=!0,t.ensureState("emphasis").hoverLayer=!0)}this._progressiveEls=[],n=Ew(n);for(var r=t.start;r0?n=i[0]:i[1]<0&&(n=i[1]);return n}(r,n),a=i.dim,s=r.dim,l=e.mapDimension(s),c=e.mapDimension(a),u="x"===s||"radius"===s?1:0,h=nn(t.dimensions,function(t){return e.mapDimension(t)}),d=!1,p=e.getCalculationInfo("stackResultDimension");return Yx(e,h[0])&&(d=!0,h[0]=p),Yx(e,h[1])&&(d=!0,h[1]=p),{dataDimsForPoint:h,valueStart:o,valueAxisDim:s,baseAxisDim:a,stacked:!!d,valueDim:l,baseDim:c,baseDataOffset:u,stackedOverDimension:e.getCalculationInfo("stackedOverDimension")}}function Rw(t,e,n,i){var r=NaN;t.stacked&&(r=n.get(n.getCalculationInfo("stackedOverDimension"),i)),isNaN(r)&&(r=t.valueStart);var o=t.baseDataOffset,a=[];return a[o]=n.get(t.baseDim,i),a[1-o]=r,e.dataToPoint(a)}var Hw=Math.min,Bw=Math.max;function Fw(t,e){return isNaN(t)||isNaN(e)}function $w(t,e,n,i,r,o,a,s,l){for(var c,u,h,d,p,f,g=n,v=0;v=r||g<0)break;if(Fw(m,y)){if(l){g+=o;continue}break}if(g===n)t[o>0?"moveTo":"lineTo"](m,y),h=m,d=y;else{var _=m-c,x=y-u;if(_*_+x*x<.5){g+=o;continue}if(a>0){for(var b=g+o,w=e[2*b],S=e[2*b+1];w===m&&S===y&&v=i||Fw(w,S))p=m,f=y;else{k=w-c,T=S-u;var A=m-c,P=w-m,L=y-u,E=S-y,z=void 0,O=void 0;if("x"===s){var N=k>0?1:-1;p=m-N*(z=Math.abs(A))*a,f=y,D=m+N*(O=Math.abs(P))*a,I=y}else if("y"===s){var R=T>0?1:-1;p=m,f=y-R*(z=Math.abs(L))*a,D=m,I=y+R*(O=Math.abs(E))*a}else z=Math.sqrt(A*A+L*L),p=m-k*a*(1-(M=(O=Math.sqrt(P*P+E*E))/(O+z))),f=y-T*a*(1-M),I=y+T*a*M,D=Hw(D=m+k*a*M,Bw(w,m)),I=Hw(I,Bw(S,y)),D=Bw(D,Hw(w,m)),f=y-(T=(I=Bw(I,Hw(S,y)))-y)*z/O,p=Hw(p=m-(k=D-m)*z/O,Bw(c,m)),f=Hw(f,Bw(u,y)),D=m+(k=m-(p=Bw(p,Hw(c,m))))*O/z,I=y+(T=y-(f=Bw(f,Hw(u,y))))*O/z}t.bezierCurveTo(h,d,p,f,m,y),h=D,d=I}else t.lineTo(m,y)}c=m,u=y,g+=o}return v}var Vw=function(){this.smooth=0,this.smoothConstraint=!0},Ww=function(t){function e(e){var n=t.call(this,e)||this;return n.type="ec-polyline",n}return _(e,t),e.prototype.getDefaultStyle=function(){return{stroke:Bf.color.neutral99,fill:null}},e.prototype.getDefaultShape=function(){return new Vw},e.prototype.buildPath=function(t,e){var n=e.points,i=0,r=n.length/2;if(e.connectNulls){for(;r>0&&Fw(n[2*r-2],n[2*r-1]);r--);for(;i=0){var v=a?(u-i)*g+i:(c-n)*g+n;return a?[t,v]:[v,t]}n=c,i=u;break;case o.C:c=r[l++],u=r[l++],h=r[l++],d=r[l++],p=r[l++],f=r[l++];var m=a?Tr(n,c,h,p,t,s):Tr(i,u,d,f,t,s);if(m>0)for(var y=0;y=0){v=a?Mr(i,u,d,f,_):Mr(n,c,h,p,_);return a?[t,v]:[v,t]}}n=p,i=f}}},e}(Tc),Uw=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return _(e,t),e}(Vw),Gw=function(t){function e(e){var n=t.call(this,e)||this;return n.type="ec-polygon",n}return _(e,t),e.prototype.getDefaultShape=function(){return new Uw},e.prototype.buildPath=function(t,e){var n=e.points,i=e.stackedOnPoints,r=0,o=n.length/2,a=e.smoothMonotone;if(e.connectNulls){for(;o>0&&Fw(n[2*o-2],n[2*o-1]);o--);for(;r=0&&t<=1){var i=t*(e.length-1),r=Math.floor(i),o=Math.ceil(i),a=to(e[r]),s=to(e[o]),l=i-r,c=ro([Wr(Xr(a[0],s[0],l)),Wr(Xr(a[1],s[1],l)),Wr(Xr(a[2],s[2],l)),Ur(Xr(a[3],s[3],l))],"rgba");return n?{color:c,leftIndex:r,rightIndex:o,value:i}:c}}((n-i)/(e.coord-i),[t.color,e.color])}}for(var s=0;se){i?r.push(a(i,l,e)):n&&r.push(a(n,l,0),a(n,l,e));break}n&&(r.push(a(n,l,0)),n=null),r.push(l),i=l}}return r}function Qw(t,e,n){var i=t.get("showAllSymbol"),r="auto"===i;if(!i||r){var o=n.getAxesByScale("ordinal")[0];if(o&&(!r||!function(t,e){var n=t.getExtent(),i=Math.abs(n[1]-n[0])/t.scale.count();isNaN(i)&&(i=0);for(var r=e.count(),o=Math.max(1,Math.round(r/5)),a=0;ai)return!1;return!0}(o,e))){var a=e.mapDimension(o.dim),s={};return en(o.getViewLabels(),function(t){var e=o.scale.getRawOrdinalNumber(t.tickValue);s[e]=1}),function(t){return!s.hasOwnProperty(e.get(a,t))}}}}function Jw(t,e){return isNaN(t)||isNaN(e)}function tS(t,e){return[t[2*e],t[2*e+1]]}function eS(t){if(t.get(["endLabel","show"]))return!0;for(var e=0;e=0;a--){var s=t.getDimensionInfo(i[a].dimension);if("x"===(r=s&&s.coordDim)||"y"===r){o=i[a];break}}if(o){var l=e.getAxis(r),c=nn(o.stops,function(t){return{coord:l.toGlobalCoord(l.dataToCoord(t.value)),color:t.color}}),u=c.length,h=o.outerColors.slice();u&&c[0].coord>c[u-1].coord&&(c.reverse(),h.reverse());var d=Kw(c,"x"===r?n.getWidth():n.getHeight()),p=d.length;if(!p&&u)return c[0].coord<0?h[1]?h[1]:c[u-1].color:h[0]?h[0]:c[0].color;var f=d[0].coord-10,g=d[p-1].coord+10,v=g-f;if(v<.001)return"transparent";en(d,function(t){t.offset=(t.coord-f)/v}),d.push({offset:p?d[p-1].offset:.5,color:h[1]||"transparent"}),d.unshift({offset:p?d[0].offset:.5,color:h[0]||"transparent"});var m=new Kh(0,0,0,0,d,!0);return m[r]=f,m[r+"2"]=g,m}}}(o,i,n)||o.getVisual("style")[o.getVisual("drawType")];if(d&&u.type===i.type&&M===this._step){v&&!p?p=this._newPolygon(l,_):p&&!v&&(f.remove(p),p=this._polygon=null),c||this._initOrUpdateEndLabel(t,i,vf(k));var T=f.getClipPath();if(T)pd(T,{shape:nS(this,i,!1,t).shape},t);else f.setClipPath(nS(this,i,!0,t));x&&h.updateData(o,{isIgnore:w,clipShape:C,disableAnimation:!0,getSymbolPoint:function(t){return[l[2*t],l[2*t+1]]}}),qw(this._stackedOnPoints,_)&&qw(this._points,l)||(g?this._doUpdateAnimation(o,_,i,n,M,m,b):(M&&(_&&(_=Zw(_,l,i,M,b)),l=Zw(l,null,i,M,b)),d.setShape({points:l}),p&&p.setShape({points:l,stackedOnPoints:_})))}else x&&h.updateData(o,{isIgnore:w,clipShape:C,disableAnimation:!0,getSymbolPoint:function(t){return[l[2*t],l[2*t+1]]}}),g&&this._initSymbolLabelAnimation(o,i,C),M&&(_&&(_=Zw(_,l,i,M,b)),l=Zw(l,null,i,M,b)),d=this._newPolyline(l),v?p=this._newPolygon(l,_):p&&(f.remove(p),p=this._polygon=null),c||this._initOrUpdateEndLabel(t,i,vf(k)),f.setClipPath(nS(this,i,!0,t));var D=t.getModel("emphasis"),I=D.get("focus"),A=D.get("blurScope"),P=D.get("disabled");(d.useStyle(Ke(a.getLineStyle(),{fill:"none",stroke:k,lineJoin:"bevel"})),qu(d,t,"lineStyle"),d.style.lineWidth>0&&"bolder"===t.get(["emphasis","lineStyle","width"]))&&(d.getState("emphasis").style.lineWidth=+d.style.lineWidth+1);nu(d).seriesIndex=t.seriesIndex,Wu(d,I,A,P);var L=Yw(t.get("smooth")),E=t.get("smoothMonotone");if(d.setShape({smooth:L,smoothMonotone:E,connectNulls:b}),p){var z=o.getCalculationInfo("stackedOnSeries"),O=0;p.useStyle(Ke(s.getAreaStyle(),{fill:k,opacity:.7,lineJoin:"bevel",decal:o.getVisual("style").decal})),z&&(O=Yw(z.get("smooth"))),p.setShape({smooth:L,stackedOnSmooth:O,smoothMonotone:E,connectNulls:b}),qu(p,t,"areaStyle"),nu(p).seriesIndex=t.seriesIndex,Wu(p,I,A,P)}var N=this._changePolyState;o.eachItemGraphicEl(function(t){t&&(t.onHoverStateChange=N)}),this._polyline.onHoverStateChange=N,this._data=o,this._coordSys=i,this._stackedOnPoints=_,this._points=l,this._step=M,this._valueOrigin=m,t.get("triggerLineEvent")&&(this.packEventData(t,d),p&&this.packEventData(t,p))},e.prototype.packEventData=function(t,e){nu(e).eventData={componentType:"series",componentSubType:"line",componentIndex:t.componentIndex,seriesIndex:t.seriesIndex,seriesName:t.name,seriesType:"line"}},e.prototype.highlight=function(t,e,n,i){var r=t.getData(),o=Ls(r,i);if(this._changePolyState("emphasis"),!(o instanceof Array)&&null!=o&&o>=0){var a=r.getLayout("points"),s=r.getItemGraphicEl(o);if(!s){var l=a[2*o],c=a[2*o+1];if(isNaN(l)||isNaN(c))return;if(this._clipShapeForSymbol&&!this._clipShapeForSymbol.contain(l,c))return;var u=t.get("zlevel")||0,h=t.get("z")||0;(s=new Aw(r,o)).x=l,s.y=c,s.setZ(u,h);var d=s.getSymbolPath().getTextContent();d&&(d.zlevel=u,d.z=h,d.z2=this._polyline.z2+1),s.__temp=!0,r.setItemGraphicEl(o,s),s.stopSymbolAnimation(!0),this.group.add(s)}s.highlight()}else km.prototype.highlight.call(this,t,e,n,i)},e.prototype.downplay=function(t,e,n,i){var r=t.getData(),o=Ls(r,i);if(this._changePolyState("normal"),null!=o&&o>=0){var a=r.getItemGraphicEl(o);a&&(a.__temp?(r.setItemGraphicEl(o,null),this.group.remove(a)):a.downplay())}else km.prototype.downplay.call(this,t,e,n,i)},e.prototype._changePolyState=function(t){var e=this._polygon;Mu(this._polyline,t),e&&Mu(e,t)},e.prototype._newPolyline=function(t){var e=this._polyline;return e&&this._lineGroup.remove(e),e=new Ww({shape:{points:t},segmentIgnoreThreshold:2,z2:10}),this._lineGroup.add(e),this._polyline=e,e},e.prototype._newPolygon=function(t,e){var n=this._polygon;return n&&this._lineGroup.remove(n),n=new Gw({shape:{points:t,stackedOnPoints:e},segmentIgnoreThreshold:2}),this._lineGroup.add(n),this._polygon=n,n},e.prototype._initSymbolLabelAnimation=function(t,e,n){var i,r,o=e.getBaseAxis(),a=o.inverse;"cartesian2d"===e.type?(i=o.isHorizontal(),r=!1):"polar"===e.type&&(i="angle"===o.dim,r=!0);var s=t.hostModel,l=s.get("animationDuration");un(l)&&(l=l(null));var c=s.get("animationDelay")||0,u=un(c)?c(null):c;t.eachItemGraphicEl(function(t,o){var s=t;if(s){var h=[t.x,t.y],d=void 0,p=void 0,f=void 0;if(n)if(r){var g=n,v=e.pointToCoord(h);i?(d=g.startAngle,p=g.endAngle,f=-v[1]/180*Math.PI):(d=g.r0,p=g.r,f=v[0])}else{var m=n;i?(d=m.x,p=m.x+m.width,f=t.x):(d=m.y+m.height,p=m.y,f=t.y)}var y=p===d?0:(f-d)/(p-d);a&&(y=1-y);var _=un(c)?c(o):l*y+u,x=s.getSymbolPath(),b=x.getTextContent();s.attr({scaleX:0,scaleY:0}),s.animateTo({scaleX:1,scaleY:1},{duration:200,setToFinal:!0,delay:_}),b&&b.animateFrom({style:{opacity:0}},{duration:300,delay:_}),x.disableLabelAnimation=!0}})},e.prototype._initOrUpdateEndLabel=function(t,e,n){var i=t.getModel("endLabel");if(eS(t)){var r=t.getData(),o=this._polyline,a=r.getLayout("points");if(!a)return o.removeTextContent(),void(this._endLabel=null);var s=this._endLabel;s||((s=this._endLabel=new Wc({z2:200})).ignoreClip=!0,o.setTextContent(this._endLabel),o.disableLabelAnimation=!0);var l=function(t){for(var e=t.length/2;e>0&&Jw(t[2*e-2],t[2*e-1]);e--);return e-1}(a);l>=0&&(Kd(o,Qd(t,"endLabel"),{inheritColor:n,labelFetcher:t,labelDataIndex:l,defaultText:function(t,e,n){return null!=n?function(t,e){var n=t.mapDimensionsAll("defaultedLabel");if(!cn(e))return e+"";for(var i=[],r=0;r=0&&i.push(e[o])}return i.join(" ")}(r,n):Iw(r,t)},enableTextSetter:!0},function(t,e){var n=e.getBaseAxis(),i=n.isHorizontal(),r=n.inverse,o=i?r?"right":"left":"center",a=i?"middle":r?"top":"bottom";return{normal:{align:t.get("align")||o,verticalAlign:t.get("verticalAlign")||a}}}(i,e)),o.textConfig.position=null)}else this._endLabel&&(this._polyline.removeTextContent(),this._endLabel=null)},e.prototype._endLabelOnDuring=function(t,e,n,i,r,o,a){var s=this._endLabel,l=this._polyline;if(s){t<1&&null==i.originalX&&(i.originalX=s.x,i.originalY=s.y);var c=n.getLayout("points"),u=n.hostModel,h=u.get("connectNulls"),d=o.get("precision"),p=o.get("distance")||0,f=a.getBaseAxis(),g=f.isHorizontal(),v=f.inverse,m=e.shape,y=v?g?m.x:m.y+m.height:g?m.x+m.width:m.y,_=(g?p:0)*(v?-1:1),x=(g?0:-p)*(v?-1:1),b=g?"x":"y",w=function(t,e,n){for(var i,r,o=t.length/2,a="x"===n?0:1,s=0,l=-1,c=0;c=e||i>=e&&r<=e){l=c;break}s=c,i=r}else i=r;return{range:[s,l],t:(e-i)/(r-i)}}(c,y,b),S=w.range,C=S[1]-S[0],M=void 0;if(C>=1){if(C>1&&!h){var k=tS(c,S[0]);s.attr({x:k[0]+_,y:k[1]+x}),r&&(M=u.getRawValue(S[0]))}else{(k=l.getPointOn(y,b))&&s.attr({x:k[0]+_,y:k[1]+x});var T=u.getRawValue(S[0]),D=u.getRawValue(S[1]);r&&(M=function(t,e,n,i,r){var o=null==e||"auto"===e;if(null==i)return i;if(pn(i))return is(f=_s(n||0,i,r),o?Math.max(rs(n||0),rs(i)):e);if(hn(i))return r<1?n:i;for(var a=[],s=n,l=i,c=Math.max(s?s.length:0,l.length),u=0;u0?S[0]:0;k=tS(c,I);r&&(M=u.getRawValue(I)),s.attr({x:k[0]+_,y:k[1]+x})}if(r){var A=sp(s);"function"==typeof A.setLabelText&&A.setLabelText(M)}}},e.prototype._doUpdateAnimation=function(t,e,n,i,r,o,a){var s=this._polyline,l=this._polygon,c=t.hostModel,u=function(t,e,n,i,r,o,a){for(var s=function(t,e){var n=[];return e.diff(t).add(function(t){n.push({cmd:"+",idx:t})}).update(function(t,e){n.push({cmd:"=",idx:e,idx1:t})}).remove(function(t){n.push({cmd:"-",idx:t})}).execute(),n}(t,e),l=[],c=[],u=[],h=[],d=[],p=[],f=[],g=Nw(r,e,a),v=t.getLayout("points")||[],m=e.getLayout("points")||[],y=0;y3e3||l&&Xw(d,f)>3e3)return s.stopAnimation(),s.setShape({points:p}),void(l&&(l.stopAnimation(),l.setShape({points:p,stackedOnPoints:f})));s.shape.__points=u.current,s.shape.points=h;var g={shape:{points:p}};u.current!==h&&(g.shape.__points=u.next),s.stopAnimation(),dd(s,g,c),l&&(l.setShape({points:h,stackedOnPoints:d}),l.stopAnimation(),dd(l,{shape:{stackedOnPoints:f}},c),s.shape.points!==l.shape.points&&(l.shape.points=s.shape.points));for(var v=[],m=u.status,y=0;ye&&(e=t[n]);return isFinite(e)?e:NaN},min:function(t){for(var e=1/0,n=0;ne[1]&&e.reverse(),e},e.prototype.pointToData=function(t,e){return this.coordToData(this.toLocalCoord(t["x"===this.dim?0:1]),e)},e.prototype.setCategorySortInfo=function(t){if("category"!==this.type)return!1;this.model.option.categorySortInfo=t,this.scale.setSortInfo(t)},e}(yw),wS="expandAxisBreak",SS=Math.PI,CS=[[1,2,1,2],[5,3,5,3],[8,3,8,3]],MS=[[0,1,0,1],[0,3,0,3],[0,3,0,3]],kS=Es(),TS=Es(),DS=function(){function t(t){this.recordMap={},this.resolveAxisNameOverlap=t}return t.prototype.ensureRecord=function(t){var e=t.axis.dim,n=t.componentIndex,i=this.recordMap,r=i[e]||(i[e]=[]);return r[n]||(r[n]={ready:{}})},t}();var IS=[1,0,0,1,0,0],AS=new $i(0,0,0,0),PS=function(t,e,n,i,r,o){if(qb(t.nameLocation)){var a=o.stOccupiedRect;a&&LS(function(t,e,n){return t.transform=Ud(t.transform,n),t.localRect=Wd(t.localRect,e),t.rect=Wd(t.rect,e),n&&t.rect.applyTransform(n),t.axisAligned=$d(n),t.obb=void 0,(t.label=t.label||{}).ignore=!1,t}({},a,o.transGroup.transform),i,r)}else ES(o.labelInfoList,o.dirVec,i,r)};function LS(t,e,n){var i=new Ti;Tw(t,e,i,{direction:Math.atan2(n.y,n.x),bidirectional:!1,touchThreshold:.05})&&function(t,e){if(t){t.label.x+=e.x,t.label.y+=e.y,t.label.markRedraw();var n=t.transform;n&&(n[4]+=e.x,n[5]+=e.y);var i=t.rect;i&&(i.x+=e.x,i.y+=e.y);var r=t.obb;r&&r.fromBoundingRect(t.localRect,n)}}(e,i)}function ES(t,e,n,i){for(var r=Ti.dot(i,e)>=0,o=0,a=t.length;o0?"top":"bottom",i="center"):ss(o-SS)?(r=n>0?"bottom":"top",i="center"):(r="middle",i=o>0&&o0?"right":"left":n>0?"left":"right"),{rotation:o,textAlign:i,textVerticalAlign:r}},t.makeAxisEventDataBase=function(t){var e={componentType:t.mainType,componentIndex:t.componentIndex};return e[t.mainType+"Index"]=t.componentIndex,e},t.isLabelSilent=function(t){var e=t.get("tooltip");return t.get("silent")||!(t.get("triggerEvent")||e&&e.show)},t}(),OS=["axisLine","axisTickLabelEstimate","axisTickLabelDetermine","axisName"],NS={axisLine:function(t,e,n,i,r,o,a){var s=i.get(["axisLine","show"]);if("auto"===s&&(s=!0,null!=t.raw.axisLineAutoShow&&(s=!!t.raw.axisLineAutoShow)),s){var l=i.axis.getExtent(),c=o.transform,u=[l[0],0],h=[l[1],0],d=u[0]>h[0];c&&(jn(u,u,c),jn(h,h,c));var p=Ze({lineCap:"round"},i.getModel(["axisLine","lineStyle"]).getLineStyle()),f={strokeContainThreshold:t.raw.strokeContainThreshold||5,silent:!0,z2:1,style:p};if(i.get(["axisLine","breakLine"])&&i.axis.scale.hasBreaks())null.buildAxisBreakLine(i,r,o,f);else{var g=new Vh(Ze({shape:{x1:u[0],y1:u[1],x2:h[0],y2:h[1]}},f));Td(g.shape,g.style.lineWidth),g.anid="line",r.add(g)}var v=i.get(["axisLine","symbol"]);if(null!=v){var m=i.get(["axisLine","symbolSize"]);hn(v)&&(v=[v,v]),(hn(m)||pn(m))&&(m=[m,m]);var y=zy(i.get(["axisLine","symbolOffset"])||0,m),_=m[0],x=m[1];en([{rotate:t.rotation+Math.PI/2,offset:y[0],r:0},{rotate:t.rotation-Math.PI/2,offset:y[1],r:Math.sqrt((u[0]-h[0])*(u[0]-h[0])+(u[1]-h[1])*(u[1]-h[1]))}],function(e,n){if("none"!==v[n]&&null!=v[n]){var i=Ey(v[n],-_/2,-x/2,_,x,p.stroke,!0),o=e.r+e.offset,a=d?h:u;i.attr({rotation:e.rotate,x:a[0]+o*Math.cos(t.rotation),y:a[1]-o*Math.sin(t.rotation),silent:!0,z2:11}),r.add(i)}})}}},axisTickLabelEstimate:function(t,e,n,i,r,o,a,s){FS(e,r,s)&&RS(t,e,n,i,r,o,a,ew)},axisTickLabelDetermine:function(t,e,n,i,r,o,a,s){FS(e,r,s)&&RS(t,e,n,i,r,o,a,nw);var l=function(t,e,n,i){var r=i.axis,o=i.getModel("axisTick"),a=o.get("show");"auto"===a&&(a=!0,null!=t.raw.axisTickAutoShow&&(a=!!t.raw.axisTickAutoShow));if(!a||r.scale.isBlank())return[];for(var s=o.getModel("lineStyle"),l=t.tickDirection*o.get("length"),c=BS(r.getTicksCoords(),n.transform,l,Ke(s.getLineStyle(),{stroke:i.get(["axisLine","lineStyle","color"])}),"ticks"),u=0;ui[1],l="start"===e&&!s||"start"!==e&&s;ss(a-SS/2)?(o=l?"bottom":"top",r="center"):ss(a-1.5*SS)?(o=l?"top":"bottom",r="center"):(o="middle",r=a<1.5*SS&&a>SS/2?l?"left":"right":l?"right":"left");return{rotation:a,textAlign:r,textVerticalAlign:o}}(t.rotation,u,b||0,f),null!=(x=t.raw.axisNameAvailableWidth)&&(x=Math.abs(x/Math.sin(_.rotation)),!isFinite(x)&&(x=null)));var w=d.getFont(),S=i.get("nameTruncate",!0)||{},C=S.ellipsis,M=xn(t.raw.nameTruncateMaxWidth,S.maxWidth,x),k=s.nameMarginLevel||0,T=new Wc({x:v.x,y:v.y,rotation:_.rotation,silent:zS.isLabelSilent(i),style:Jd(d,{text:c,font:w,overflow:"truncate",width:M,ellipsis:C,fill:d.getTextColor()||i.get(["axisLine","lineStyle","color"]),align:d.get("align")||_.textAlign,verticalAlign:d.get("verticalAlign")||_.textVerticalAlign}),z2:1});if(Hd({el:T,componentModel:i,itemName:c}),T.__fullText=c,T.anid="name",i.get("triggerEvent")){var D=zS.makeAxisEventDataBase(i);D.targetType="axisName",D.name=c,nu(T).eventData=D}o.add(T),T.updateTransform(),e.nameEl=T;var I=l.nameLayout=Sw({label:T,priority:T.z2,defaultAttr:{ignore:T.ignore},marginDefault:qb(u)?CS[k]:MS[k]});if(l.nameLocation=u,r.add(T),T.decomposeTransform(),t.shouldNameMoveOverlap&&I){var A=n.ensureRecord(i);n.resolveAxisNameOverlap(t,n,i,I,m,A)}}}};function RS(t,e,n,i,r,o,a,s){$S(e)||function(t,e,n,i,r,o){var a=r.axis,s=xn(t.raw.axisLabelShow,r.get(["axisLabel","show"])),l=new Ua;n.add(l);var c=iw(i);if(!s||a.scale.isBlank())return void VS(e,[],l,c);var u=r.getModel("axisLabel"),h=a.getViewLabels(c),d=(xn(t.raw.labelRotate,u.get("rotate"))||0)*SS/180,p=zS.innerTextLayout(t.rotation,d,t.labelDirection),f=r.getCategories&&r.getCategories(!0),g=[],v=r.get("triggerEvent"),m=1/0,y=-1/0;en(h,function(t,e){var n,i="ordinal"===a.scale.type?a.scale.getRawOrdinalNumber(t.tickValue):t.tickValue,s=t.formattedLabel,c=t.rawLabel,d=u;if(f&&f[i]){var _=f[i];fn(_)&&_.textStyle&&(d=new _p(_.textStyle,u,r.ecModel))}var x=d.getTextColor()||r.get(["axisLine","lineStyle","color"]),b=d.getShallow("align",!0)||p.textAlign,w=bn(d.getShallow("alignMinLabel",!0),b),S=bn(d.getShallow("alignMaxLabel",!0),b),C=d.getShallow("verticalAlign",!0)||d.getShallow("baseline",!0)||p.textVerticalAlign,M=bn(d.getShallow("verticalAlignMinLabel",!0),C),k=bn(d.getShallow("verticalAlignMaxLabel",!0),C),T=10+((null===(n=t.time)||void 0===n?void 0:n.level)||0);m=Math.min(m,T),y=Math.max(y,T);var D=new Wc({x:0,y:0,rotation:0,silent:zS.isLabelSilent(r),z2:T,style:Jd(d,{text:s,align:0===e?w:e===h.length-1?S:b,verticalAlign:0===e?M:e===h.length-1?k:C,fill:un(x)?x("category"===a.type?c:"value"===a.type?i+"":i,e):x})});D.anid="label_"+i;var I=kS(D);if(I.break=t.break,I.tickValue=i,I.layoutRotation=p.rotation,Hd({el:D,componentModel:r,itemName:s,formatterParamsExtra:{isTruncated:function(){return D.isTruncated},value:c,tickIndex:e}}),v){var A=zS.makeAxisEventDataBase(r);A.targetType="axisLabel",A.value=c,A.tickIndex=e,t.break&&(A.break={start:t.break.parsedBreak.vmin,end:t.break.parsedBreak.vmax}),"category"===a.type&&(A.dataIndex=i),nu(D).eventData=A,t.break&&function(t,e,n,i){n.on("click",function(n){var r={type:wS,breaks:[{start:i.parsedBreak.breakOption.start,end:i.parsedBreak.breakOption.end}]};r[t.axis.dim+"AxisIndex"]=t.componentIndex,e.dispatchAction(r)})}(r,o,D,t.break)}g.push(D),l.add(D)});var _=nn(g,function(t){return{label:t,priority:kS(t).break?t.z2+(y-m+1):t.z2,defaultAttr:{ignore:t.ignore}}});VS(e,_,l,c)}(t,e,r,s,i,a);var l=e.labelLayoutList;!function(t,e,n,i){var r=e.get(["axisLabel","margin"]);en(n,function(n,o){var a=Sw(n);if(a){var s=a.label,l=kS(s);a.suggestIgnore=s.ignore,s.ignore=!1,xa(WS,US),WS.x=e.axis.dataToCoord(l.tickValue),WS.y=t.labelOffset+t.labelDirection*r,WS.rotation=l.layoutRotation,i.add(WS),WS.updateTransform(),i.remove(WS),WS.decomposeTransform(),xa(s,WS),s.markRedraw(),bw(a,!0),Sw(a)}})}(t,i,l,o),t.rotation;var c=t.optionHideOverlap;!function(t,e,n){if(Ub(t.axis))return;function i(t,i,r){var o=Sw(e[i]),a=Sw(e[r]);if(o&&a)if(!1===t||o.suggestIgnore)HS(o.label);else if(a.suggestIgnore)HS(a.label);else{var s=.1;if(!n){var l=[0,0,0,0];o=Mw({marginForce:l},o),a=Mw({marginForce:l},a)}Tw(o,a,null,{touchThreshold:s})&&HS(t?a.label:o.label)}}var r=t.get(["axisLabel","showMinLabel"]),o=t.get(["axisLabel","showMaxLabel"]),a=e.length;i(r,0,1),i(o,a-1,a-2)}(i,l,c),c&&function(t){var e=[];function n(t){if(!t.ignore){var e=t.ensureState("emphasis");null==e.ignore&&(e.ignore=!1)}t.ignore=!0}t.sort(function(t,e){return(e.suggestIgnore?1:0)-(t.suggestIgnore?1:0)||e.priority-t.priority});for(var i=0;i.1?"x":"y",u=a.transGroup[c];if(s.sort(function(t,e){return Math.abs(t.label[c]-u)-Math.abs(e.label[c]-u)}),l&&r){var h=o.getExtent(),d=Math.min(h[0],h[1]),p=Math.max(h[0],h[1])-d;r.union(new $i(d,0,p,1))}a.stOccupiedRect=r,a.labelInfoList=s}(t,n,i,l)}function HS(t){t&&(t.ignore=!0)}function BS(t,e,n,i,r){for(var o=[],a=[],s=[],l=0;l=0;o--){var a=t[+n[o]],s=a.model,l=a.scale;Kx(l)&&s.get("alignTicks")&&null==s.get("interval")?r.push(a):(Fb(l,s),Kx(l)&&(e=a))}r.length&&(e||Fb((e=r.pop()).scale,e.model),en(r,function(t){!function(t,e,n){var i=pb.prototype,r=i.getTicks.call(n),o=i.getTicks.call(n,{expandToNicedExtent:!0}),a=r.length-1,s=i.getInterval.call(n),l=Bb(t,e),c=l.extent,u=l.fixMin,h=l.fixMax;"log"===t.type&&(c=ab(t.base,c,!0)),t.setBreaksFromOption(Xb(e)),t.setExtent(c[0],c[1]),t.calcNiceExtent({splitNumber:a,fixMin:u,fixMax:h});var d=i.getExtent.call(t);u&&(c[0]=d[0]),h&&(c[1]=d[1]);var p=i.getInterval.call(t),f=c[0],g=c[1];if(u&&h)p=(g-f)/a;else if(u)for(g=c[0]+p*a;gc[0]&&isFinite(f)&&isFinite(c[0]);)p=Jx(p),f=c[1]-p*a;else{t.getTicks().length-1>a&&(p=Jx(p));var v=p*a;(f=is((g=Math.ceil(c[1]/p)*p)-v))<0&&c[0]>=0?(f=0,g=is(v)):g>0&&c[1]<=0&&(g=0,f=-is(v))}var m=(r[0].value-o[0].value)/s,y=(r[a].value-o[a].value)/s;i.setExtent.call(t,f+p*m,g+p*y),i.setInterval.call(t,p),(m||y)&&i.setNiceExtent.call(t,f+p,g-p)}(t.scale,t.model,e.scale)}))}}this._updateScale(t,this.model),i(n.x),i(n.y);var r={};en(n.x,function(t){KS(n,"y",t,r)}),en(n.y,function(t){KS(n,"x",t,r)}),this.resize(this.model,e)},t.prototype.resize=function(t,e,n){var i=Ef(t,e),r=this._rect=Pf(t.getBoxLayoutParams(),i.refContainer),o=this._axesMap,a=this._coordsList,s=t.get("containLabel");if(JS(o,r),!n){var l=function(t,e,n,i,r){var o=new DS(iC);return en(n,function(n){return en(n,function(n){if(jb(n.model)){var a=!i;n.axisBuilder=function(t,e,n,i,r,o){for(var a=qS(t,n),s=!1,l=!1,c=0;c0&&i>0||n<0&&i<0)}(t)}function JS(t,e){en(t.x,function(t){return tC(t,e.x,e.width)}),en(t.y,function(t){return tC(t,e.y,e.height)})}function tC(t,e,n){var i=[0,n],r=t.inverse?1:0;t.setExtent(i[r],i[1-r]),function(t,e){var n=t.getExtent(),i=n[0]+n[1];t.toGlobalCoord="x"===t.dim?function(t){return t+e}:function(t){return i-t+e},t.toLocalCoord="x"===t.dim?function(t){return t-e}:function(t){return i-t+e}}(t,e)}function eC(t,e,n,i,r,o,a){nC(i,r,ew,e,!1,a);var s=[0,0,0,0];c(0),c(1),u(i,0,NaN),u(i,1,NaN);var l=null==function(t,e,n){if(t&&e)for(var i=0,r=t.length;i0});return Od(i,s,!0,!0,n),JS(r,i),l;function c(t){en(r[yd[t]],function(e){if(jb(e.model)){var n=o.ensureRecord(e.model),i=n.labelInfoList;if(i)for(var r=0;r0&&!_n(e)&&e>1e-4&&(t/=e),t}}function nC(t,e,n,i,r,o){var a=n===nw;en(e,function(e){return en(e,function(e){jb(e.model)&&(!function(t,e,n){var i=qS(e,n);t.updateCfg(i)}(e.axisBuilder,t,e.model),e.axisBuilder.build(a?{axisTickLabelDetermine:!0}:{axisTickLabelEstimate:!0},{noPxChange:r}))})});var s={x:0,y:0};function l(e){s[yd[1-e]]=t[_d[e]]<=.5*o.refContainer[_d[e]]?0:1-e==1?2:1}l(0),l(1),en(e,function(t,e){return en(t,function(t){jb(t.model)&&(("all"===i||a)&&t.axisBuilder.build({axisName:!0},{nameMarginLevel:s[e]}),a&&t.axisBuilder.build({axisLine:!0}))})})}var iC=function(t,e,n,i,r,o){var a="x"===n.axis.dim?"y":"x";PS(t,0,0,i,r,o),qb(t.nameLocation)||en(e.recordMap[a],function(t){t&&t.labelInfoList&&t.dirVec&&ES(t.labelInfoList,t.dirVec,i,r)})};function rC(t,e){var n={axesInfo:{},seriesInvolved:!1,coordSysAxesInfo:{},coordSysMap:{}};return function(t,e,n){var i=e.getComponent("tooltip"),r=e.getComponent("axisPointer"),o=r.get("link",!0)||[],a=[];en(n.getCoordinateSystems(),function(n){if(n.axisPointerEnabled){var s=lC(n.model),l=t.coordSysAxesInfo[s]={};t.coordSysMap[s]=n;var c=n.model.getModel("tooltip",i);if(en(n.getAxes(),ln(p,!1,null)),n.getTooltipAxes&&i&&c.get("show")){var u="axis"===c.get("trigger"),h="cross"===c.get(["axisPointer","type"]),d=n.getTooltipAxes(c.get(["axisPointer","axis"]));(u||h)&&en(d.baseAxes,ln(p,!h||"cross",u)),h&&en(d.otherAxes,ln(p,"cross",!1))}}function p(i,s,u){var h=u.model.getModel("axisPointer",r),d=h.get("show");if(d&&("auto"!==d||i||sC(h))){null==s&&(s=h.get("triggerTooltip")),h=i?function(t,e,n,i,r,o){var a=e.getModel("axisPointer"),s={};en(["type","snap","lineStyle","shadowStyle","label","animation","animationDurationUpdate","animationEasingUpdate","z"],function(t){s[t]=Xe(a.get(t))}),s.snap="category"!==t.type&&!!o,"cross"===a.get("type")&&(s.type="line");var l=s.label||(s.label={});if(null==l.show&&(l.show=!1),"cross"===r){var c=a.get(["label","show"]);if(l.show=null==c||c,!o){var u=s.lineStyle=a.get("crossStyle");u&&Ke(l,u.textStyle)}}return t.model.getModel("axisPointer",new _p(s,n,i))}(u,c,r,e,i,s):h;var p=h.get("snap"),f=h.get("triggerEmphasis"),g=lC(u.model),v=s||p||"category"===u.type,m=t.axesInfo[g]={key:g,axis:u,coordSys:n,axisPointerModel:h,triggerTooltip:s,triggerEmphasis:f,involveSeries:v,snap:p,useHandle:sC(h),seriesModels:[],linkGroup:null};l[g]=m,t.seriesInvolved=t.seriesInvolved||v;var y=function(t,e){for(var n=e.model,i=e.dim,r=0;r=0||t===e}function aC(t){var e=(t.ecModel.getComponent("axisPointer")||{}).coordSysAxesInfo;return e&&e.axesInfo[lC(t)]}function sC(t){return!!t.get(["handle","show"])}function lC(t){return t.type+"||"+t.id}var cC={},uC=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return _(e,t),e.prototype.render=function(e,n,i,r){this.axisPointerClass&&function(t){var e=aC(t);if(e){var n=e.axisPointerModel,i=e.axis.scale,r=n.option,o=n.get("status"),a=n.get("value");null!=a&&(a=i.parse(a));var s=sC(n);null==o&&(r.status=s?"show":"hide");var l=i.getExtent().slice();l[0]>l[1]&&l.reverse(),(null==a||a>l[1])&&(a=l[1]),aa)return!0;if(o){var s=aC(t).seriesDataCount,l=i.getExtent();return Math.abs(l[0]-l[1])/s>a}return!1}return!0===n},t.prototype.makeElOption=function(t,e,n,i,r){},t.prototype.createPointerEl=function(t,e,n,i){var r=e.pointer;if(r){var o=xC(t).pointerEl=new Xd[r.type](bC(e.pointer));t.add(o)}},t.prototype.createLabelEl=function(t,e,n,i){if(e.label){var r=xC(t).labelEl=new Wc(bC(e.label));t.add(r),kC(r,i)}},t.prototype.updatePointerEl=function(t,e,n){var i=xC(t).pointerEl;i&&e.pointer&&(i.setStyle(e.pointer.style),n(i,{shape:e.pointer.shape}))},t.prototype.updateLabelEl=function(t,e,n,i){var r=xC(t).labelEl;r&&(r.setStyle(e.label.style),n(r,{x:e.label.x,y:e.label.y}),kC(r,i))},t.prototype._renderHandle=function(t){if(!this._dragging&&this.updateHandleTransform){var e,n=this._axisPointerModel,i=this._api.getZr(),r=this._handle,o=n.getModel("handle"),a=n.get("status");if(!o.get("show")||!a||"hide"===a)return r&&i.remove(r),void(this._handle=null);this._handle||(e=!0,r=this._handle=Ld(o.get("icon"),{cursor:"move",draggable:!0,onmousemove:function(t){mi(t.event)},onmousedown:wC(this._onHandleDragMove,this,0,0),drift:wC(this._onHandleDragMove,this),ondragend:wC(this._onHandleDragEnd,this)}),i.add(r)),DC(r,n,!1),r.setStyle(o.getItemStyle(null,["color","borderColor","borderWidth","opacity","shadowColor","shadowBlur","shadowOffsetX","shadowOffsetY"]));var s=o.get("size");cn(s)||(s=[s,s]),r.scaleX=s[0]/2,r.scaleY=s[1]/2,Nm(this,"_doDispatchAxisPointer",o.get("throttle")||0,"fixRate"),this._moveHandleToValue(t,e)}},t.prototype._moveHandleToValue=function(t,e){CC(this._axisPointerModel,!e&&this._moveAnimation,this._handle,TC(this.getHandleTransform(t,this._axisModel,this._axisPointerModel)))},t.prototype._onHandleDragMove=function(t,e){var n=this._handle;if(n){this._dragging=!0;var i=this.updateHandleTransform(TC(n),[t,e],this._axisModel,this._axisPointerModel);this._payloadInfo=i,n.stopAnimation(),n.attr(TC(i)),xC(n).lastProp=null,this._doDispatchAxisPointer()}},t.prototype._doDispatchAxisPointer=function(){if(this._handle){var t=this._payloadInfo,e=this._axisModel;this._api.dispatchAction({type:"updateAxisPointer",x:t.cursorPoint[0],y:t.cursorPoint[1],tooltipOption:t.tooltipOption,axesInfo:[{axisDim:e.axis.dim,axisIndex:e.componentIndex}]})}},t.prototype._onHandleDragEnd=function(){if(this._dragging=!1,this._handle){var t=this._axisPointerModel.get("value");this._moveHandleToValue(t),this._api.dispatchAction({type:"hideTip"})}},t.prototype.clear=function(t){this._lastValue=null,this._lastStatus=null;var e=t.getZr(),n=this._group,i=this._handle;e&&n&&(this._lastGraphicKey=null,n&&e.remove(n),i&&e.remove(i),this._group=null,this._handle=null,this._payloadInfo=null),Rm(this,"_doDispatchAxisPointer")},t.prototype.doClear=function(){},t.prototype.buildLabel=function(t,e,n){return{x:t[n=n||0],y:t[1-n],width:e[n],height:e[1-n]}},t}();function CC(t,e,n,i){MC(xC(n).lastProp,i)||(xC(n).lastProp=i,e?dd(n,i,t):(n.stopAnimation(),n.attr(i)))}function MC(t,e){if(fn(t)&&fn(e)){var n=!0;return en(e,function(e,i){n=n&&MC(t[i],e)}),!!n}return t===e}function kC(t,e){t[e.get(["label","show"])?"show":"hide"]()}function TC(t){return{x:t.x||0,y:t.y||0,rotation:t.rotation||0}}function DC(t,e,n){var i=e.get("z"),r=e.get("zlevel");t&&t.traverse(function(t){"group"!==t.type&&(null!=i&&(t.z=i),null!=r&&(t.zlevel=r),t.silent=n)})}function IC(t,e,n,i,r){var o=AC(n.get("value"),e.axis,e.ecModel,n.get("seriesDataIndices"),{precision:n.get(["label","precision"]),formatter:n.get(["label","formatter"])}),a=n.getModel("label"),s=hf(a.get("padding")||0),l=a.getFont(),c=Ta(o,l),u=r.position,h=c.width+s[1]+s[3],d=c.height+s[0]+s[2],p=r.align;"right"===p&&(u[0]-=h),"center"===p&&(u[0]-=h/2);var f=r.verticalAlign;"bottom"===f&&(u[1]-=d),"middle"===f&&(u[1]-=d/2),function(t,e,n,i){var r=i.getWidth(),o=i.getHeight();t[0]=Math.min(t[0]+e,r)-e,t[1]=Math.min(t[1]+n,o)-n,t[0]=Math.max(t[0],0),t[1]=Math.max(t[1],0)}(u,h,d,i);var g=a.get("backgroundColor");g&&"auto"!==g||(g=e.get(["axisLine","lineStyle","color"])),t.label={x:u[0],y:u[1],style:Jd(a,{text:o,font:l,fill:a.getTextColor(),padding:s,backgroundColor:g}),z2:10}}function AC(t,e,n,i,r){t=e.scale.parse(t);var o=e.scale.getLabel({value:t},{precision:r.precision}),a=r.formatter;if(a){var s={value:Vb(e,{value:t}),axisDimension:e.dim,axisIndex:e.index,seriesData:[]};en(i,function(t){var e=n.getSeriesByIndex(t.seriesIndex),i=t.dataIndexInside,r=e&&e.getDataParams(i);r&&s.seriesData.push(r)}),hn(a)?o=a.replace("{value}",o):un(a)&&(o=a(s))}return o}function PC(t,e,n){var i=[1,0,0,1,0,0];return Mi(i,i,n.rotation),Ci(i,i,n.position),Id([t.dataToCoord(e),(n.labelOffset||0)+(n.labelDirection||1)*(n.labelMargin||0)],i)}var LC=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return _(e,t),e.prototype.makeElOption=function(t,e,n,i,r){var o=n.axis,a=o.grid,s=i.get("type"),l=EC(a,o).getOtherAxis(o).getGlobalExtent(),c=o.toGlobalCoord(o.dataToCoord(e,!0));if(s&&"none"!==s){var u=function(t){var e,n=t.get("type"),i=t.getModel(n+"Style");return"line"===n?(e=i.getLineStyle()).fill=null:"shadow"===n&&((e=i.getAreaStyle()).stroke=null),e}(i),h=zC[s](o,c,l);h.style=u,t.graphicKey=h.type,t.pointer=h}!function(t,e,n,i,r,o){var a=zS.innerTextLayout(n.rotation,0,n.labelDirection);n.labelMargin=r.get(["label","margin"]),IC(e,i,r,o,{position:PC(i.axis,t,n),align:a.textAlign,verticalAlign:a.textVerticalAlign})}(e,t,qS(a.getRect(),n),n,i,r)},e.prototype.getHandleTransform=function(t,e,n){var i=qS(e.axis.grid.getRect(),e,{labelInside:!1});i.labelMargin=n.get(["handle","margin"]);var r=PC(e.axis,t,i);return{x:r[0],y:r[1],rotation:i.rotation+(i.labelDirection<0?Math.PI:0)}},e.prototype.updateHandleTransform=function(t,e,n,i){var r=n.axis,o=r.grid,a=r.getGlobalExtent(!0),s=EC(o,r).getOtherAxis(r).getGlobalExtent(),l="x"===r.dim?0:1,c=[t.x,t.y];c[l]+=e[l],c[l]=Math.min(a[1],c[l]),c[l]=Math.max(a[0],c[l]);var u=(s[1]+s[0])/2,h=[u,u];h[l]=c[l];return{x:c[0],y:c[1],rotation:t.rotation,cursorPoint:h,tooltipOption:[{verticalAlign:"middle"},{align:"center"}][l]}},e}(SC);function EC(t,e){var n={};return n[e.dim+"AxisIndex"]=e.index,t.getCartesian(n)}var zC={line:function(t,e,n){var i,r,o;return{type:"Line",subPixelOptimize:!0,shape:(i=[e,n[0]],r=[e,n[1]],o=OC(t),{x1:i[o=o||0],y1:i[1-o],x2:r[o],y2:r[1-o]})}},shadow:function(t,e,n){var i,r,o,a=Math.max(1,t.getBandWidth()),s=n[1]-n[0];return{type:"Rect",shape:(i=[e-a/2,n[0]],r=[a,s],o=OC(t),{x:i[o=o||0],y:i[1-o],width:r[o],height:r[1-o]})}}};function OC(t){return"x"===t.dim?0:1}var NC=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return _(e,t),e.type="axisPointer",e.defaultOption={show:"auto",z:50,type:"line",snap:!1,triggerTooltip:!0,triggerEmphasis:!0,value:null,status:null,link:[],animation:null,animationDurationUpdate:200,lineStyle:{color:Bf.color.border,width:1,type:"dashed"},shadowStyle:{color:Bf.color.shadowTint},label:{show:!0,formatter:null,precision:"auto",margin:3,color:Bf.color.neutral00,padding:[5,7,5,7],backgroundColor:Bf.color.accent60,borderColor:null,borderWidth:0,borderRadius:3},handle:{show:!1,icon:"M10.7,11.9v-1.3H9.3v1.3c-4.9,0.3-8.8,4.4-8.8,9.4c0,5,3.9,9.1,8.8,9.4h1.3c4.9-0.3,8.8-4.4,8.8-9.4C19.5,16.3,15.6,12.2,10.7,11.9z M13.3,24.4H6.7v-1.2h6.6z M13.3,22H6.7v-1.2h6.6z M13.3,19.6H6.7v-1.2h6.6z",size:45,margin:50,color:Bf.color.accent40,throttle:40}},e}(Hf),RC=Es(),HC=en;function BC(t,e,n){if(!Te.node){var i=e.getZr();RC(i).records||(RC(i).records={}),function(t,e){if(RC(t).initialized)return;function n(n,i){t.on(n,function(n){var r=function(t){var e={showTip:[],hideTip:[]},n=function(i){var r=e[i.type];r?r.push(i):(i.dispatchAction=n,t.dispatchAction(i))};return{dispatchAction:n,pendings:e}}(e);HC(RC(t).records,function(t){t&&i(t,n,r.dispatchAction)}),function(t,e){var n,i=t.showTip.length,r=t.hideTip.length;i?n=t.showTip[i-1]:r&&(n=t.hideTip[r-1]);n&&(n.dispatchAction=null,e.dispatchAction(n))}(r.pendings,e)})}RC(t).initialized=!0,n("click",ln($C,"click")),n("mousemove",ln($C,"mousemove")),n("globalout",FC)}(i,e),(RC(i).records[t]||(RC(i).records[t]={})).handler=n}}function FC(t,e,n){t.handler("leave",null,n)}function $C(t,e,n,i){e.handler(t,n,i)}function VC(t,e){if(!Te.node){var n=e.getZr();(RC(n).records||{})[t]&&(RC(n).records[t]=null)}}var WC=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return _(e,t),e.prototype.render=function(t,e,n){var i=e.getComponent("tooltip"),r=t.get("triggerOn")||i&&i.get("triggerOn")||"mousemove|click";BC("axisPointer",n,function(t,e,n){"none"!==r&&("leave"===t||r.indexOf(t)>=0)&&n({type:"updateAxisPointer",currTrigger:t,x:e&&e.offsetX,y:e&&e.offsetY})})},e.prototype.remove=function(t,e){VC("axisPointer",e)},e.prototype.dispose=function(t,e){VC("axisPointer",e)},e.type="axisPointer",e}(wm);function UC(t,e){var n,i=[],r=t.seriesIndex;if(null==r||!(n=e.getSeriesByIndex(r)))return{point:[]};var o=n.getData(),a=Ls(o,t);if(null==a||a<0||cn(a))return{point:[]};var s=o.getItemGraphicEl(a),l=n.coordinateSystem;if(n.getTooltipPosition)i=n.getTooltipPosition(a)||[];else if(l&&l.dataToPoint)if(t.isStacked){var c=l.getBaseAxis(),u=l.getOtherAxis(c).dim,h=c.dim,d="x"===u||"radius"===u?1:0,p=o.mapDimension(h),f=[];f[d]=o.get(p,a),f[1-d]=o.get(o.getCalculationInfo("stackResultDimension"),a),i=l.dataToPoint(f)||[]}else i=l.dataToPoint(o.getValues(nn(l.dimensions,function(t){return o.mapDimension(t)}),a))||[];else if(s){var g=s.getBoundingRect().clone();g.applyTransform(s.transform),i=[g.x+g.width/2,g.y+g.height/2]}return{point:i,el:s}}var GC=Es();function qC(t,e,n){var i=t.currTrigger,r=[t.x,t.y],o=t,a=t.dispatchAction||sn(n.dispatchAction,n),s=e.getComponent("axisPointer").coordSysAxesInfo;if(s){KC(r)&&(r=UC({seriesIndex:o.seriesIndex,dataIndex:o.dataIndex},e).point);var l=KC(r),c=o.axesInfo,u=s.axesInfo,h="leave"===i||KC(r),d={},p={},f={list:[],map:{}},g={showPointer:ln(XC,p),showTooltip:ln(YC,f)};en(s.coordSysMap,function(t,e){var n=l||t.containPoint(r);en(s.coordSysAxesInfo[e],function(t,e){var i=t.axis,o=function(t,e){for(var n=0;n<(t||[]).length;n++){var i=t[n];if(e.axis.dim===i.axisDim&&e.axis.model.componentIndex===i.axisIndex)return i}}(c,t);if(!h&&n&&(!c||o)){var a=o&&o.value;null!=a||l||(a=i.pointToData(r)),null!=a&&jC(t,a,g,!1,d)}})});var v={};return en(u,function(t,e){var n=t.linkGroup;n&&!p[e]&&en(n.axesInfo,function(e,i){var r=p[i];if(e!==t&&r){var o=r.value;n.mapper&&(o=t.axis.scale.parse(n.mapper(o,ZC(e),ZC(t)))),v[t.key]=o}})}),en(v,function(t,e){jC(u[e],t,g,!0,d)}),function(t,e,n){var i=n.axesInfo=[];en(e,function(e,n){var r=e.axisPointerModel.option,o=t[n];o?(!e.useHandle&&(r.status="show"),r.value=o.value,r.seriesDataIndices=(o.payloadBatch||[]).slice()):!e.useHandle&&(r.status="hide"),"show"===r.status&&i.push({axisDim:e.axis.dim,axisIndex:e.axis.model.componentIndex,value:r.value})})}(p,u,d),function(t,e,n,i){if(KC(e)||!t.list.length)return void i({type:"hideTip"});var r=((t.list[0].dataByAxis[0]||{}).seriesDataIndices||[])[0]||{};i({type:"showTip",escapeConnect:!0,x:e[0],y:e[1],tooltipOption:n.tooltipOption,position:n.position,dataIndexInside:r.dataIndexInside,dataIndex:r.dataIndex,seriesIndex:r.seriesIndex,dataByCoordSys:t.list})}(f,r,t,a),function(t,e,n){var i=n.getZr(),r="axisPointerLastHighlights",o=GC(i)[r]||{},a=GC(i)[r]={};en(t,function(t,e){var n=t.axisPointerModel.option;"show"===n.status&&t.triggerEmphasis&&en(n.seriesDataIndices,function(t){var e=t.seriesIndex+" | "+t.dataIndex;a[e]=t})});var s=[],l=[];en(o,function(t,e){!a[e]&&l.push(t)}),en(a,function(t,e){!o[e]&&s.push(t)}),l.length&&n.dispatchAction({type:"downplay",escapeConnect:!0,notBlur:!0,batch:l}),s.length&&n.dispatchAction({type:"highlight",escapeConnect:!0,notBlur:!0,batch:s})}(u,0,n),d}}function jC(t,e,n,i,r){var o=t.axis;if(!o.scale.isBlank()&&o.containData(e))if(t.involveSeries){var a=function(t,e){var n=e.axis,i=n.dim,r=t,o=[],a=Number.MAX_VALUE,s=-1;return en(e.seriesModels,function(e,l){var c,u,h=e.getData().mapDimensionsAll(i);if(e.getAxisTooltipData){var d=e.getAxisTooltipData(h,t,n);u=d.dataIndices,c=d.nestestValue}else{if(!(u=e.indicesOfNearest(i,h[0],t,"category"===n.type?.5:null)).length)return;c=e.getData().get(h[0],u[0])}if(null!=c&&isFinite(c)){var p=t-c,f=Math.abs(p);f<=a&&((f=0&&s<0)&&(a=f,s=p,r=c,o.length=0),en(u,function(t){o.push({seriesIndex:e.seriesIndex,dataIndexInside:t,dataIndex:e.getData().getRawIndex(t)})}))}}),{payloadBatch:o,snapToValue:r}}(e,t),s=a.payloadBatch,l=a.snapToValue;s[0]&&null==r.seriesIndex&&Ze(r,s[0]),!i&&t.snap&&o.containData(l)&&null!=l&&(e=l),n.showPointer(t,e,s),n.showTooltip(t,a,l)}else n.showPointer(t,e)}function XC(t,e,n,i){t[e.key]={value:n,payloadBatch:i}}function YC(t,e,n,i){var r=n.payloadBatch,o=e.axis,a=o.model,s=e.axisPointerModel;if(e.triggerTooltip&&r.length){var l=e.coordSys.model,c=lC(l),u=t.map[c];u||(u=t.map[c]={coordSysId:l.id,coordSysIndex:l.componentIndex,coordSysType:l.type,coordSysMainType:l.mainType,dataByAxis:[]},t.list.push(u)),u.dataByAxis.push({axisDim:o.dim,axisIndex:a.componentIndex,axisType:a.type,axisId:a.id,value:i,valueLabelOpt:{precision:s.get(["label","precision"]),formatter:s.get(["label","formatter"])},seriesDataIndices:r.slice()})}}function ZC(t){var e=t.axis.model,n={},i=n.axisDim=t.axis.dim;return n.axisIndex=n[i+"AxisIndex"]=e.componentIndex,n.axisName=n[i+"AxisName"]=e.name,n.axisId=n[i+"AxisId"]=e.id,n}function KC(t){return!t||null==t[0]||isNaN(t[0])||null==t[1]||isNaN(t[1])}function QC(t){uC.registerAxisPointerClass("CartesianAxisPointer",LC),t.registerComponentModel(NC),t.registerComponentView(WC),t.registerPreprocessor(function(t){if(t){(!t.axisPointer||0===t.axisPointer.length)&&(t.axisPointer={});var e=t.axisPointer.link;e&&!cn(e)&&(t.axisPointer.link=[e])}}),t.registerProcessor(t.PRIORITY.PROCESSOR.STATISTIC,function(t,e){t.getComponent("axisPointer").coordSysAxesInfo=rC(t,e)}),t.registerAction({type:"updateAxisPointer",event:"updateAxisPointer",update:":updateAxisPointer"},qC)}var JC=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return _(e,t),e.type="tooltip",e.dependencies=["axisPointer"],e.defaultOption={z:60,show:!0,showContent:!0,trigger:"item",triggerOn:"mousemove|click",alwaysShowContent:!1,renderMode:"auto",confine:null,showDelay:0,hideDelay:100,transitionDuration:.4,displayTransition:!0,enterable:!1,backgroundColor:Bf.color.neutral00,shadowBlur:10,shadowColor:"rgba(0, 0, 0, .2)",shadowOffsetX:1,shadowOffsetY:2,borderRadius:4,borderWidth:1,defaultBorderColor:Bf.color.border,padding:null,extraCssText:"",axisPointer:{type:"line",axis:"auto",animation:"auto",animationDurationUpdate:200,animationEasingUpdate:"exponentialOut",crossStyle:{color:Bf.color.borderShade,width:1,type:"dashed",textStyle:{}}},textStyle:{color:Bf.color.tertiary,fontSize:14}},e}(Hf);function tM(t){var e=t.get("confine");return null!=e?!!e:"richText"===t.get("renderMode")}function eM(t){if(Te.domSupported)for(var e=document.documentElement.style,n=0,i=t.length;n0&&r.push(function(t,e,n){var i="cubic-bezier(0.23,1,0.32,1)",r="",o="";return n&&(o="opacity"+(r=" "+t/2+"s "+i)+",visibility"+r),e||(r=" "+t+"s "+i,o+=(o.length?",":"")+(Te.transformSupported?""+oM+r:",left"+r+",top"+r)),rM+":"+o}(o,n,i)),a&&r.push("background-color:"+a),en(["width","color","radius"],function(e){var n="border-"+e,i=uf(n),o=t.get(i);null!=o&&r.push(n+":"+o+("color"===e?"":"px"))}),r.push(function(t){var e=[],n=t.get("fontSize"),i=t.getTextColor();i&&e.push("color:"+i),e.push("font:"+t.getFont());var r=bn(t.get("lineHeight"),Math.round(3*n/2));n&&e.push("line-height:"+r+"px");var o=t.get("textShadowColor"),a=t.get("textShadowBlur")||0,s=t.get("textShadowOffsetX")||0,l=t.get("textShadowOffsetY")||0;return o&&a&&e.push("text-shadow:"+s+"px "+l+"px "+a+"px "+o),en(["decoration","align"],function(n){var i=t.get(n);i&&e.push("text-"+n+":"+i)}),e.join(";")}(h)),null!=d&&r.push("padding:"+hf(d).join("px ")+"px"),r.join(";")+";"}function cM(t,e,n,i,r){var o=e&&e.painter;if(n){var a=o&&o.getViewportRoot();a&&function(t,e,n,i,r){ri(ii,e,i,r,!0)&&ri(t,n,ii[0],ii[1])}(t,a,n,i,r)}else{t[0]=i,t[1]=r;var s=o&&o.getViewportRootOffset();s&&(t[0]+=s.offsetLeft,t[1]+=s.offsetTop)}t[2]=t[0]/e.getWidth(),t[3]=t[1]/e.getHeight()}var uM=function(){function t(t,e){if(this._show=!1,this._styleCoord=[0,0,0,0],this._enterable=!0,this._alwaysShowContent=!1,this._firstShow=!0,this._longHide=!0,Te.wxa)return null;var n=document.createElement("div");n.domBelongToZr=!0,this.el=n;var i=this._zr=t.getZr(),r=e.appendTo,o=r&&(hn(r)?document.querySelector(r):mn(r)?r:un(r)&&r(t.getDom()));cM(this._styleCoord,i,o,t.getWidth()/2,t.getHeight()/2),(o||t.getDom()).appendChild(n),this._api=t,this._container=o;var a=this;n.onmouseenter=function(){a._enterable&&(clearTimeout(a._hideTimeout),a._show=!0),a._inContent=!0},n.onmousemove=function(t){if(t=t||window.event,!a._enterable){var e=i.handler;gi(i.painter.getViewportRoot(),t,!0),e.dispatch("mousemove",t)}},n.onmouseleave=function(){a._inContent=!1,a._enterable&&a._show&&a.hideLater(a._hideDelay)}}return t.prototype.update=function(t){if(!this._container){var e=this._api.getDom(),n=(o="position",(a=(r=e).currentStyle||document.defaultView&&document.defaultView.getComputedStyle(r))?a[o]:null),i=e.style;"absolute"!==i.position&&"absolute"!==n&&(i.position="relative")}var r,o,a,s=t.get("alwaysShowContent");s&&this._moveIfResized(),this._alwaysShowContent=s,this._enableDisplayTransition=t.get("displayTransition")&&t.get("transitionDuration")>0,this.el.className=t.get("className")||""},t.prototype.show=function(t,e){clearTimeout(this._hideTimeout),clearTimeout(this._longHideTimeout);var n=this.el,i=n.style,r=this._styleCoord;n.innerHTML?i.cssText=aM+lM(t,!this._firstShow,this._longHide,this._enableDisplayTransition)+sM(r[0],r[1],!0)+"border-color:"+vf(e)+";"+(t.get("extraCssText")||"")+";pointer-events:"+(this._enterable?"auto":"none"):i.display="none",this._show=!0,this._firstShow=!1,this._longHide=!1},t.prototype.setContent=function(t,e,n,i,r){var o=this.el;if(null!=t){var a="";if(hn(r)&&"item"===n.get("trigger")&&!tM(n)&&(a=function(t,e,n){if(!hn(n)||"inside"===n)return"";var i=t.get("backgroundColor"),r=t.get("borderWidth");e=vf(e);var o,a,s="left"===(o=n)?"right":"right"===o?"left":"top"===o?"bottom":"top",l=Math.max(1.5*Math.round(r),6),c="",u=oM+":";Qe(["left","right"],s)>-1?(c+="top:50%",u+="translateY(-50%) rotate("+(a="left"===s?-225:-45)+"deg)"):(c+="left:50%",u+="translateX(-50%) rotate("+(a="top"===s?225:45)+"deg)");var h=a*Math.PI/180,d=l+r,p=d*Math.abs(Math.cos(h))+d*Math.abs(Math.sin(h)),f=e+" solid "+r+"px;";return'
'}(n,i,r)),hn(t))o.innerHTML=t+a;else if(t){o.innerHTML="",cn(t)||(t=[t]);for(var s=0;s=0?this._tryShow(n,i):"leave"===e&&this._hide(i))},this))},e.prototype._keepShow=function(){var t=this._tooltipModel,e=this._ecModel,n=this._api,i=t.get("triggerOn");if(null!=this._lastX&&null!=this._lastY&&"none"!==i&&"click"!==i){var r=this;clearTimeout(this._refreshUpdateTimeout),this._refreshUpdateTimeout=setTimeout(function(){!n.isDisposed()&&r.manuallyShowTip(t,e,n,{x:r._lastX,y:r._lastY,dataByCoordSys:r._lastDataByCoordSys})})}},e.prototype.manuallyShowTip=function(t,e,n,i){if(i.from!==this.uid&&!Te.node&&n.getDom()){var r=yM(i,n);this._ticket="";var o=i.dataByCoordSys,a=function(t,e,n){var i=Ns(t).queryOptionMap,r=i.keys()[0];if(!r||"series"===r)return;var o=Hs(e,r,i.get(r),{useDefault:!1,enableAll:!1,enableNone:!1}),a=o.models[0];if(!a)return;var s,l=n.getViewOfComponentModel(a);if(l.group.traverse(function(e){var n=nu(e).tooltipConfig;if(n&&n.name===t.name)return s=e,!0}),s)return{componentMainType:r,componentIndex:a.componentIndex,el:s}}(i,e,n);if(a){var s=a.el.getBoundingRect().clone();s.applyTransform(a.el.transform),this._tryShow({offsetX:s.x+s.width/2,offsetY:s.y+s.height/2,target:a.el,position:i.position,positionDefault:"bottom"},r)}else if(i.tooltip&&null!=i.x&&null!=i.y){var l=gM;l.x=i.x,l.y=i.y,l.update(),nu(l).tooltipConfig={name:null,option:i.tooltip},this._tryShow({offsetX:i.x,offsetY:i.y,target:l},r)}else if(o)this._tryShow({offsetX:i.x,offsetY:i.y,position:i.position,dataByCoordSys:o,tooltipOption:i.tooltipOption},r);else if(null!=i.seriesIndex){if(this._manuallyAxisShowTip(t,e,n,i))return;var c=UC(i,e),u=c.point[0],h=c.point[1];null!=u&&null!=h&&this._tryShow({offsetX:u,offsetY:h,target:c.el,position:i.position,positionDefault:"bottom"},r)}else null!=i.x&&null!=i.y&&(n.dispatchAction({type:"updateAxisPointer",x:i.x,y:i.y}),this._tryShow({offsetX:i.x,offsetY:i.y,position:i.position,target:n.getZr().findHover(i.x,i.y).target},r))}},e.prototype.manuallyHideTip=function(t,e,n,i){var r=this._tooltipContent;this._tooltipModel&&r.hideLater(this._tooltipModel.get("hideDelay")),this._lastX=this._lastY=this._lastDataByCoordSys=null,i.from!==this.uid&&this._hide(yM(i,n))},e.prototype._manuallyAxisShowTip=function(t,e,n,i){var r=i.seriesIndex,o=i.dataIndex,a=e.getComponent("axisPointer").coordSysAxesInfo;if(null!=r&&null!=o&&null!=a){var s=e.getSeriesByIndex(r);if(s)if("axis"===mM([s.getData().getItemModel(o),s,(s.coordinateSystem||{}).model],this._tooltipModel).get("trigger"))return n.dispatchAction({type:"updateAxisPointer",seriesIndex:r,dataIndex:o,position:i.position}),!0}},e.prototype._tryShow=function(t,e){var n=t.target;if(this._tooltipModel){this._lastX=t.offsetX,this._lastY=t.offsetY;var i=t.dataByCoordSys;if(i&&i.length)this._showAxisTooltip(i,t);else if(n){var r,o;if("legend"===nu(n).ssrType)return;this._lastDataByCoordSys=null,by(n,function(t){if(t.tooltipDisabled)return r=o=null,!0;r||o||(null!=nu(t).dataIndex?r=t:null!=nu(t).tooltipConfig&&(o=t))},!0),r?this._showSeriesItemTooltip(t,r,e):o?this._showComponentItemTooltip(t,o,e):this._hide(e)}else this._lastDataByCoordSys=null,this._hide(e)}},e.prototype._showOrMove=function(t,e){var n=t.get("showDelay");e=sn(e,this),clearTimeout(this._showTimout),n>0?this._showTimout=setTimeout(e,n):e()},e.prototype._showAxisTooltip=function(t,e){var n=this._ecModel,i=this._tooltipModel,r=[e.offsetX,e.offsetY],o=mM([e.tooltipOption],i),a=this._renderMode,s=[],l=tm("section",{blocks:[],noHeader:!0}),c=[],u=new um;en(t,function(t){en(t.dataByAxis,function(t){var e=n.getComponent(t.axisDim+"Axis",t.axisIndex),r=t.value;if(e&&null!=r){var o=AC(r,e.axis,n,t.seriesDataIndices,t.valueLabelOpt),h=tm("section",{header:o,noHeader:!kn(o),sortBlocks:!0,blocks:[]});l.blocks.push(h),en(t.seriesDataIndices,function(l){var d=n.getSeriesByIndex(l.seriesIndex),p=l.dataIndexInside,f=d.getDataParams(p);if(!(f.dataIndex<0)){f.axisDim=t.axisDim,f.axisIndex=t.axisIndex,f.axisType=t.axisType,f.axisId=t.axisId,f.axisValue=Vb(e.axis,{value:r}),f.axisValueLabel=o,f.marker=u.makeTooltipMarker("item",vf(f.color),a);var g=bv(d.formatTooltip(p,!0,null)),v=g.frag;if(v){var m=mM([d],i).get("valueFormatter");h.blocks.push(m?Ze({valueFormatter:m},v):v)}g.text&&c.push(g.text),s.push(f)}})}})}),l.blocks.reverse(),c.reverse();var h=e.position,d=o.get("order"),p=am(l,u,a,d,n.get("useUTC"),o.get("textStyle"));p&&c.unshift(p);var f="richText"===a?"\n\n":"
",g=c.join(f);this._showOrMove(o,function(){this._updateContentNotChangedOnAxis(t,s)?this._updatePosition(o,h,r[0],r[1],this._tooltipContent,s):this._showTooltipContent(o,g,s,Math.random()+"",r[0],r[1],h,null,u)})},e.prototype._showSeriesItemTooltip=function(t,e,n){var i=this._ecModel,r=nu(e),o=r.seriesIndex,a=i.getSeriesByIndex(o),s=r.dataModel||a,l=r.dataIndex,c=r.dataType,u=s.getData(c),h=this._renderMode,d=t.positionDefault,p=mM([u.getItemModel(l),s,a&&(a.coordinateSystem||{}).model],this._tooltipModel,d?{position:d}:null),f=p.get("trigger");if(null==f||"item"===f){var g=s.getDataParams(l,c),v=new um;g.marker=v.makeTooltipMarker("item",vf(g.color),h);var m=bv(s.formatTooltip(l,!1,c)),y=p.get("order"),_=p.get("valueFormatter"),x=m.frag,b=x?am(_?Ze({valueFormatter:_},x):x,v,h,y,i.get("useUTC"),p.get("textStyle")):m.text,w="item_"+s.name+"_"+l;this._showOrMove(p,function(){this._showTooltipContent(p,b,g,w,t.offsetX,t.offsetY,t.position,t.target,v)}),n({type:"showTip",dataIndexInside:l,dataIndex:u.getRawIndex(l),seriesIndex:o,from:this.uid})}},e.prototype._showComponentItemTooltip=function(t,e,n){var i="html"===this._renderMode,r=nu(e),o=r.tooltipConfig.option||{},a=o.encodeHTMLContent;if(hn(o)){o={content:o,formatter:o},a=!0}a&&i&&o.content&&((o=Xe(o)).content=li(o.content));var s=[o],l=this._ecModel.getComponent(r.componentMainType,r.componentIndex);l&&s.push(l),s.push({formatter:o.content});var c=t.positionDefault,u=mM(s,this._tooltipModel,c?{position:c}:null),h=u.get("content"),d=Math.random()+"",p=new um;this._showOrMove(u,function(){var n=Xe(u.get("formatterParams")||{});this._showTooltipContent(u,h,n,d,t.offsetX,t.offsetY,t.position,e,p)}),n({type:"showTip",from:this.uid})},e.prototype._showTooltipContent=function(t,e,n,i,r,o,a,s,l){if(this._ticket="",t.get("showContent")&&t.get("show")){var c=this._tooltipContent;c.setEnterable(t.get("enterable"));var u=t.get("formatter");a=a||t.get("position");var h=e,d=this._getNearestPoint([r,o],n,t.get("trigger"),t.get("borderColor"),t.get("defaultBorderColor",!0)).color;if(u)if(hn(u)){var p=t.ecModel.get("useUTC"),f=cn(n)?n[0]:n;h=u,f&&f.axisType&&f.axisType.indexOf("time")>=0&&(h=Gp(f.axisValue,h,p)),h=gf(h,n,!0)}else if(un(u)){var g=sn(function(e,i){e===this._ticket&&(c.setContent(i,l,t,d,a),this._updatePosition(t,a,r,o,c,n,s))},this);this._ticket=i,h=u(n,i,g)}else h=u;c.setContent(h,l,t,d,a),c.show(t,d),this._updatePosition(t,a,r,o,c,n,s)}},e.prototype._getNearestPoint=function(t,e,n,i,r){return"axis"===n||cn(e)?{color:i||r}:cn(e)?void 0:{color:i||e.color||e.borderColor}},e.prototype._updatePosition=function(t,e,n,i,r,o,a){var s=this._api.getWidth(),l=this._api.getHeight();e=e||t.get("position");var c=r.getSize(),u=t.get("align"),h=t.get("verticalAlign"),d=a&&a.getBoundingRect().clone();if(a&&d.applyTransform(a.transform),un(e)&&(e=e([n,i],o,r.el,d,{viewSize:[s,l],contentSize:c.slice()})),cn(e))n=es(e[0],s),i=es(e[1],l);else if(fn(e)){var p=e;p.width=c[0],p.height=c[1];var f=Pf(p,{width:s,height:l});n=f.x,i=f.y,u=null,h=null}else if(hn(e)&&a){var g=function(t,e,n,i){var r=n[0],o=n[1],a=Math.ceil(Math.SQRT2*i)+8,s=0,l=0,c=e.width,u=e.height;switch(t){case"inside":s=e.x+c/2-r/2,l=e.y+u/2-o/2;break;case"top":s=e.x+c/2-r/2,l=e.y-o-a;break;case"bottom":s=e.x+c/2-r/2,l=e.y+u+a;break;case"left":s=e.x-r-a,l=e.y+u/2-o/2;break;case"right":s=e.x+c+a,l=e.y+u/2-o/2}return[s,l]}(e,d,c,t.get("borderWidth"));n=g[0],i=g[1]}else{g=function(t,e,n,i,r,o,a){var s=n.getSize(),l=s[0],c=s[1];null!=o&&(t+l+o+2>i?t-=l+o:t+=o);null!=a&&(e+c+a>r?e-=c+a:e+=a);return[t,e]}(n,i,r,s,l,u?null:20,h?null:20);n=g[0],i=g[1]}if(u&&(n-=_M(u)?c[0]/2:"right"===u?c[0]:0),h&&(i-=_M(h)?c[1]/2:"bottom"===h?c[1]:0),tM(t)){g=function(t,e,n,i,r){var o=n.getSize(),a=o[0],s=o[1];return t=Math.min(t+a,i)-a,e=Math.min(e+s,r)-s,t=Math.max(t,0),e=Math.max(e,0),[t,e]}(n,i,r,s,l);n=g[0],i=g[1]}r.moveTo(n,i)},e.prototype._updateContentNotChangedOnAxis=function(t,e){var n=this._lastDataByCoordSys,i=this._cbParamsList,r=!!n&&n.length===t.length;return r&&en(n,function(n,o){var a=n.dataByAxis||[],s=(t[o]||{}).dataByAxis||[];(r=r&&a.length===s.length)&&en(a,function(t,n){var o=s[n]||{},a=t.seriesDataIndices||[],l=o.seriesDataIndices||[];(r=r&&t.value===o.value&&t.axisType===o.axisType&&t.axisId===o.axisId&&a.length===l.length)&&en(a,function(t,e){var n=l[e];r=r&&t.seriesIndex===n.seriesIndex&&t.dataIndex===n.dataIndex}),i&&en(t.seriesDataIndices,function(t){var n=t.seriesIndex,o=e[n],a=i[n];o&&a&&a.data!==o.data&&(r=!1)})})}),this._lastDataByCoordSys=t,this._cbParamsList=e,!!r},e.prototype._hide=function(t){this._lastDataByCoordSys=null,t({type:"hideTip",from:this.uid})},e.prototype.dispose=function(t,e){!Te.node&&e.getDom()&&(Rm(this,"_updatePosition"),this._tooltipContent.dispose(),VC("itemTooltip",e))},e.type="tooltip",e}(wm);function mM(t,e,n){var i,r=e.ecModel;n?(i=new _p(n,r,r),i=new _p(e.option,i,r)):i=e;for(var o=t.length-1;o>=0;o--){var a=t[o];a&&(a instanceof _p&&(a=a.get("tooltip",!0)),hn(a)&&(a={formatter:a}),a&&(i=new _p(a,i,r)))}return i}function yM(t,e){return t.dispatchAction||sn(e.dispatchAction,e)}function _M(t){return"center"===t||"middle"===t}var xM=Math.sin,bM=Math.cos,wM=Math.PI,SM=2*Math.PI,CM=180/wM,MM=function(){function t(){}return t.prototype.reset=function(t){this._start=!0,this._d=[],this._str="",this._p=Math.pow(10,t||4)},t.prototype.moveTo=function(t,e){this._add("M",t,e)},t.prototype.lineTo=function(t,e){this._add("L",t,e)},t.prototype.bezierCurveTo=function(t,e,n,i,r,o){this._add("C",t,e,n,i,r,o)},t.prototype.quadraticCurveTo=function(t,e,n,i){this._add("Q",t,e,n,i)},t.prototype.arc=function(t,e,n,i,r,o){this.ellipse(t,e,n,n,0,i,r,o)},t.prototype.ellipse=function(t,e,n,i,r,o,a,s){var l=a-o,c=!s,u=Math.abs(l),h=ho(u-SM)||(c?l>=SM:-l>=SM),d=l>0?l%SM:l%SM+SM,p=!1;p=!!h||!ho(u)&&d>=wM==!!c;var f=t+n*bM(o),g=e+i*xM(o);this._start&&this._add("M",f,g);var v=Math.round(r*CM);if(h){var m=1/this._p,y=(c?1:-1)*(SM-m);this._add("A",n,i,v,1,+c,t+n*bM(o+y),e+i*xM(o+y)),m>.01&&this._add("A",n,i,v,0,+c,f,g)}else{var _=t+n*bM(a),x=e+i*xM(a);this._add("A",n,i,v,+p,+c,_,x)}},t.prototype.rect=function(t,e,n,i){this._add("M",t,e),this._add("l",n,0),this._add("l",0,i),this._add("l",-n,0),this._add("Z")},t.prototype.closePath=function(){this._d.length>0&&this._add("Z")},t.prototype._add=function(t,e,n,i,r,o,a,s,l){for(var c=[],u=this._p,h=1;h"}(r,o)+("style"!==r?li(a):a||"")+(i?""+n+nn(i,function(e){return t(e)}).join(n)+n:"")+("")}(t)}function RM(t){return{zrId:t,shadowCache:{},patternCache:{},gradientCache:{},clipPathCache:{},defs:{},cssNodes:{},cssAnims:{},cssStyleCache:{},cssAnimIdx:0,shadowIdx:0,gradientIdx:0,patternIdx:0,clipPathIdx:0}}function HM(t,e,n,i){return OM("svg","root",{width:t,height:e,xmlns:PM,"xmlns:xlink":LM,version:"1.1",baseProfile:"full",viewBox:!!i&&"0 0 "+t+" "+e},n)}var BM=0;function FM(){return BM++}var $M={cubicIn:"0.32,0,0.67,0",cubicOut:"0.33,1,0.68,1",cubicInOut:"0.65,0,0.35,1",quadraticIn:"0.11,0,0.5,0",quadraticOut:"0.5,1,0.89,1",quadraticInOut:"0.45,0,0.55,1",quarticIn:"0.5,0,0.75,0",quarticOut:"0.25,1,0.5,1",quarticInOut:"0.76,0,0.24,1",quinticIn:"0.64,0,0.78,0",quinticOut:"0.22,1,0.36,1",quinticInOut:"0.83,0,0.17,1",sinusoidalIn:"0.12,0,0.39,0",sinusoidalOut:"0.61,1,0.88,1",sinusoidalInOut:"0.37,0,0.63,1",exponentialIn:"0.7,0,0.84,0",exponentialOut:"0.16,1,0.3,1",exponentialInOut:"0.87,0,0.13,1",circularIn:"0.55,0,1,0.45",circularOut:"0,0.55,0.45,1",circularInOut:"0.85,0,0.15,1"},VM="transform-origin";function WM(t,e,n){var i=Ze({},t.shape);Ze(i,e),t.buildPath(n,i);var r=new MM;return r.reset(wo(t)),n.rebuildPath(r,1),r.generateStr(),r.getStr()}function UM(t,e){var n=e.originX,i=e.originY;(n||i)&&(t[VM]=n+"px "+i+"px")}var GM={fill:"fill",opacity:"opacity",lineWidth:"stroke-width",lineDashOffset:"stroke-dashoffset"};function qM(t,e){var n=e.zrId+"-ani-"+e.cssAnimIdx++;return e.cssAnims[n]=t,n}function jM(t){return hn(t)?$M[t]?"cubic-bezier("+$M[t]+")":Rr(t)?t:"":""}function XM(t,e,n,i){var r=t.animators,o=r.length,a=[];if(t instanceof Yh){var s=function(t,e,n){var i,r,o=t.shape.paths,a={};if(en(o,function(t){var e=RM(n.zrId);e.animation=!0,XM(t,{},e,!0);var o=e.cssAnims,s=e.cssNodes,l=an(o),c=l.length;if(c){var u=o[r=l[c-1]];for(var h in u){var d=u[h];a[h]=a[h]||{d:""},a[h].d+=d.d||""}for(var p in s){var f=s[p].animation;f.indexOf(r)>=0&&(i=f)}}}),i){e.d=!1;var s=qM(a,n);return i.replace(r,s)}}(t,e,n);if(s)a.push(s);else if(!o)return}else if(!o)return;for(var l={},c=0;c0}).length)return qM(u,n)+" "+r[0]+" both"}for(var v in l){(s=g(l[v]))&&a.push(s)}if(a.length){var m=n.zrId+"-cls-"+FM();n.cssNodes["."+m]={animation:a.join(",")},e.class=m}}function YM(t,e,n,i){var r=JSON.stringify(t),o=n.cssStyleCache[r];o||(o=n.zrId+"-cls-"+FM(),n.cssStyleCache[r]=o,n.cssNodes["."+o+":hover"]=t),e.class=e.class?e.class+" "+o:o}var ZM=Math.round;function KM(t){return t&&hn(t.src)}function QM(t){return t&&un(t.toDataURL)}function JM(t,e,n,i){AM(function(r,o){var a="fill"===r||"stroke"===r;a&&xo(o)?uk(e,t,r,i):a&&mo(o)?hk(n,t,r,i):t[r]=o,a&&i.ssr&&"none"===o&&(t["pointer-events"]="visible")},e,n,!1),function(t,e,n){var i=t.style;if(function(t){return t&&(t.shadowBlur||t.shadowOffsetX||t.shadowOffsetY)}(i)){var r=function(t){var e=t.style,n=t.getGlobalScale();return[e.shadowColor,(e.shadowBlur||0).toFixed(2),(e.shadowOffsetX||0).toFixed(2),(e.shadowOffsetY||0).toFixed(2),n[0],n[1]].join(",")}(t),o=n.shadowCache,a=o[r];if(!a){var s=t.getGlobalScale(),l=s[0],c=s[1];if(!l||!c)return;var u=i.shadowOffsetX||0,h=i.shadowOffsetY||0,d=i.shadowBlur,p=co(i.shadowColor),f=p.opacity,g=p.color,v=d/2/l+" "+d/2/c;a=n.zrId+"-s"+n.shadowIdx++,n.defs[a]=OM("filter",a,{id:a,x:"-100%",y:"-100%",width:"300%",height:"300%"},[OM("feDropShadow","",{dx:u/l,dy:h/c,stdDeviation:v,"flood-color":g,"flood-opacity":f})]),o[r]=a}e.filter=bo(a)}}(n,t,i)}function tk(t,e){var n=function(t){if("function"==typeof ja)return ja(t)}(e);n&&(n.each(function(e,n){null!=e&&(t[(EM+n).toLowerCase()]=e+"")}),e.isSilent()&&(t[EM+"silent"]="true"))}function ek(t){return ho(t[0]-1)&&ho(t[1])&&ho(t[2])&&ho(t[3]-1)}function nk(t,e,n){if(e&&(!function(t){return ho(t[4])&&ho(t[5])}(e)||!ek(e))){var i=1e4;t.transform=ek(e)?"translate("+ZM(e[4]*i)/i+" "+ZM(e[5]*i)/i+")":function(t){return"matrix("+po(t[0])+","+po(t[1])+","+po(t[2])+","+po(t[3])+","+fo(t[4])+","+fo(t[5])+")"}(e)}}function ik(t,e,n){for(var i=t.points,r=[],o=0;o=0&&a||o;s&&(r=so(s))}var l=i.lineWidth;l&&(l/=!i.strokeNoScale&&t.transform?t.transform[0]:1);var c={cursor:"pointer"};r&&(c.fill=r),i.stroke&&(c.stroke=i.stroke),l&&(c["stroke-width"]=l),YM(c,e,n)}}(t,o,e),OM(s,t.id+"",o)}function ck(t,e){return t instanceof Tc?lk(t,e):t instanceof Lc?function(t,e){var n=t.style,i=n.image;if(i&&!hn(i)&&(KM(i)?i=i.src:QM(i)&&(i=i.toDataURL())),i){var r=n.x||0,o=n.y||0,a={href:i,width:n.width,height:n.height};return r&&(a.x=r),o&&(a.y=o),nk(a,t.transform),JM(a,n,t,e),tk(a,t),e.animation&&XM(t,a,e),OM("image",t.id+"",a)}}(t,e):t instanceof Ic?function(t,e){var n=t.style,i=n.text;if(null!=i&&(i+=""),i&&!isNaN(n.x)&&!isNaN(n.y)){var r=n.font||Ie,o=n.x||0,a=function(t,e,n){return"top"===n?t+=e/2:"bottom"===n&&(t-=e/2),t}(n.y||0,Aa(r),n.textBaseline),s={"dominant-baseline":"central","text-anchor":go[n.textAlign]||n.textAlign};if(Yc(n)){var l="",c=n.fontStyle,u=jc(n.fontSize);if(!parseFloat(u))return;var h=n.fontFamily||De,d=n.fontWeight;l+="font-size:"+u+";font-family:"+h+";",c&&"normal"!==c&&(l+="font-style:"+c+";"),d&&"normal"!==d&&(l+="font-weight:"+d+";"),s.style=l}else s.style="font: "+r;return i.match(/\s/)&&(s["xml:space"]="preserve"),o&&(s.x=o),a&&(s.y=a),nk(s,t.transform),JM(s,n,t,e),tk(s,t),e.animation&&XM(t,s,e),OM("text",t.id+"",s,void 0,i)}}(t,e):void 0}function uk(t,e,n,i){var r,o=t[n],a={gradientUnits:o.global?"userSpaceOnUse":"objectBoundingBox"};if(yo(o))r="linearGradient",a.x1=o.x,a.y1=o.y,a.x2=o.x2,a.y2=o.y2;else{if(!_o(o))return;r="radialGradient",a.cx=bn(o.x,.5),a.cy=bn(o.y,.5),a.r=bn(o.r,.5)}for(var s=o.colorStops,l=[],c=0,u=s.length;cl?kk(t,null==n[h+1]?null:n[h+1].elm,n,s,h):Tk(t,e,a,l))}(n,i,r):wk(r)?(wk(t.text)&&_k(n,""),kk(n,null,r,0,r.length-1)):wk(i)?Tk(n,i,0,i.length-1):wk(t.text)&&_k(n,""):t.text!==e.text&&(wk(i)&&Tk(n,i,0,i.length-1),_k(n,e.text)))}var Ak=0,Pk=function(){function t(t,e,n){if(this.type="svg",this.refreshHover=function(){},this.configLayer=function(){},this.storage=e,this._opts=n=Ze({},n),this.root=t,this._id="zr"+Ak++,this._oldVNode=HM(n.width,n.height),t&&!n.ssr){var i=this._viewport=document.createElement("div");i.style.cssText="position:relative;overflow:hidden";var r=this._svgDom=this._oldVNode.elm=zM("svg");Dk(null,this._oldVNode),i.appendChild(r),t.appendChild(i)}this.resize(n.width,n.height)}return t.prototype.getType=function(){return this.type},t.prototype.getViewportRoot=function(){return this._viewport},t.prototype.getViewportRootOffset=function(){var t=this.getViewportRoot();if(t)return{offsetLeft:t.offsetLeft||0,offsetTop:t.offsetTop||0}},t.prototype.getSvgDom=function(){return this._svgDom},t.prototype.refresh=function(){if(this.root){var t=this.renderToVNode({willUpdate:!0});t.attrs.style="position:absolute;left:0;top:0;user-select:none",function(t,e){if(Ck(t,e))Ik(t,e);else{var n=t.elm,i=mk(n);Mk(e),null!==i&&(fk(i,e.elm,yk(n)),Tk(i,[t],0,0))}}(this._oldVNode,t),this._oldVNode=t}},t.prototype.renderOneToVNode=function(t){return ck(t,RM(this._id))},t.prototype.renderToVNode=function(t){t=t||{};var e=this.storage.getDisplayList(!0),n=this._width,i=this._height,r=RM(this._id);r.animation=t.animation,r.willUpdate=t.willUpdate,r.compress=t.compress,r.emphasis=t.emphasis,r.ssr=this._opts.ssr;var o=[],a=this._bgVNode=function(t,e,n,i){var r;if(n&&"none"!==n)if(r=OM("rect","bg",{width:t,height:e,x:"0",y:"0"}),xo(n))uk({fill:n},r.attrs,"fill",i);else if(mo(n))hk({style:{fill:n},dirty:Nn,getBoundingRect:function(){return{width:t,height:e}}},r.attrs,"fill",i);else{var o=co(n),a=o.color,s=o.opacity;r.attrs.fill=a,s<1&&(r.attrs["fill-opacity"]=s)}return r}(n,i,this._backgroundColor,r);a&&o.push(a);var s=t.compress?null:this._mainVNode=OM("g","main",{},[]);this._paintList(e,r,s?s.children:o),s&&o.push(s);var l=nn(an(r.defs),function(t){return r.defs[t]});if(l.length&&o.push(OM("defs","defs",{},l)),t.animation){var c=function(t,e,n){var i=(n=n||{}).newline?"\n":"",r=" {"+i,o=i+"}",a=nn(an(t),function(e){return e+r+nn(an(t[e]),function(n){return n+":"+t[e][n]+";"}).join(i)+o}).join(i),s=nn(an(e),function(t){return"@keyframes "+t+r+nn(an(e[t]),function(n){return n+r+nn(an(e[t][n]),function(i){var r=e[t][n][i];return"d"===i&&(r='path("'+r+'")'),i+":"+r+";"}).join(i)+o}).join(i)+o}).join(i);return a||s?[""].join(i):""}(r.cssNodes,r.cssAnims,{newline:!0});if(c){var u=OM("style","stl",{},[],c);o.push(u)}}return HM(n,i,o,t.useViewBox)},t.prototype.renderToString=function(t){return t=t||{},NM(this.renderToVNode({animation:bn(t.cssAnimation,!0),emphasis:bn(t.cssEmphasis,!0),willUpdate:!1,compress:!0,useViewBox:bn(t.useViewBox,!0)}),{newline:!0})},t.prototype.setBackgroundColor=function(t){this._backgroundColor=t},t.prototype.getSvgRoot=function(){return this._mainVNode&&this._mainVNode.elm},t.prototype._paintList=function(t,e,n){for(var i,r,o=t.length,a=[],s=0,l=0,c=0;c=0&&(!h||!r||h[f]!==r[f]);f--);for(var g=p-1;g>f;g--)i=a[--s-1];for(var v=f+1;v10&&"cartesian2d"===o.type&&r){var s=o.getBaseAxis(),l=o.getOtherAxis(s),c=s.getExtent(),u=n.getDevicePixelRatio(),h=Math.abs(c[1]-c[0])*(u||1),d=Math.round(a/h);if(isFinite(d)&&d>1){"lttb"===r?t.setData(i.lttbDownSample(i.mapDimension(l.dim),1/d)):"minmax"===r&&t.setData(i.minmaxDownSample(i.mapDimension(l.dim),1/d));var p=void 0;hn(r)?p=rS[r]:un(r)&&(p=r),p&&t.setData(i.downSample(i.mapDimension(l.dim),1/d,p,oS))}}}}}("line"))},function(t){Qb(_C),Qb(QC)},function(t){Qb(QC),t.registerComponentModel(JC),t.registerComponentView(vM),t.registerAction({type:"showTip",event:"showTip",update:"tooltip:manuallyShowTip"},Nn),t.registerAction({type:"hideTip",event:"hideTip",update:"tooltip:manuallyHideTip"},Nn)},function(t){t.registerPainter("svg",Pk)}]);class Lk extends At{constructor(){super(...arguments),this.options=null,this.data=[],this.height="120px",this._chart=null,this._resizeObserver=null}render(){return ht`
`}connectedCallback(){super.connectedCallback(),this.hasUpdated&&!this._chart&&this.options&&(this._mount(),this._observeResize())}firstUpdated(){this._mount(),this._observeResize()}updated(t){(t.has("options")||t.has("data"))&&(this._chart&&this.options?this._chart.setOption(this._mergedOptions(),!0):!this._chart&&this.options&&this._mount()),t.has("height")&&this._chart&&this._chart.resize()}disconnectedCallback(){this._destroy(),super.disconnectedCallback()}_mount(){if(!this.options)return;const t=this.shadowRoot?.querySelector(".chart-host");t&&(this._chart=rx(t,void 0,{renderer:"svg"}),this._chart.setOption(this._mergedOptions(),!0))}_mergedOptions(){if(!this.options)throw new Error("span-chart: options not set");return{...this.options,series:this.data}}_observeResize(){if("undefined"==typeof ResizeObserver)return;this._resizeObserver=new ResizeObserver(()=>{this._chart&&this._chart.resize()});const t=this.shadowRoot?.querySelector(".chart-host");t&&this._resizeObserver.observe(t)}_destroy(){this._resizeObserver&&(this._resizeObserver.disconnect(),this._resizeObserver=null),this._chart&&(this._chart.dispose(),this._chart=null)}}Lk.styles=T` + :host { + display: block; + width: 100%; + } + .chart-host { + width: 100%; + height: 100%; + } + `,x([zt({attribute:!1})],Lk.prototype,"options",void 0),x([zt({attribute:!1})],Lk.prototype,"data",void 0),x([zt({type:String})],Lk.prototype,"height",void 0);try{customElements.get("span-chart")||customElements.define("span-chart",Lk)}catch{}function Ek(t,e,n,i,r,a,s,l,c){const{options:u,series:h}=function(t,e,n,i,r,a=!1){n||(n=g[o]);const s=i?"140, 160, 220":"77, 217, 175",l=`rgb(${s})`,c=Date.now(),u=c-e,h=void 0!==n.fixedMin&&void 0!==n.fixedMax,d=(t??[]).filter(t=>t.time>=u).map(t=>[t.time,Math.abs(t.value)]),p=[{type:"line",data:d,showSymbol:!1,smooth:!1,...a?{}:{step:"end"},lineStyle:{width:1.5,color:l},areaStyle:{color:{type:"linear",x:0,y:0,x2:0,y2:1,colorStops:[{offset:0,color:`rgba(${s}, 0.18)`},{offset:1,color:`rgba(${s}, 0.18)`}]}},itemStyle:{color:l}}],f=d.length>0?function(t){let e=0;for(const n of t)n[1]>e&&(e=n[1]);return e}(d):0,v={type:"value",splitNumber:4,axisLabel:{fontSize:10,formatter:f<10?t=>0===t?"0":t.toFixed(1):t=>n.format(t)},splitLine:{lineStyle:{opacity:.15}}};h?(v.min=n.fixedMin,v.max=n.fixedMax):f<1&&(v.min=0,v.max=1),r&&"current"===n.entityRole&&(v.min=0,v.max=Math.ceil(1.25*r),p.push({type:"line",data:[[u,.8*r],[c,.8*r]],showSymbol:!1,lineStyle:{width:1,color:"rgba(255, 200, 40, 0.6)",type:"dashed"},itemStyle:{color:"transparent"},tooltip:{show:!1}}),p.push({type:"line",data:[[u,r],[c,r]],showSymbol:!1,lineStyle:{width:1.5,color:"rgba(255, 60, 60, 0.7)",type:"solid"},itemStyle:{color:"transparent"},tooltip:{show:!1}}));const m={xAxis:{type:"time",min:u,max:c,axisLabel:{fontSize:10},splitLine:{show:!1}},yAxis:v,grid:{top:8,right:4,bottom:0,left:0,containLabel:!0},tooltip:{trigger:"axis",axisPointer:{type:"line",lineStyle:{type:"dashed"}},formatter:t=>{if(!t||0===t.length)return"";const e=t[0],i=new Date(e.value[0]).toLocaleString(void 0,{month:"short",day:"numeric",hour:"2-digit",minute:"2-digit",second:"2-digit"}),r=parseFloat(e.value[1].toFixed(2));return`
${i}
${n.format(r)} ${n.unit(r)}
`}},animation:!1};return{options:m,series:p}}(n,i,r,a,l,c),d=s??120;t.style.minHeight=d+"px";let p=t.querySelector("span-chart");p||(p=document.createElement("span-chart"),p.style.display="block",p.style.width="100%",t.innerHTML="",t.appendChild(p));const f=t.clientHeight;p.height=(f>0?f:d)+"px",p.options=u,p.data=h}function zk(t){return"function"==typeof globalThis.CSS?.escape?CSS.escape(t):t.replace(/["\\]/g,"\\$&")}function Ok(t,e,n,i,r){const o=t.querySelector(".panel-stats");o&&function(t,e,n,i,r){const o="current"===(i.chart_metric||"power"),a=t.querySelector(".stat-consumption .stat-value"),s=t.querySelector(".stat-consumption .stat-unit");if(o){const t=n.panel_entities?.site_power,i=t?e.states[t]:null,r=i?parseFloat(i.attributes?.amperage):NaN;a&&(a.textContent=Number.isFinite(r)?Math.abs(r).toFixed(1):"--"),s&&(s.textContent="A")}else{let t=r;const i=n.panel_entities?.site_power;if(i){const n=e.states[i];n&&(t=Math.abs(parseFloat(n.state)||0))}a&&(a.textContent=Gt(t)),s&&(s.textContent="kW")}const l=t.querySelector(".stat-upstream .stat-value"),c=t.querySelector(".stat-upstream .stat-unit");if(l){const t=n.panel_entities?.current_power,i=t?e.states[t]:null;if(o){const t=i?parseFloat(i.attributes?.amperage):NaN;l.textContent=Number.isFinite(t)?Math.abs(t).toFixed(1):"--",c&&(c.textContent="A")}else{const t=i?Math.abs(parseFloat(i.state)||0):0;l.textContent=Gt(t),c&&(c.textContent="kW")}}const u=t.querySelector(".stat-downstream .stat-value"),h=t.querySelector(".stat-downstream .stat-unit");if(u){const t=n.panel_entities?.feedthrough_power,i=t?e.states[t]:null;if(o){const t=i?parseFloat(i.attributes?.amperage):NaN;u.textContent=Number.isFinite(t)?Math.abs(t).toFixed(1):"--",h&&(h.textContent="A")}else{const t=i?Math.abs(parseFloat(i.state)||0):0;u.textContent=Gt(t),h&&(h.textContent="kW")}}const d=t.querySelector(".stat-solar .stat-value"),p=t.querySelector(".stat-solar .stat-unit");if(d){const t=n.panel_entities?.pv_power,i=t?e.states[t]:null;if(o){const t=i?parseFloat(i.attributes?.amperage):NaN;d.textContent=Number.isFinite(t)?Math.abs(t).toFixed(1):"--",p&&(p.textContent="A")}else{if(i){const t=Math.abs(parseFloat(i.state)||0);d.textContent=Gt(t)}else d.textContent="--";p&&(p.textContent="kW")}}const f=t.querySelector(".stat-battery .stat-value");if(f){const t=n.panel_entities?.battery_level,i=t?e.states[t]:null;i&&(f.textContent=`${Math.round(parseFloat(i.state)||0)}`)}const g=t.querySelector(".stat-grid-state .stat-value");if(g){const t=n.panel_entities?.dsm_state,i=t?e.states[t]:null;g.textContent=i?e.formatEntityState?.(i)||i.state:"--"}}(o,e,n,i,r)}class Nk{get errorStore(){return this._errorStore}set errorStore(t){this._errorStore=t,this._retry=t?new Qt(t):null}constructor(){this._errorStore=null,this._retry=null,this._settings=null,this._lastFetch=0,this._fetching=!1}async fetch(t,e){const n=Date.now();if(this._fetching)return this._settings;if(this._settings&&n-this._lastFetch<3e4)return this._settings;this._fetching=!0;try{const n={};e&&(n.config_entry_id=e);const r={type:"call_service",domain:l,service:"get_graph_settings",service_data:n,return_response:!0},o=this._retry?await this._retry.callWS(t,r,{errorId:"fetch:graph_settings",errorMessage:i("error.graph_settings_failed")}):await t.callWS(r);this._settings=o?.response??null,this._lastFetch=Date.now()}catch(t){console.warn("SPAN Panel: graph settings fetch failed",t),this._settings=null,this._retry||this._errorStore?.add({key:"fetch:graph_settings",level:"warning",message:i("error.graph_settings_failed"),persistent:!1})}finally{this._fetching=!1}return this._settings}invalidate(){this._lastFetch=0}get settings(){return this._settings}clear(){this._settings=null,this._lastFetch=0}}function Rk(t,e){if(!t)return a;const n=t.circuits?.[e];return n?.has_override?n.horizon:t.global_horizon??a}function Hk(t,e){if(!t)return a;const n=t.sub_devices?.[e];return n?.has_override?n.horizon:t.global_horizon??a}class Bk{constructor(){this.powerHistory=new Map,this.horizonMap=new Map,this.subDeviceHorizonMap=new Map,this.monitoringCache=new Jt,this.monitoringMultiCache=new te,this.graphSettingsCache=new Nk,this._errorStore=null,this._hass=null,this._topology=null,this._config=null,this._configEntryId=null,this._favRefs=null,this._perPanelInfo=new Map,this._panelFavorites=null,this._showMonitoring=!1,this._updateInterval=null,this._recorderRefreshInterval=null,this._resizeObserver=null,this._lastWidth=0,this._resizeDebounce=null}get errorStore(){return this._errorStore}set errorStore(t){this._errorStore=t,this.monitoringCache.errorStore=t,this.graphSettingsCache.errorStore=t,this.monitoringMultiCache.errorStore=t}get hass(){return this._hass}set hass(t){this._hass=t}get topology(){return this._topology}get config(){return this._config}set showMonitoring(t){this._showMonitoring=t}init(t,e,n,i){this._topology=t,this._config=e,this._hass=n,this._configEntryId=i}setFavoriteRefs(t){this._favRefs=t}clearFavoriteRefs(){this._favRefs=null}setPanelFavorites(t){this._panelFavorites=t}setFavoritesPerPanelInfo(t){this._perPanelInfo=t??new Map}get _inFavoritesView(){return null!==this._favRefs}setConfig(t){this._config=t}buildHorizonMaps(t){if(this.horizonMap.clear(),this.subDeviceHorizonMap.clear(),t&&this._topology?.circuits)for(const e of Object.keys(this._topology.circuits))this.horizonMap.set(e,Rk(t,e));if(t&&this._topology?.sub_devices)for(const e of Object.keys(this._topology.sub_devices))this.subDeviceHorizonMap.set(e,Hk(t,e))}async fetchAndBuildHorizonMaps(){try{this._favRefs?await this._buildFavoritesHorizonMaps():(await this.graphSettingsCache.fetch(this._hass,this._configEntryId),this.buildHorizonMaps(this.graphSettingsCache.settings))}catch(t){console.warn("SPAN Panel: graph settings fetch failed",t),this.graphSettingsCache.errorStore||this._errorStore?.add({key:"fetch:graph_settings",level:"warning",message:i("error.graph_settings_failed"),persistent:!1})}}async fetchMergedMonitoringStatus(t){if(!this._hass||0===t.length)return null;const e=this._hass;return function(t){let e=!1;const n={},i={};for(const r of t)r&&(e=!0,r.circuits&&Object.assign(n,r.circuits),r.mains&&Object.assign(i,r.mains));return e?{circuits:n,mains:i}:null}(await Promise.all(t.map(t=>this.monitoringMultiCache.fetchOne(e,t))))}async _buildFavoritesHorizonMaps(){if(!this._hass||!this._favRefs||!this._topology)return;const t=new Set;for(const e of Object.values(this._favRefs))e.configEntryId&&t.add(e.configEntryId);const e=new Map;await Promise.all(Array.from(t).map(async t=>{e.set(t,await this._fetchGraphSettingsFresh(t))})),this.horizonMap.clear(),this.subDeviceHorizonMap.clear();for(const t of Object.keys(this._topology.circuits)){const n=this._favRefs[t],i=n?.configEntryId?e.get(n.configEntryId)??null:null,r=n?.targetId??t;this.horizonMap.set(t,Rk(i,r))}if(this._topology.sub_devices)for(const t of Object.keys(this._topology.sub_devices)){const n=this._favRefs[t],i=n?.configEntryId?e.get(n.configEntryId)??null:null,r=n?.targetId??t;this.subDeviceHorizonMap.set(t,Hk(i,r))}}async loadHistory(){await Me(this._hass,this._topology,this._config,this.powerHistory,this.horizonMap,this.subDeviceHorizonMap)}recordSamples(){if(!this._topology||!this._hass||!this._config)return;const t=Date.now();for(const[e,n]of Object.entries(this._topology.circuits)){const i=this.horizonMap.get(e)??a;if(!s[i]?.useRealtime)continue;const r=Zt(n,this._config);if(!r)continue;const o=this._hass.states[r];if(!o)continue;const l=parseFloat(o.state);if(isNaN(l))continue;const c=me(i),u=ye(c),h=_e(c),d=t-c,p=this.powerHistory.get(e)??[];p.length>0&&t-p[p.length-1].time0&&t-p[p.length-1].time0&&this._topology)for(const{key:t,devId:e}of Ce(this._topology))n.has(e)&&r.add(t);const o=new Map;try{await Me(this._hass,this._topology,this._config,o,e,n);for(const t of e.keys()){const e=o.get(t);e?this.powerHistory.set(t,e):this.powerHistory.delete(t)}for(const t of r){const e=o.get(t);e?this.powerHistory.set(t,e):this.powerHistory.delete(t)}this.updateDOM(t)}catch(t){console.warn("SPAN Panel: history refresh failed",t),this._errorStore?.add({key:"fetch:history",level:"warning",message:i("error.history_failed"),persistent:!1})}}updateDOM(t){this._hass&&this._topology&&this._config&&(function(t,e,n,r,o,a){if(!t||!n||!e)return;const s=ve(r);let l=0;for(const[,t]of Object.entries(n.circuits)){const n=t.entities?.power;if(!n)continue;const i=e.states[n],r=i&&parseFloat(i.state)||0;t.device_type!==u&&(l+=Math.abs(r))}Ok(t,e,n,r,l);const h=Yt(r),d="current"===h.entityRole;for(const[r,l]of Object.entries(n.circuits)){const n=t.querySelector(`.circuit-slot[data-uuid="${zk(r)}"]`);if(!n)continue;const p=l.entities?.power,f=p?e.states[p]:null,g=f&&parseFloat(f.state)||0,v=l.device_type===u||g<0,y=l.entities?.switch,_=y?e.states[y]:null,x=_?"on"===_.state:(f?.attributes?.relay_state||l.relay_state)===c,b=n.querySelector(".power-value");if(b)if(d){const t=l.entities?.current,n=t?e.states[t]:null,i=n&&parseFloat(n.state)||0;b.innerHTML=`${h.format(i)}A`}else b.innerHTML=`${Ut(g)}${Wt(g)}`;const w=n.querySelector(".toggle-pill");if(w){w.className="toggle-pill "+(x?"toggle-on":"toggle-off");const t=w.querySelector(".toggle-label");t&&(t.textContent=i(x?"grid.on":"grid.off"))}let S;if(n.classList.toggle("circuit-off",!x),n.classList.toggle("circuit-producer",v),l.always_on)S="always_on";else{const t=l.entities?.select,n=t?e.states[t]:null;S=n?n.state:"unknown"}const C=m[S]??m.unknown,M=n.querySelector(".shedding-icon");M&&(M.setAttribute("icon",C.icon),M.style.color=C.color,M.title=C.label());const k=n.querySelector(".shedding-icon-secondary");k&&(C.icon2?(k.setAttribute("icon",C.icon2),k.style.color=C.color,k.style.display=""):k.style.display="none");const T=n.querySelector(".shedding-label");T&&(C.textLabel?(T.textContent=C.textLabel,T.style.color=C.color,T.style.display=""):T.style.display="none");const D=n.querySelector(".chart-container");if(D){const t=o.get(r)||[],e=n.classList.contains("circuit-col-span")?200:100,i=a?.has(r)?me(a.get(r)):s,c=l.device_type===u;Ek(D,0,t,i,h,v,e,l.breaker_rating_a??void 0,c)}}}(t,this._hass,this._topology,this._config,this.powerHistory,this.horizonMap),function(t,e,n,i,r,o){if(!n.sub_devices)return;const a=ve(i);for(const[i,s]of Object.entries(n.sub_devices)){const n=t.querySelector(`[data-subdev="${zk(i)}"]`);if(!n)continue;const l=ue(s);if(l){const t=e.states[l],i=t&&parseFloat(t.state)||0,r=n.querySelector(".sub-power-value");r&&(r.innerHTML=`${Ut(i)} ${Wt(i)}`)}const c=n.querySelectorAll("[data-chart-key]");for(const t of c){const e=t.dataset.chartKey;if(!e)continue;const n=r.get(e)||[];let s=v.power;e.endsWith("_soc")?s=v.soc:e.endsWith("_soe")&&(s=v.soe);const l=!!t.closest(".bess-chart-col");Ek(t,0,n,o?.has(i)?me(o.get(i)):a,s,!1,l?120:150,void 0,e.endsWith("_soc")||e.endsWith("_soe"))}for(const t of Object.keys(s.entities||{})){const i=n.querySelector(`[data-eid="${zk(t)}"]`);if(!i)continue;const r=e.states[t];if(r){let t;if(e.formatEntityState)t=e.formatEntityState(r);else{t=r.state;const e=r.attributes.unit_of_measurement||"";e&&(t+=" "+e)}if("Wh"===(r.attributes.unit_of_measurement||"")){const e=parseFloat(r.state);isNaN(e)||(t=(e/1e3).toFixed(1)+" kWh")}i.textContent=t}}}}(t,this._hass,this._topology,this._config,this.powerHistory,this.subDeviceHorizonMap))}async onGraphSettingsChanged(t){if(this._hass){this._favRefs?await this._buildFavoritesHorizonMaps():(this.graphSettingsCache.invalidate(),await this.graphSettingsCache.fetch(this._hass,this._configEntryId),this.buildHorizonMaps(this.graphSettingsCache.settings)),this.powerHistory.clear();try{await this.loadHistory()}catch{}this.updateDOM(t)}}onToggleClick(t,e){const n=t.target,r=n?.closest(".toggle-pill");if(!r)return;const o=e.querySelector(".slide-confirm");if(!o||!o.classList.contains("confirmed"))return;t.stopPropagation(),t.preventDefault();const a=r.closest("[data-uuid]");if(!a||!this._topology||!this._hass)return;const s=a.dataset.uuid;if(!s)return;const l=this._topology.circuits[s];if(!l)return;const c=l.entities?.switch;if(!c)return;const u=this._hass.states[c];if(!u)return void console.warn("SPAN Panel: switch entity not found:",c);const h="on"===u.state?"turn_off":"turn_on";this._hass.callService("switch",h,{},{entity_id:c}).catch(t=>{console.warn("SPAN Panel: switch service call failed",t),this._errorStore?.add({key:"service:relay",level:"error",message:i("error.relay_failed"),persistent:!1})})}async onGearClick(t,e){const n=t.target,i=n?.closest(".gear-icon");if(!i)return;const r=e.querySelector("span-side-panel");if(!r||!this._hass)return;if(r.hass=this._hass,r.errorStore=this.errorStore,i.classList.contains("panel-gear")){if(this._inFavoritesView){const t=await this._buildFavoritesSections();if(0===t.length)return;return void r.open({favoritesMode:!0,perPanelSections:t})}return await this.graphSettingsCache.fetch(this._hass,this._configEntryId),void r.open({panelMode:!0,topology:this._topology,graphSettings:this.graphSettingsCache.settings,showFavorites:null!==this._panelFavorites,favoritePanelDeviceId:this._panelFavorites?.panelDeviceId,favoriteCircuitUuids:this._panelFavorites?.circuitUuids,favoriteSubDeviceIds:this._panelFavorites?.subDeviceIds,configEntryId:this._configEntryId})}const o=i.dataset.uuid;if(o&&this._topology){const t=this._topology.circuits[o];if(t){const e=this._favRefs?.[o]??null,n=e&&"circuit"===e.kind?e.targetId:o,i=e?.configEntryId??this._configEntryId;let s,l;e?[s,l]=await Promise.all([this._fetchGraphSettingsFresh(i),this._fetchMonitoringStatusFresh(i)]):(await Promise.all([this.graphSettingsCache.fetch(this._hass,i),this.monitoringCache.fetch(this._hass,i)]),s=this.graphSettingsCache.settings,l=this.monitoringCache.status);const c=t.entities?.current??t.entities?.power,u=c?l?.circuits?.[c]??null:null,h=s?.global_horizon??a,d=s?.circuits?.[n],p=d?{...d,globalHorizon:h}:{horizon:h,has_override:!1,globalHorizon:h},f=e?.panelDeviceId??this._panelFavorites?.panelDeviceId,g=null!==e||(this._panelFavorites?.circuitUuids.has(n)??!1),v=this._inFavoritesView||null!==this._panelFavorites;return void r.open({...t,uuid:n,monitoringInfo:u,showMonitoring:this._showMonitoring,graphHorizonInfo:p,showFavorites:v,favoritePanelDeviceId:f,isFavorite:g,configEntryId:i})}}const s=i.dataset.subdevId;if(s&&this._topology?.sub_devices?.[s]){const t=this._topology.sub_devices[s],e=this._favRefs?.[s]??null,n=e&&"sub_device"===e.kind?e.targetId:s,i=e?.configEntryId??this._configEntryId;let o;e?o=await this._fetchGraphSettingsFresh(i):(await this.graphSettingsCache.fetch(this._hass,i),o=this.graphSettingsCache.settings);const l=o?.global_horizon??a,c=o?.sub_devices?.[n],u=c?{...c,globalHorizon:l}:{horizon:l,has_override:!1,globalHorizon:l},h=e?.panelDeviceId??this._panelFavorites?.panelDeviceId,d=null!==e||(this._panelFavorites?.subDeviceIds.has(n)??!1),p=this._inFavoritesView||null!==this._panelFavorites;r.open({subDeviceMode:!0,subDeviceId:n,name:t.name??n,deviceType:t.type??"",entities:t.entities,graphHorizonInfo:u,showFavorites:p,favoritePanelDeviceId:h,isFavorite:d,configEntryId:i})}}async _buildFavoritesSections(){if(!this._hass||!this._favRefs)return[];const t=function(t,e){const n=new Map;for(const i of Object.values(t)){if("circuit"!==i.kind)continue;const t=e.get(i.panelDeviceId);if(void 0===t)continue;let r=n.get(i.panelDeviceId);void 0===r&&(r={panelDeviceId:i.panelDeviceId,panelName:t.panelName,topology:t.topology,configEntryId:t.configEntryId,favoriteCircuitUuids:new Set},n.set(i.panelDeviceId,r)),r.favoriteCircuitUuids.add(i.targetId)}return Array.from(n.values()).sort((t,e)=>t.panelName.localeCompare(e.panelName))}(this._favRefs,this._perPanelInfo);if(0===t.length)return[];return await Promise.all(t.map(async t=>({panelDeviceId:t.panelDeviceId,panelName:t.panelName,topology:t.topology,graphSettings:await this._fetchGraphSettingsFresh(t.configEntryId),favoriteCircuitUuids:t.favoriteCircuitUuids,configEntryId:t.configEntryId})))}async _fetchGraphSettingsFresh(t){if(!this._hass)return null;try{const e={};t&&(e.config_entry_id=t);const n={type:"call_service",domain:l,service:"get_graph_settings",service_data:e,return_response:!0},r=this._errorStore?new Qt(this._errorStore):null,o=r?await r.callWS(this._hass,n,{errorId:"fetch:graph_settings",errorMessage:i("error.graph_settings_failed")}):await this._hass.callWS(n);return o?.response??null}catch(t){return console.warn("SPAN Panel: fresh graph settings fetch failed",t),null}}async _fetchMonitoringStatusFresh(t){if(!this._hass)return null;try{const e={};t&&(e.config_entry_id=t);const n={type:"call_service",domain:l,service:"get_monitoring_status",service_data:e,return_response:!0},r=this._errorStore?new Qt(this._errorStore):null,o=r?await r.callWS(this._hass,n,{errorId:"fetch:monitoring",errorMessage:i("error.monitoring_failed")}):await this._hass.callWS(n),a=o?.response;return a?{circuits:a.circuits,mains:a.mains}:null}catch(t){return console.warn("SPAN Panel: fresh monitoring status fetch failed",t),null}}bindSlideConfirm(t,e){const n=t.querySelector(".slide-confirm-knob"),i=t.querySelector(".slide-confirm-text");if(!n||!i)return;let r=!1,o=0,a=0;const s=e=>{t.classList.contains("confirmed")||(r=!0,o=e-n.offsetLeft,a=t.offsetWidth-n.offsetWidth-4,n.classList.remove("snapping"))},l=t=>{if(!r)return;const e=Math.max(2,Math.min(t-o,a));n.style.left=e+"px"},c=()=>{if(!r)return;r=!1;(n.offsetLeft-2)/a>=.9?(n.style.left=a+"px",t.classList.add("confirmed"),n.querySelector("span-icon")?.setAttribute("icon","mdi:lock-open"),i.textContent=t.dataset.textOn??"",e&&e.classList.remove("switches-disabled")):(n.classList.add("snapping"),n.style.left="2px")};n.addEventListener("mousedown",t=>{t.preventDefault(),s(t.clientX)}),t.addEventListener("mousemove",t=>l(t.clientX)),t.addEventListener("mouseup",c),t.addEventListener("mouseleave",c),n.addEventListener("touchstart",t=>{t.preventDefault(),s(t.touches[0].clientX)},{passive:!1}),t.addEventListener("touchmove",t=>l(t.touches[0].clientX),{passive:!0}),t.addEventListener("touchend",c),t.addEventListener("touchcancel",c),t.addEventListener("click",()=>{t.classList.contains("confirmed")&&(t.classList.remove("confirmed"),n.classList.add("snapping"),n.style.left="2px",n.querySelector("span-icon")?.setAttribute("icon","mdi:lock"),i.textContent=t.dataset.textOff??"",e&&e.classList.add("switches-disabled"))})}startIntervals(t,e){this._updateInterval=setInterval(()=>{this.recordSamples(),this.updateDOM(t),e&&e()},1e3),this._recorderRefreshInterval=setInterval(()=>{this.refreshRecorderData(t)},3e4)}stopIntervals(){this._updateInterval&&(clearInterval(this._updateInterval),this._updateInterval=null),this._recorderRefreshInterval&&(clearInterval(this._recorderRefreshInterval),this._recorderRefreshInterval=null),this.cleanupResizeObserver()}setupResizeObserver(t,e){this.cleanupResizeObserver(),e&&(this._lastWidth=e.clientWidth,this._resizeObserver=new ResizeObserver(e=>{const n=e[0];if(!n)return;const i=n.contentRect.width;Math.abs(i-this._lastWidth)<5||(this._lastWidth=i,this._resizeDebounce&&clearTimeout(this._resizeDebounce),this._resizeDebounce=setTimeout(()=>{for(const e of t.querySelectorAll(".chart-container")){const t=e.querySelector("span-chart");t&&t.remove()}this.updateDOM(t)},150))}),this._resizeObserver.observe(e))}cleanupResizeObserver(){this._resizeObserver&&(this._resizeObserver.disconnect(),this._resizeObserver=null),this._resizeDebounce&&(clearTimeout(this._resizeDebounce),this._resizeDebounce=null)}reset(){this.powerHistory.clear(),this.horizonMap.clear(),this.subDeviceHorizonMap.clear(),this.monitoringCache.clear(),this.monitoringMultiCache.clear(),this.graphSettingsCache.clear()}}function Fk(t=""){const e=t?` value="${Rt(t)}"`:"",n=t?"":"display:none;";return`\n
\n \n \n
\n `}function $k(t,e,n,r,o,a,s){const l=e.entities?.power,u=l?n.states[l]:null,h=u&&parseFloat(u.state)||0,d=e.entities?.switch,p=d?n.states[d]:null,f=p?"on"===p.state:(u?.attributes?.relay_state||e.relay_state)===c,g=e.breaker_rating_a,v=g?`${Math.round(g)}A`:"",y=Rt(e.name||i("grid.unknown")),_=Yt(r),x="current"===_.entityRole;let b;if(f)if(x){const t=e.entities?.current,i=t?n.states[t]:null,r=i&&parseFloat(i.state)||0;b=`${_.format(r)}A`}else b=`${Ut(h)}${Wt(h)}`;else b="";const w=a||"unknown";let S="";if("unknown"!==w){const t=m[w]??m.unknown??{icon:"mdi:help",color:"#999",label:()=>"Unknown"};S=t.icon2?`\n \n \n `:t.textLabel?`\n \n ${t.textLabel}\n `:``}let C="",M=o?.utilization_pct??null;if(null==M&&e.breaker_rating_a){const t=e.entities?.current,i=t?n.states[t]:null,r=i?Math.abs(parseFloat(i.state)||0):0;M=Math.round(r/e.breaker_rating_a*1e3)/10}if(null!=M){C=`=80?"utilization-warning":"utilization-normal"}">${Math.round(M)}%`}const k=``,T=!1!==e.is_user_controllable&&!!e.entities?.switch?`
\n ${i(f?"grid.on":"grid.off")}\n \n
`:`${f?"ON":"OFF"}`;return`\n
\n ${v?`${v}`:""}\n ${C}\n ${y}\n ${S}\n ${T}\n \n ${b}\n \n ${k}\n \n
\n `}function Vk(t,e,n,i,r){const o=e.entities?.power,a=o?n.states[o]:null,s=a&&parseFloat(a.state)||0,l=e.device_type===u||s<0,h=e.entities?.switch,d=h?n.states[h]:null,p=ne(0,r,d?"on"===d.state:(a?.attributes?.relay_state||e.relay_state)===c,l),f=Rt(t);return`\n
\n
\n
\n
\n
\n `}function Wk(t){return`
${Rt(t)}
`}function Uk(t,e){const n=e.hysteresisPx??48,i=new WeakSet;let r=null;const o=t=>{const n=t.querySelector(e.nameSelector);return!!n&&(0!==t.clientWidth&&(n.clientWidth<1||n.scrollWidth>n.clientWidth+1))},a=()=>{const i=t.querySelectorAll(e.rowSelector);if(0===i.length)return;const a=i[0].clientWidth;if(0===a)return;if(null!==r){if(a>r+n){for(const t of i)t.classList.remove(e.foldClass);r=null}else for(const t of i)t.classList.add(e.foldClass);return}let s=!1;for(const t of i)if(o(t)){s=!0;break}if(s){for(const t of i)t.classList.add(e.foldClass);r=a}},s=new ResizeObserver(()=>a()),l=()=>{const n=t.querySelectorAll(e.rowSelector);for(const t of n)i.has(t)||(i.add(t),s.observe(t));requestAnimationFrame(()=>a())};l();const c=new MutationObserver(t=>{(t=>{const n=t=>{for(const n of t)if(n instanceof Element){if(n.matches(e.rowSelector))return!0;if(n.querySelector(e.rowSelector))return!0}return!1};for(const e of t)if(n(e.addedNodes)||n(e.removedNodes))return!0;return!1})(t)&&l()});return c.observe(t,{childList:!0,subtree:!0}),()=>{s.disconnect(),c.disconnect()}}function Gk(t,e,n){const i=t.entities?.switch,r=i?e.states[i]:null,o=t.entities?.power,a=o?e.states[o]:null,s=r?"on"===r.state:(a?.attributes?.relay_state||t.relay_state)===c;let l;if("current"===(n.chart_metric||"power")){const n=t.entities?.current,i=n?e.states[n]:null;l=i?Math.abs(parseFloat(i.state)||0):0}else l=a?Math.abs(parseFloat(a.state)||0):0;return{isOn:s,value:l}}function qk(t,e){if(t.always_on)return"always_on";const n=t.entities?.select,i=n?e.states[n]:null;return i?i.state:"unknown"}function jk(t,e,n,i){const r=Gk(t,n,i),o=Gk(e,n,i);return r.isOn&&!o.isOn?-1:!r.isOn&&o.isOn?1:o.value-r.value}function Xk(t,e,n){return t.sort((t,i)=>jk(t[1],i[1],e,n))}function Yk(t){return t.entities?.current??t.entities?.power??""}class Zk{constructor(t){this._expandedUuids=new Set,this._searchQuery="",this._container=null,this._clickHandler=null,this._inputHandler=null,this._graphSettingsHandler=null,this._hass=null,this._topology=null,this._config=null,this._monitoringStatus=null,this._viewName=null,this._columns=1,this._foldUnobserve=null,this._ctrl=t}setColumns(t){const e=Math.max(1,Math.min(3,Math.floor(t)));this._columns=e}setInitialExpansion(t){this._expandedUuids=new Set(t)}setInitialSearchQuery(t){this._searchQuery=t}setViewName(t){this._viewName=t}renderActivityView(t,e,n,i,r,o){this._unbindEvents(),this._hass=e,this._topology=n,this._config=i,this._monitoringStatus=r;const a=Xk(Object.entries(n.circuits),e,i);let s=o+Fk(this._searchQuery);s+=`
`;for(const[t,n]of a){const o=ee(r,Yk(n)),a=qk(n,e),l=this._expandedUuids.has(t);s+=`
`,s+=$k(t,n,e,i,o,a,l),l&&(s+=Vk(t,n,e,0,o)),s+="
"}s+="
",s+="",t.innerHTML=s;const l=t.querySelector("span-side-panel");l&&(l.hass=e,l.errorStore=this._ctrl.errorStore),this._bindEvents(t),this._searchQuery&&this._applyFilter(t),this._ctrl.updateDOM(t),this._attachFoldObserver(t)}renderAreaView(t,e,n,r,o,a){this._unbindEvents(),this._hass=e,this._topology=n,this._config=r,this._monitoringStatus=o;const s=i("list.unassigned_area"),l=new Map;for(const[t,e]of Object.entries(n.circuits)){const n=e.area??s,i=l.get(n);i?i.push([t,e]):l.set(n,[[t,e]])}const c=[...l.keys()].sort((t,e)=>t===s?1:e===s?-1:t.localeCompare(e));let u=a+Fk(this._searchQuery);u+=`
`;for(const t of c){const n=l.get(t);if(!n)continue;const i=Xk(n,e,r);u+=Wk(t);for(const[t,n]of i){const i=ee(o,Yk(n)),a=qk(n,e),s=this._expandedUuids.has(t);u+=`
`,u+=$k(t,n,e,r,i,a,s),s&&(u+=Vk(t,n,e,0,i)),u+="
"}}u+="
",u+="",t.innerHTML=u;const h=t.querySelector("span-side-panel");h&&(h.hass=e,h.errorStore=this._ctrl.errorStore),this._bindEvents(t),this._searchQuery&&this._applyFilter(t),this._ctrl.updateDOM(t),this._attachFoldObserver(t)}updateCollapsedRows(t,e,n,r){const o=Yt(r),a="current"===o.entityRole,s=t.querySelectorAll(".list-row[data-row-uuid]");for(const t of s){const s=t.dataset.rowUuid;if(!s)continue;const l=n.circuits[s];if(!l)continue;const{isOn:c,value:u}=Gk(l,e,r),h=t.querySelector(".list-power-value");if(h)if(c)if(a)h.innerHTML=`${o.format(u)}A`;else{const t=l.entities?.power,n=t?e.states[t]:null,i=n&&parseFloat(n.state)||0;h.innerHTML=`${Ut(i)}${Wt(i)}`}else h.innerHTML="";const d=t.querySelector(".toggle-pill");if(d){d.classList.toggle("toggle-on",c),d.classList.toggle("toggle-off",!c);const t=d.querySelector(".toggle-label");t&&(t.textContent=i(c?"grid.on":"grid.off"))}const p=t.querySelector(".list-status-badge");p&&(p.textContent=c?"ON":"OFF",p.classList.toggle("list-status-on",c),p.classList.toggle("list-status-off",!c)),t.classList.toggle("circuit-off",!c)}!function(t,e,n,i){const r=t.querySelector(".list-view");if(r)for(const t of function(t,e){let n={anchor:null,units:[]};const i=[n];for(const r of[...t.children])if(r.classList.contains("area-header"))n={anchor:r,units:[]},i.push(n);else if(r.classList.contains("list-cell")){const t=r.dataset.cellUuid,i=t?e.circuits[t]:void 0;t&&i&&n.units.push({cell:r,uuid:t,circuit:i})}return i}(r,n)){if(t.units.length<2)continue;const n=[...t.units].sort((t,n)=>jk(t.circuit,n.circuit,e,i));if(!n.some((e,n)=>e.uuid!==t.units[n].uuid))continue;let o=t.anchor;for(const t of n)o?o.after(t.cell):r.prepend(t.cell),o=t.cell}}(t,e,n,r)}stop(){this._unbindEvents(),null===this._viewName&&(this._expandedUuids.clear(),this._searchQuery=""),this._hass=null,this._topology=null,this._config=null,this._monitoringStatus=null}_dispatchFavoritesViewState(){if(!this._viewName||!this._container)return;const t={view:this._viewName,expanded:[...this._expandedUuids],searchQuery:this._searchQuery};this._container.dispatchEvent(new CustomEvent("favorites-view-state-changed",{detail:t,bubbles:!0,composed:!0}))}_bindEvents(t){this._container=t,this._clickHandler=e=>{const n=e.target;if(!n)return;const i=n.closest(".list-expand-toggle");if(i){const t=i.dataset.expandUuid;return void(t&&this._toggleExpand(t))}if(n.closest(".gear-icon"))return void this._ctrl.onGearClick(e,t);if(n.closest(".toggle-pill"))return void this._ctrl.onToggleClick(e,t);if(n.closest(".list-search-clear")){const e=t.querySelector(".list-search");return void(e&&(e.value="",e.dispatchEvent(new Event("input",{bubbles:!0}))))}const r=n.closest(".unit-btn");if(r){const e=r.dataset.unit;e&&t.dispatchEvent(new CustomEvent("unit-changed",{detail:e,bubbles:!0,composed:!0}))}},this._inputHandler=e=>{const n=e.target;n&&n.classList.contains("list-search")&&(this._searchQuery=n.value.toLowerCase(),this._applyFilter(t),this._dispatchFavoritesViewState())},this._graphSettingsHandler=()=>{this._ctrl.onGraphSettingsChanged(t).then(()=>{this._ctrl.updateDOM(t)}).catch(()=>{})},t.addEventListener("click",this._clickHandler),t.addEventListener("input",this._inputHandler),t.addEventListener("graph-settings-changed",this._graphSettingsHandler);const e=t.querySelector(".slide-confirm");e&&(this._ctrl.bindSlideConfirm(e,t),t.classList.add("switches-disabled"))}_unbindEvents(){this._container&&(this._clickHandler&&this._container.removeEventListener("click",this._clickHandler),this._inputHandler&&this._container.removeEventListener("input",this._inputHandler),this._graphSettingsHandler&&this._container.removeEventListener("graph-settings-changed",this._graphSettingsHandler)),this._foldUnobserve&&(this._foldUnobserve(),this._foldUnobserve=null),this._container=null,this._clickHandler=null,this._inputHandler=null,this._graphSettingsHandler=null}_attachFoldObserver(t){this._foldUnobserve&&(this._foldUnobserve(),this._foldUnobserve=null),this._foldUnobserve=Uk(t,{rowSelector:".list-row",nameSelector:".list-circuit-name",foldClass:"is-folded"})}_applyFilter(t){const e=t.querySelector(".list-search-clear");e&&(e.style.display=this._searchQuery?"":"none");const n=t.querySelectorAll(".list-cell[data-cell-uuid]");for(const t of n){const e=t.querySelector(".list-circuit-name"),n=(e?.textContent?.toLowerCase()??"").includes(this._searchQuery);t.style.display=n?"":"none"}const i=t.querySelectorAll(".area-header");for(const t of i){let e=!1,n=t.nextElementSibling;for(;n&&!n.classList.contains("area-header");){if(n.classList.contains("list-cell")&&"none"!==n.style.display){e=!0;break}n=n.nextElementSibling}t.style.display=e?"":"none"}}_toggleExpand(t){if(!(this._container&&this._hass&&this._topology&&this._config))return;const e=zk(t),n=this._container.querySelector(`.list-cell[data-cell-uuid="${e}"]`);if(!n)return;const i=n.querySelector(`.list-row[data-row-uuid="${e}"]`),r=n.querySelector(`.list-expand-toggle[data-expand-uuid="${e}"]`);if(i){if(this._expandedUuids.has(t)){this._expandedUuids.delete(t);const o=n.querySelector(`.list-expanded-content[data-expanded-uuid="${e}"]`);o&&o.remove(),r&&r.classList.remove("expanded"),i.classList.remove("list-row-expanded")}else{this._expandedUuids.add(t);const e=this._topology.circuits[t];if(!e)return;const n=ee(this._monitoringStatus,Yk(e)),o=Vk(t,e,this._hass,this._config,n);i.insertAdjacentHTML("afterend",o),r&&r.classList.add("expanded"),i.classList.add("list-row-expanded"),this._ctrl.updateDOM(this._container)}this._dispatchFavoritesViewState()}}}async function Kk(t,e){const[n,i,r]=await Promise.all([t.callWS({type:"config/area_registry/list"}),t.callWS({type:"config/entity_registry/list"}),t.callWS({type:"config/device_registry/list"})]),o=new Map;for(const t of n)o.set(t.area_id,t.name);const a=new Map;for(const t of i)t.area_id&&a.set(t.entity_id,t.area_id);const s=new Map;for(const t of r)s.set(t.id,t.area_id);let l;if(e.device_id){const t=s.get(e.device_id);t&&(l=o.get(t))}for(const t of Object.values(e.circuits)){let e;for(const n of Object.values(t.entities)){if(!n)continue;const t=a.get(n);if(t){e=o.get(t);break}}e||(e=l),t.area=e}}class Qk{constructor(){this._persistent=new Map,this._transient=null,this._transientTimer=null,this._subscribers=new Set,this._watchedPanels=new Map}add(t){const e={...t,timestamp:Date.now()};if(e.persistent)this._persistent.set(e.key,e);else{this._clearTransient(),this._transient=e;const t=e.ttl??5e3;this._transientTimer=setTimeout(()=>{this._transient=null,this._transientTimer=null,this._notify()},t)}this._notify()}remove(t){if(this._persistent.has(t))return this._persistent.delete(t),void this._notify();this._transient?.key===t&&(this._clearTransient(),this._notify())}clear(t){void 0===t?(this._persistent.clear(),this._clearTransient(),this._watchedPanels.clear()):!0===t.persistent?this._persistent.clear():!1===t.persistent&&this._clearTransient(),this._notify()}get active(){const t=[...this._persistent.values()];return null!==this._transient&&t.push(this._transient),t}hasPersistent(t){return this._persistent.has(t)}hasAnyPanelOffline(){for(const t of this._persistent.keys())if("panel-offline"===t||t.startsWith("panel-offline:"))return!0;return!1}subscribe(t){return this._subscribers.add(t),()=>{this._subscribers.delete(t)}}watchPanelStatus(t){this.watchPanelStatuses([{entityId:t,panelName:null}])}watchPanelStatuses(t){const e=this._watchedPanels,n=new Map;for(const i of t){const t=e.get(i.entityId);n.set(i.entityId,{panelName:i.panelName??null,wasOffline:t?.wasOffline??!1})}const i=this._isSingleUnnamed(e),r=this._isSingleUnnamed(n);for(const t of e.keys()){n.has(t)&&i===r||this._persistent.delete(this._offlineKey(t,i))}this._watchedPanels=n,this._notify()}clearPanelStatusWatch(){if(0===this._watchedPanels.size)return;const t=this._isSingleUnnamed(this._watchedPanels);for(const e of this._watchedPanels.keys())this._persistent.delete(this._offlineKey(e,t));this._watchedPanels.clear(),this._notify()}updateHass(t){if(0===this._watchedPanels.size)return;const e=this._isSingleUnnamed(this._watchedPanels);for(const[n,o]of this._watchedPanels){const a=t.states[n]?.state,s="on"===a,l=this._offlineKey(n,e),c=this._reconnectKey(n,e);if(s){const t=o.wasOffline;o.wasOffline=!1,this.remove(l),t&&this.add({key:c,level:"info",message:null===o.panelName?i("error.panel_reconnected"):r("error.panel_reconnected_named",{name:o.panelName}),persistent:!1})}else o.wasOffline=!0,this.hasPersistent(l)||this.add({key:l,level:"error",message:null===o.panelName?i("error.panel_offline"):r("error.panel_offline_named",{name:o.panelName}),persistent:!0})}}dispose(){this._clearTransient(),this._persistent.clear(),this._subscribers.clear(),this._watchedPanels.clear()}_isSingleUnnamed(t){if(1!==t.size)return!1;for(const e of t.values())return null===e.panelName;return!1}_offlineKey(t,e){return e?"panel-offline":`panel-offline:${t}`}_reconnectKey(t,e){return e?"panel-reconnected":`panel-reconnected:${t}`}_clearTransient(){null!==this._transientTimer&&(clearTimeout(this._transientTimer),this._transientTimer=null),this._transient=null}_notify(){for(const t of this._subscribers)try{t()}catch(t){console.warn("SPAN Panel: error-store subscriber threw",t)}}}function Jk(t){let e=0;for(const n of Object.values(t))if(n)for(const t of n.tabs)t>e&&(e=t);return e>0?e+e%2:0}function tT(t){return t?{id:t.id,name:t.name,name_by_user:t.name_by_user,config_entries:t.config_entries,identifiers:t.identifiers,via_device_id:t.via_device_id,sw_version:t.sw_version,model:t.model}:null}const eT="favorites-changed";async function nT(t,e,n={}){const i=await t.callWS({type:"call_service",domain:l,service:e,service_data:n,return_response:!0});return i?.response??null}const iT=Object.keys(m).filter(t=>"unknown"!==t&&"always_on"!==t);class rT extends HTMLElement{constructor(){super(),this.errorStore=null,this.attachShadow({mode:"open"}),this._hass=null,this._config=null,this._debounceTimers={}}set hass(t){this._hass=t,this.hasAttribute("open")&&this._config&&this._updateLiveState()}get hass(){return this._hass}disconnectedCallback(){this._clearDebounceTimers(),this._config=null}open(t){this._config=t,this._render(),this.offsetHeight,this.setAttribute("open",""),this.setAttribute("data-mode",this._modeFor(t))}close(){this._clearDebounceTimers(),this.removeAttribute("open"),this.removeAttribute("data-mode"),this._config=null,this.dispatchEvent(new CustomEvent("side-panel-closed",{bubbles:!0,composed:!0}))}_clearDebounceTimers(){for(const t of Object.keys(this._debounceTimers))clearTimeout(this._debounceTimers[t]);this._debounceTimers={}}_modeFor(t){return t.favoritesMode?"favorites":t.panelMode?"panel":t.subDeviceMode?"subDevice":"circuit"}_render(){const t=this._config;if(!t)return;const e=this.shadowRoot;if(!e)return;e.innerHTML="";const n=document.createElement("style");n.textContent='\n :host {\n display: block;\n position: fixed;\n top: 0;\n right: 0;\n bottom: 0;\n width: 360px;\n max-width: 90vw;\n z-index: 1000;\n transform: translateX(100%);\n transition: transform 0.3s ease;\n pointer-events: none;\n }\n :host([open]) {\n transform: translateX(0);\n pointer-events: auto;\n }\n\n .backdrop {\n display: none;\n position: fixed;\n top: 0;\n left: 0;\n right: 0;\n bottom: 0;\n background: rgba(0, 0, 0, 0.3);\n z-index: -1;\n }\n :host([open]) .backdrop {\n display: block;\n }\n\n .panel {\n height: 100%;\n background: var(--card-background-color, #fff);\n border-left: 1px solid var(--divider-color, #e0e0e0);\n display: flex;\n flex-direction: column;\n overflow: hidden;\n }\n\n .panel-header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 16px;\n border-bottom: 1px solid var(--divider-color, #e0e0e0);\n }\n .panel-header .title {\n font-size: 18px;\n font-weight: 500;\n color: var(--primary-text-color, #212121);\n margin: 0;\n }\n .panel-header .subtitle {\n font-size: 13px;\n color: var(--secondary-text-color, #727272);\n margin: 2px 0 0 0;\n }\n .close-btn {\n background: none;\n border: none;\n cursor: pointer;\n color: var(--secondary-text-color, #727272);\n padding: 4px;\n line-height: 1;\n font-size: 20px;\n }\n\n .panel-body {\n flex: 1;\n overflow-y: auto;\n padding: 16px;\n }\n\n .section {\n margin-bottom: 20px;\n }\n .section-label {\n font-size: 12px;\n font-weight: 600;\n text-transform: uppercase;\n color: var(--secondary-text-color, #727272);\n margin: 0 0 8px 0;\n letter-spacing: 0.5px;\n }\n\n .field-row {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 8px 0;\n }\n .field-label {\n font-size: 14px;\n color: var(--primary-text-color, #212121);\n }\n\n select {\n padding: 6px 8px;\n border: 1px solid var(--divider-color, #e0e0e0);\n border-radius: 4px;\n background: var(--card-background-color, #fff);\n color: var(--primary-text-color, #212121);\n font-size: 14px;\n }\n\n input[type="number"] {\n width: 72px;\n padding: 6px 8px;\n border: 1px solid var(--divider-color, #e0e0e0);\n border-radius: 4px;\n background: var(--card-background-color, #fff);\n color: var(--primary-text-color, #212121);\n font-size: 14px;\n text-align: right;\n }\n input[type="number"]:disabled {\n opacity: 0.5;\n }\n\n .radio-group {\n display: flex;\n gap: 16px;\n padding: 8px 0;\n }\n .radio-group label {\n display: flex;\n align-items: center;\n gap: 6px;\n font-size: 14px;\n color: var(--primary-text-color, #212121);\n cursor: pointer;\n }\n\n .horizon-bar {\n display: flex;\n border: 1px solid var(--divider-color, #e0e0e0);\n border-radius: 6px;\n overflow: hidden;\n margin-top: 4px;\n }\n .horizon-segment {\n flex: 1;\n padding: 6px 0;\n text-align: center;\n font-size: 13px;\n cursor: pointer;\n background: var(--card-background-color, #fff);\n color: var(--primary-text-color, #212121);\n border: none;\n border-right: 1px solid var(--divider-color, #e0e0e0);\n transition: background 0.15s ease, color 0.15s ease;\n user-select: none;\n line-height: 1.4;\n }\n .horizon-segment:last-child {\n border-right: none;\n }\n .horizon-segment:hover:not(.active) {\n background: var(--secondary-background-color, #f5f5f5);\n }\n .horizon-segment.active {\n background: var(--primary-color, #03a9f4);\n color: #fff;\n font-weight: 600;\n }\n .horizon-segment.referenced {\n box-shadow: inset 0 -3px 0 var(--primary-color, #03a9f4);\n }\n\n .unit-toggle {\n display: inline-flex;\n border: 1px solid var(--divider-color, #e0e0e0);\n border-radius: 6px;\n overflow: hidden;\n }\n .unit-btn {\n padding: 4px 10px;\n border: none;\n border-right: 1px solid var(--divider-color, #e0e0e0);\n background: var(--card-background-color, #fff);\n color: var(--primary-text-color, #212121);\n font-size: 13px;\n font-weight: 500;\n cursor: pointer;\n transition: background 0.15s ease, color 0.15s ease;\n }\n .unit-btn:last-child {\n border-right: none;\n }\n .unit-btn:hover:not(.unit-active) {\n background: var(--secondary-background-color, #f5f5f5);\n }\n .unit-btn.unit-active {\n background: var(--primary-color, #03a9f4);\n color: #fff;\n font-weight: 600;\n }\n\n .monitoring-header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n }\n\n .fav-heart {\n background: none;\n border: 1px solid var(--divider-color, #e0e0e0);\n color: var(--secondary-text-color, #727272);\n border-radius: 4px;\n padding: 2px 6px;\n cursor: pointer;\n font-size: 0.9em;\n margin-right: 6px;\n line-height: 1;\n display: inline-flex;\n align-items: center;\n }\n .fav-heart.active {\n color: var(--primary-color, #03a9f4);\n border-color: var(--primary-color, #03a9f4);\n }\n .fav-heart:hover:not(.active) {\n background: var(--secondary-background-color, #f5f5f5);\n }\n .fav-heart span-icon {\n --mdc-icon-size: 16px;\n }\n\n .panel-mode-info {\n font-size: 14px;\n color: var(--primary-text-color, #212121);\n line-height: 1.6;\n }\n .panel-mode-info p {\n margin: 0 0 12px 0;\n }\n\n',e.appendChild(n);const i=document.createElement("div");i.className="backdrop",i.addEventListener("click",()=>this.close()),e.appendChild(i);const r=document.createElement("div");r.className="panel",e.appendChild(r),t.favoritesMode?this._renderFavoritesMode(r):t.panelMode?this._renderPanelMode(r):t.subDeviceMode?this._renderSubDeviceMode(r,t):this._renderCircuitMode(r,t)}_renderPanelMode(t){const e=this._config,n=this._createHeader(i("sidepanel.graph_settings"),i("sidepanel.global_defaults"));t.appendChild(n);const r=document.createElement("div");r.className="panel-body";const o=e.graphSettings,l=e.topology,c=o?.global_horizon??a,u=o?.circuits??{};r.appendChild(this._buildListColumnsSection());const h=document.createElement("div");h.className="section";const d=document.createElement("div");d.className="section-label",d.textContent=i("sidepanel.graph_horizon"),h.appendChild(d);const p=document.createElement("div");p.className="field-row";const g=document.createElement("span");g.className="field-label",g.textContent=i("sidepanel.global_default"),p.appendChild(g);const v=document.createElement("select");for(const t of Object.keys(s)){const e=document.createElement("option");e.value=t;const n=`horizon.${t}`,r=i(n);e.textContent=r!==n?r:t,t===c&&(e.selected=!0),v.appendChild(e)}if(v.addEventListener("change",()=>{const t={horizon:v.value};e.configEntryId&&(t.config_entry_id=e.configEntryId),this._callDomainService("set_graph_time_horizon",t).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(t=>{console.warn("SPAN Panel: graph horizon service failed",t),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:i("error.graph_horizon_failed"),persistent:!1})})}),p.appendChild(v),h.appendChild(p),r.appendChild(h),l?.circuits){const t=document.createElement("div");t.className="section";const n=document.createElement("div");n.className="section-label",n.textContent=i("sidepanel.circuit_scales"),t.appendChild(n);const o=Object.entries(l.circuits).sort(([,t],[,e])=>(t.name||"").localeCompare(e.name||""));for(const[n,i]of o){const r=this._buildPanelModeCircuitRow(n,i,u[n],c,e.configEntryId??null,e.showFavorites??!1,e.favoritePanelDeviceId,e.favoriteCircuitUuids);t.appendChild(r)}r.appendChild(t)}const m=o?.sub_devices??{};if(l?.sub_devices){const t=document.createElement("div");t.className="section";const n=document.createElement("div");n.className="section-label",n.textContent=i("sidepanel.subdevice_scales"),t.appendChild(n);const o=Object.entries(l.sub_devices).sort(([,t],[,e])=>(t.name||"").localeCompare(e.name||""));for(const[n,r]of o){const o=document.createElement("div");o.className="field-row";const a=document.createElement("span");if(a.className="field-label",a.textContent=r.name||n,a.style.cssText="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;flex:1;",o.appendChild(a),e.showFavorites&&e.favoritePanelDeviceId){const t=this._buildSubDeviceFavoriteHeart(r.entities,e.favoriteSubDeviceIds?.has(n)??!1);t&&o.appendChild(t)}const l=m[n]||{horizon:c,has_override:!1},u=l.has_override?l.horizon:c,h=document.createElement("select");h.dataset.subdevId=n;for(const t of Object.keys(s)){const e=document.createElement("option");e.value=t;const n=`horizon.${t}`,r=i(n);e.textContent=r!==n?r:t,t===u&&(e.selected=!0),h.appendChild(e)}if(h.addEventListener("change",()=>{this._debounce(`subdev-${n}`,f,()=>{const t={subdevice_id:n,horizon:h.value};e.configEntryId&&(t.config_entry_id=e.configEntryId),this._callDomainService("set_subdevice_graph_horizon",t).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(t=>{console.warn("SPAN Panel: graph horizon service failed",t),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:i("error.graph_horizon_failed"),persistent:!1})})})}),o.appendChild(h),l.has_override){const t=document.createElement("button");t.textContent="↺",t.title=i("sidepanel.reset_to_global"),Object.assign(t.style,{background:"none",border:"1px solid var(--divider-color, #e0e0e0)",color:"var(--primary-text-color)",borderRadius:"4px",padding:"3px 6px",cursor:"pointer",marginLeft:"4px",fontSize:"0.85em"}),t.addEventListener("click",()=>{const r={subdevice_id:n};e.configEntryId&&(r.config_entry_id=e.configEntryId),this._callDomainService("clear_subdevice_graph_horizon",r).then(()=>{h.value=c,t.remove(),this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(t=>{console.warn("SPAN Panel: graph horizon service failed",t),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:i("error.graph_horizon_failed"),persistent:!1})})}),o.appendChild(t)}t.appendChild(o)}r.appendChild(t)}t.appendChild(r)}_buildPanelModeCircuitRow(t,e,n,r,o,a,l,c){const u=document.createElement("div");u.className="field-row";const h=document.createElement("span");if(h.className="field-label",h.textContent=e.name||t,h.style.cssText="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;flex:1;",u.appendChild(h),a&&l){const n=this._buildFavoriteHeart(e.entities,c?.has(t)??!1);n&&u.appendChild(n)}const d=n||{horizon:r,has_override:!1},p=d.has_override?d.horizon:r,g=document.createElement("select");g.dataset.uuid=t;for(const t of Object.keys(s)){const e=document.createElement("option");e.value=t;const n=`horizon.${t}`,r=i(n);e.textContent=r!==n?r:t,t===p&&(e.selected=!0),g.appendChild(e)}if(g.addEventListener("change",()=>{this._debounce(`circuit-${t}`,f,()=>{const e={circuit_id:t,horizon:g.value};o&&(e.config_entry_id=o),this._callDomainService("set_circuit_graph_horizon",e).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(t=>{console.warn("SPAN Panel: graph horizon service failed",t),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:i("error.graph_horizon_failed"),persistent:!1})})})}),u.appendChild(g),d.has_override){const e=document.createElement("button");e.textContent="↺",e.title=i("sidepanel.reset_to_global"),Object.assign(e.style,{background:"none",border:"1px solid var(--divider-color, #e0e0e0)",color:"var(--primary-text-color)",borderRadius:"4px",padding:"3px 6px",cursor:"pointer",marginLeft:"4px",fontSize:"0.85em"}),e.addEventListener("click",()=>{const n={circuit_id:t};o&&(n.config_entry_id=o),this._callDomainService("clear_circuit_graph_horizon",n).then(()=>{g.value=r,e.remove(),this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(t=>{console.warn("SPAN Panel: graph horizon service failed",t),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:i("error.graph_horizon_failed"),persistent:!1})})}),u.appendChild(e)}return u}_renderFavoritesMode(t){const e=this._config,n=this._createHeader(i("sidepanel.graph_settings"),i("sidepanel.favorites_subtitle"));t.appendChild(n);const r=document.createElement("div");r.className="panel-body",r.appendChild(this._buildListColumnsSection());for(const t of e.perPanelSections)r.appendChild(this._buildFavoritesPanelSection(t));t.appendChild(r)}_buildFavoritesPanelSection(t){const e=document.createElement("div");e.className="section";const n=document.createElement("div");n.className="section-label",n.textContent=t.panelName,e.appendChild(n);const i=t.graphSettings?.global_horizon??a,r=t.graphSettings?.circuits??{},o=function(t){const e=t.circuits??{};return Object.entries(e).map(([t,e])=>({uuid:t,circuit:e})).sort((t,e)=>(t.circuit.name||"").localeCompare(e.circuit.name||""))}(t.topology);for(const{uuid:n,circuit:a}of o){const o=this._buildPanelModeCircuitRow(n,a,r[n],i,t.configEntryId,!0,t.panelDeviceId,t.favoriteCircuitUuids);e.appendChild(o)}return e}_renderCircuitMode(t,e){const n=`${Rt(String(e.breaker_rating_a))}A · ${Rt(String(e.voltage))}V · Tabs [${Rt(String(e.tabs))}]`,i=this._createHeader(Rt(e.name),n);t.appendChild(i);const r=document.createElement("div");r.className="panel-body",t.appendChild(r),this._renderRelaySection(r,e),e.showFavorites&&this._renderFavoriteSection(r,e),this._renderSheddingSection(r,e),this._renderGraphHorizonSection(r,e),e.showMonitoring&&this._renderMonitoringSection(r,e)}_favoriteEntityId(t){return t?.current??t?.power??null}_subDeviceFavoriteEntityId(t){if(!t)return null;let e=null;for(const[n,i]of Object.entries(t)){if("sensor"===i.domain)return n;e||(e=n)}return e}_buildSubDeviceFavoriteHeart(t,e){const n=this._subDeviceFavoriteEntityId(t);return n?this._buildHeartButton(n,e):null}_buildListColumnsSection(){const t=document.createElement("div");t.className="section";const e=document.createElement("div");e.className="section-label",e.textContent=i("sidepanel.list_view_columns"),t.appendChild(e);const n=document.createElement("div");n.className="field-row";const r=document.createElement("span");r.className="field-label",r.textContent=i("sidepanel.columns"),n.appendChild(r);const o=Bt(),a=document.createElement("div");a.className="unit-toggle";for(const t of[1,2,3]){const e=document.createElement("button");e.type="button",e.className="unit-btn"+(t===o?" unit-active":""),e.dataset.columns=String(t),e.textContent=String(t),e.addEventListener("click",()=>{Ft(t);for(const t of a.querySelectorAll(".unit-btn"))t.classList.toggle("unit-active",t===e);this.dispatchEvent(new CustomEvent("list-columns-changed",{detail:t,bubbles:!0,composed:!0}))}),a.appendChild(e)}return n.appendChild(a),t.appendChild(n),t}_buildFavoriteHeart(t,e){const n=this._favoriteEntityId(t);return n?this._buildHeartButton(n,e):(console.warn("SPAN Panel: circuit has no current/power sensor; favorite heart suppressed"),null)}_buildHeartButton(t,e){const n=document.createElement("button");n.type="button",n.className=e?"fav-heart active":"fav-heart",n.dataset.role="fav-heart",n.title=i("sidepanel.save_to_favorites"),n.setAttribute("role","switch"),n.setAttribute("aria-checked",String(e)),n.setAttribute("aria-label",i("sidepanel.save_to_favorites"));const r=document.createElement("span-icon");return r.setAttribute("icon",e?"mdi:heart":"mdi:heart-outline"),n.appendChild(r),n.addEventListener("click",e=>{e.stopPropagation(),this._toggleFavoriteEntity(n,r,t).catch(()=>{})}),n}async _toggleFavoriteEntity(t,e,n){if(!this._hass)return;const r=t.classList.contains("active"),o=!r;t.classList.toggle("active",o),e.setAttribute("icon",o?"mdi:heart":"mdi:heart-outline"),t.setAttribute("aria-checked",String(o));try{o?await async function(t,e){const n=await nT(t,"add_favorite",{entity_id:e});return document.dispatchEvent(new CustomEvent(eT)),n?.favorites??{}}(this._hass,n):await async function(t,e){const n=await nT(t,"remove_favorite",{entity_id:e});return document.dispatchEvent(new CustomEvent(eT)),n?.favorites??{}}(this._hass,n)}catch(n){throw t.classList.toggle("active",r),e.setAttribute("icon",r?"mdi:heart":"mdi:heart-outline"),t.setAttribute("aria-checked",String(r)),console.warn("SPAN Panel: favorite toggle failed",n),this.errorStore?.add({key:"service:favorites",level:"error",message:i("error.favorites_toggle_failed"),persistent:!1}),n}}_renderFavoriteSection(t,e){const n=this._favoriteEntityId(e.entities);n&&this._appendFavoriteHeartSection(t,n,!0===e.isFavorite)}_appendFavoriteHeartSection(t,e,n){const r=document.createElement("div");r.className="section",r.innerHTML=``;const o=document.createElement("div");o.className="field-row";const a=document.createElement("span");a.className="field-label",a.textContent=i("sidepanel.save_to_favorites"),o.appendChild(a),o.appendChild(this._buildHeartButton(e,n)),r.appendChild(o),t.appendChild(r)}_renderSubDeviceMode(t,e){const n=this._createHeader(Rt(e.name),Rt(e.deviceType));t.appendChild(n);const i=document.createElement("div");i.className="panel-body",t.appendChild(i),e.showFavorites&&this._renderSubDeviceFavoriteSection(i,e),this._renderSubDeviceHorizonSection(i,e)}_renderSubDeviceFavoriteSection(t,e){const n=this._subDeviceFavoriteEntityId(e.entities);n&&this._appendFavoriteHeartSection(t,n,!0===e.isFavorite)}_renderSubDeviceHorizonSection(t,e){const n=document.createElement("div");n.className="section";const r=document.createElement("div");r.className="section-label",r.textContent=i("sidepanel.graph_horizon"),n.appendChild(r);const o=e.graphHorizonInfo,l=!0===o?.has_override,c=o?.horizon||a,u=o?.globalHorizon||a,h=document.createElement("div");h.className="horizon-bar";const d=[{key:"global",label:i("sidepanel.global")}];for(const t of Object.keys(s))d.push({key:t,label:t});const p=l?c:"global",f=t=>{for(const e of h.querySelectorAll(".horizon-segment")){const n=e.dataset.horizon;e.classList.toggle("active",n===t),e.classList.toggle("referenced","global"===t&&n===u)}};for(const{key:t,label:n}of d){const r=document.createElement("button");r.type="button",r.className="horizon-segment",r.dataset.horizon=t,r.textContent=n,r.classList.toggle("active",t===p),r.classList.toggle("referenced","global"===p&&t===u),r.addEventListener("click",()=>{if(r.classList.contains("active"))return;const n={subdevice_id:e.subDeviceId};e.configEntryId&&(n.config_entry_id=e.configEntryId),"global"===t?(f("global"),this._callDomainService("clear_subdevice_graph_horizon",n).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(t=>{console.warn("SPAN Panel: graph horizon service failed",t),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:i("error.graph_horizon_failed"),persistent:!1})})):(f(t),this._callDomainService("set_subdevice_graph_horizon",{...n,horizon:t}).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(t=>{console.warn("SPAN Panel: graph horizon service failed",t),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:i("error.graph_horizon_failed"),persistent:!1})}))}),h.appendChild(r)}n.appendChild(h),t.appendChild(n)}_createHeader(t,e){const n=document.createElement("div");n.className="panel-header";const i=document.createElement("div"),r=Rt(t),o=Rt(e);i.innerHTML=`
${r}
`+(o?`
${o}
`:"");const a=document.createElement("button");return a.className="close-btn",a.innerHTML="✕",a.addEventListener("click",()=>this.close()),n.appendChild(i),n.appendChild(a),n}_renderRelaySection(t,e){if(!1===e.is_user_controllable||!e.entities?.switch)return;const n=document.createElement("div");n.className="section",n.innerHTML=``;const r=document.createElement("div");r.className="field-row";const o=document.createElement("span");o.className="field-label",o.textContent=i("sidepanel.breaker");const a=document.createElement("span-switch");a.dataset.role="relay-toggle";const s=e.entities.switch,l=this._hass?.states?.[s]?.state;"on"===l&&a.setAttribute("checked",""),a.addEventListener("change",()=>{const t=a.hasAttribute("checked")||a.checked;this._callService("switch",t?"turn_on":"turn_off",{entity_id:s}).catch(t=>{console.warn("SPAN Panel: relay toggle failed",t),this.errorStore?.add({key:"service:relay",level:"error",message:i("error.relay_failed"),persistent:!1})})}),r.appendChild(o),r.appendChild(a),n.appendChild(r),t.appendChild(n)}_renderSheddingSection(t,e){if(!e.entities?.select)return;const n=document.createElement("div");n.className="section",n.innerHTML=``;const r=document.createElement("div");r.className="field-row";const o=document.createElement("span");o.className="field-label",o.textContent=i("sidepanel.priority_label");const a=document.createElement("select");a.dataset.role="shedding-select";const s=e.entities.select,l=this._hass?.states?.[s]?.state||"";for(const t of iT){const e=m[t];if(!e)continue;const n=document.createElement("option");n.value=t,n.textContent=i(`shedding.select.${t}`)||e.label(),t===l&&(n.selected=!0),a.appendChild(n)}a.addEventListener("change",()=>{this._callService("select","select_option",{entity_id:s,option:a.value}).catch(t=>{console.warn("SPAN Panel: shedding update failed",t),this.errorStore?.add({key:"service:shedding",level:"error",message:i("error.shedding_failed"),persistent:!1})})}),r.appendChild(o),r.appendChild(a),n.appendChild(r),t.appendChild(n)}_renderGraphHorizonSection(t,e){const n=document.createElement("div");n.className="section";const r=document.createElement("div");r.className="section-label",r.textContent=i("sidepanel.graph_horizon"),n.appendChild(r);const o=e.graphHorizonInfo,l=!0===o?.has_override,c=o?.horizon||a,u=o?.globalHorizon||a,h=document.createElement("div");h.className="horizon-bar";const d=[{key:"global",label:i("sidepanel.global")}];for(const t of Object.keys(s))d.push({key:t,label:t});const p=l?c:"global",f=t=>{for(const e of h.querySelectorAll(".horizon-segment")){const n=e.dataset.horizon;e.classList.toggle("active",n===t),e.classList.toggle("referenced","global"===t&&n===u)}};for(const{key:t,label:n}of d){const r=document.createElement("button");r.type="button",r.className="horizon-segment",r.dataset.horizon=t,r.textContent=n,r.classList.toggle("active",t===p),r.classList.toggle("referenced","global"===p&&t===u),r.addEventListener("click",()=>{if(r.classList.contains("active"))return;const n={circuit_id:e.uuid};e.configEntryId&&(n.config_entry_id=e.configEntryId),"global"===t?(f("global"),this._callDomainService("clear_circuit_graph_horizon",n).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(t=>{console.warn("SPAN Panel: graph horizon service failed",t),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:i("error.graph_horizon_failed"),persistent:!1})})):(f(t),this._callDomainService("set_circuit_graph_horizon",{...n,horizon:t}).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(t=>{console.warn("SPAN Panel: graph horizon service failed",t),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:i("error.graph_horizon_failed"),persistent:!1})}))}),h.appendChild(r)}n.appendChild(h),t.appendChild(n)}_renderMonitoringSection(t,e){const n=document.createElement("div");n.className="section";const r=document.createElement("div");r.className="monitoring-header";const o=document.createElement("div");o.className="section-label",o.textContent=i("sidepanel.monitoring"),o.style.margin="0";const a=document.createElement("span-switch");a.dataset.role="monitoring-toggle";const s=e.monitoringInfo,l=null!=s&&!1!==s.monitoring_enabled;l&&a.setAttribute("checked",""),r.appendChild(o),r.appendChild(a),n.appendChild(r);const c=document.createElement("div");c.dataset.role="monitoring-details",c.style.display=l?"block":"none",n.appendChild(c);const u=!0===s?.has_override,h=document.createElement("div");h.className="radio-group",h.innerHTML=`\n \n \n `,c.appendChild(h);const d=document.createElement("div");d.dataset.role="threshold-fields",d.style.display=u?"block":"none";const p=s?.continuous_threshold_pct??80,f=s?.spike_threshold_pct??100,g=s?.window_duration_m??15,v=s?.cooldown_duration_m??15;d.appendChild(this._createThresholdRow(i("sidepanel.continuous_pct"),"continuous",p,e)),d.appendChild(this._createThresholdRow(i("sidepanel.spike_pct"),"spike",f,e)),d.appendChild(this._createDurationRow(i("sidepanel.window_duration"),"window-m",g,1,180,"m",e)),d.appendChild(this._createDurationRow(i("sidepanel.cooldown"),"cooldown-m",v,1,180,"m",e)),c.appendChild(d),a.addEventListener("change",()=>{const t=a.checked;c.style.display=t?"block":"none";const n={circuit_id:e.entities?.power||e.uuid,monitoring_enabled:t};e.configEntryId&&(n.config_entry_id=e.configEntryId),this._callDomainService("set_circuit_threshold",n).catch(t=>{console.warn("SPAN Panel: monitoring update failed",t),this.errorStore?.add({key:"service:monitoring",level:"error",message:i("error.threshold_failed"),persistent:!1})})});const m=h.querySelectorAll('input[type="radio"]');for(const t of m)t.addEventListener("change",()=>{const n="custom"===t.value&&t.checked;if(d.style.display=n?"block":"none",!n&&t.checked){const t={circuit_id:e.entities?.power||e.uuid};e.configEntryId&&(t.config_entry_id=e.configEntryId),this._callDomainService("clear_circuit_threshold",t).catch(t=>{console.warn("SPAN Panel: monitoring update failed",t),this.errorStore?.add({key:"service:monitoring",level:"error",message:i("error.threshold_failed"),persistent:!1})})}});t.appendChild(n)}_createThresholdRow(t,e,n,r){const o=document.createElement("div");o.className="field-row";const a=document.createElement("span");a.className="field-label",a.textContent=t;const s=document.createElement("input");return s.type="number",s.min="0",s.max="200",s.value=String(n),s.dataset.role=`threshold-${e}`,s.addEventListener("input",()=>{this._debounce(`threshold-${e}`,f,()=>{const t=this.shadowRoot;if(!t)return;const e=t.querySelector('[data-role="threshold-continuous"]'),n=t.querySelector('[data-role="threshold-spike"]'),o=t.querySelector('[data-role="threshold-window-m"]'),a=t.querySelector('[data-role="threshold-cooldown-m"]'),s={circuit_id:r.entities?.power||r.uuid,continuous_threshold_pct:e?Number(e.value):void 0,spike_threshold_pct:n?Number(n.value):void 0,window_duration_m:o?Number(o.value):void 0,cooldown_duration_m:a?Number(a.value):void 0};r.configEntryId&&(s.config_entry_id=r.configEntryId),this._callDomainService("set_circuit_threshold",s).catch(t=>{console.warn("SPAN Panel: monitoring update failed",t),this.errorStore?.add({key:"service:monitoring",level:"error",message:i("error.threshold_failed"),persistent:!1})})})}),o.appendChild(a),o.appendChild(s),o}_createDurationRow(t,e,n,r,o,a,s,l=!1){const c=document.createElement("div");c.className="field-row";const u=document.createElement("span");u.className="field-label",u.textContent=t;const h=document.createElement("div"),d=document.createElement("input");d.type="number",d.min=String(r),d.max=String(o),d.value=String(n),d.dataset.role=`threshold-${e}`,l&&(d.disabled=!0);const p=document.createElement("span");return p.textContent=a,h.appendChild(d),h.appendChild(p),l||d.addEventListener("input",()=>{this._debounce(`threshold-${e}`,f,()=>{const t=this.shadowRoot;if(!t)return;const e=t.querySelector('[data-role="threshold-continuous"]'),n=t.querySelector('[data-role="threshold-spike"]'),r=t.querySelector('[data-role="threshold-window-m"]'),o={circuit_id:s.uuid,continuous_threshold_pct:e?Number(e.value):void 0,spike_threshold_pct:n?Number(n.value):void 0,window_duration_m:r?Number(r.value):void 0};s.configEntryId&&(o.config_entry_id=s.configEntryId),this._callDomainService("set_circuit_threshold",o).catch(t=>{console.warn("SPAN Panel: monitoring update failed",t),this.errorStore?.add({key:"service:monitoring",level:"error",message:i("error.threshold_failed"),persistent:!1})})})}),c.appendChild(u),c.appendChild(h),c}_updateLiveState(){if(!this._config||this._config.panelMode)return;const t=this._config;if(!t.subDeviceMode&&!t.favoritesMode){if(t.entities?.switch){const e=this.shadowRoot?.querySelector('[data-role="relay-toggle"]');if(e){const n=this._hass?.states?.[t.entities.switch]?.state;"on"===n?e.setAttribute("checked",""):e.removeAttribute("checked")}}if(t.entities?.select){const e=this.shadowRoot?.querySelector('[data-role="shedding-select"]');if(e){const n=this._hass?.states?.[t.entities.select]?.state||"";e.value=n}}}}_callService(t,e,n){return this._hass?Promise.resolve(this._hass.callService(t,e,n)):Promise.resolve()}_callDomainService(t,e){return this._hass?this._hass.callWS({type:"call_service",domain:l,service:t,service_data:e}):Promise.resolve()}_debounce(t,e,n){this._debounceTimers[t]&&clearTimeout(this._debounceTimers[t]),this._debounceTimers[t]=setTimeout(()=>{delete this._debounceTimers[t],n()},e)}}try{customElements.get("span-side-panel")||customElements.define("span-side-panel",rT)}catch{}class oT extends At{constructor(){super(...arguments),this._store=null,this._unsub=null,this._errors=[]}set store(t){if(this._store===t)return;this._unsub?.(),this._unsub=null,this._store=t,this._errors=t.active;const e=t;this._unsub=t.subscribe(()=>{this._errors=e.active})}connectedCallback(){if(super.connectedCallback(),this._store&&!this._unsub){const t=this._store;this._errors=t.active,this._unsub=t.subscribe(()=>{this._errors=t.active})}}disconnectedCallback(){super.disconnectedCallback(),this._unsub?.(),this._unsub=null}render(){return 0===this._errors.length?ft:ht`${this._errors.map(t=>ht` + + `)}`}_iconForLevel(t){switch(t){case"error":return"mdi:alert-circle";case"warning":return"mdi:alert";default:return"mdi:information"}}}oT.styles=T` + :host { + display: block; + } + .banner-row { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + font-size: 13px; + line-height: 1.4; + } + .banner-row + .banner-row { + border-top: 1px solid rgba(128, 128, 128, 0.2); + } + .banner-row.level-error { + background: color-mix(in srgb, var(--error-color, #db4437) 15%, transparent); + color: var(--error-color, #db4437); + } + .banner-row.level-warning { + background: color-mix(in srgb, var(--warning-color, #ff9800) 15%, transparent); + color: var(--warning-color, #ff9800); + } + .banner-row.level-info { + background: color-mix(in srgb, var(--info-color, #4285f4) 15%, transparent); + color: var(--info-color, #4285f4); + } + .icon { + flex-shrink: 0; + width: 18px; + height: 18px; + --mdc-icon-size: 18px; + } + .message { + flex: 1; + min-width: 0; + } + .retry-btn { + flex-shrink: 0; + background: none; + border: 1px solid currentColor; + border-radius: 4px; + color: inherit; + cursor: pointer; + font-size: 12px; + padding: 2px 8px; + } + .retry-btn:hover { + opacity: 0.8; + } + `,x([Ot()],oT.prototype,"_errors",void 0);try{customElements.get("span-error-banner")||customElements.define("span-error-banner",oT)}catch{}const aT=Object.freeze({"mdi:alert":"M13 14H11V9H13M13 18H11V16H13M1 21H23L12 2L1 21Z","mdi:alert-circle":"M13,13H11V7H13M13,17H11V15H13M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z","mdi:battery":"M16.67,4H15V2H9V4H7.33A1.33,1.33 0 0,0 6,5.33V20.67C6,21.4 6.6,22 7.33,22H16.67A1.33,1.33 0 0,0 18,20.67V5.33C18,4.6 17.4,4 16.67,4Z","mdi:battery-alert-variant-outline":"M14 20H6V6H14M14.67 4H13V2H7V4H5.33C4.6 4 4 4.6 4 5.33V20.67C4 21.4 4.6 22 5.33 22H14.67C15.4 22 16 21.4 16 20.67V5.33C16 4.6 15.4 4 14.67 4M21 7H19V13H21V8M21 15H19V17H21V15Z","mdi:chevron-down":"M7.41,8.58L12,13.17L16.59,8.58L18,10L12,16L6,10L7.41,8.58Z","mdi:close":"M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z","mdi:cog":"M12,15.5A3.5,3.5 0 0,1 8.5,12A3.5,3.5 0 0,1 12,8.5A3.5,3.5 0 0,1 15.5,12A3.5,3.5 0 0,1 12,15.5M19.43,12.97C19.47,12.65 19.5,12.33 19.5,12C19.5,11.67 19.47,11.34 19.43,11L21.54,9.37C21.73,9.22 21.78,8.95 21.66,8.73L19.66,5.27C19.54,5.05 19.27,4.96 19.05,5.05L16.56,6.05C16.04,5.66 15.5,5.32 14.87,5.07L14.5,2.42C14.46,2.18 14.25,2 14,2H10C9.75,2 9.54,2.18 9.5,2.42L9.13,5.07C8.5,5.32 7.96,5.66 7.44,6.05L4.95,5.05C4.73,4.96 4.46,5.05 4.34,5.27L2.34,8.73C2.21,8.95 2.27,9.22 2.46,9.37L4.57,11C4.53,11.34 4.5,11.67 4.5,12C4.5,12.33 4.53,12.65 4.57,12.97L2.46,14.63C2.27,14.78 2.21,15.05 2.34,15.27L4.34,18.73C4.46,18.95 4.73,19.03 4.95,18.95L7.44,17.94C7.96,18.34 8.5,18.68 9.13,18.93L9.5,21.58C9.54,21.82 9.75,22 10,22H14C14.25,22 14.46,21.82 14.5,21.58L14.87,18.93C15.5,18.67 16.04,18.34 16.56,17.94L19.05,18.95C19.27,19.03 19.54,18.95 19.66,18.73L21.66,15.27C21.78,15.05 21.73,14.78 21.54,14.63L19.43,12.97Z","mdi:heart":"M12,21.35L10.55,20.03C5.4,15.36 2,12.27 2,8.5C2,5.41 4.42,3 7.5,3C9.24,3 10.91,3.81 12,5.08C13.09,3.81 14.76,3 16.5,3C19.58,3 22,5.41 22,8.5C22,12.27 18.6,15.36 13.45,20.03L12,21.35Z","mdi:heart-outline":"M12.1,18.55L12,18.65L11.89,18.55C7.14,14.24 4,11.39 4,8.5C4,6.5 5.5,5 7.5,5C9.04,5 10.54,6 11.07,7.36H12.93C13.46,6 14.96,5 16.5,5C18.5,5 20,6.5 20,8.5C20,11.39 16.86,14.24 12.1,18.55M16.5,3C14.76,3 13.09,3.81 12,5.08C10.91,3.81 9.24,3 7.5,3C4.42,3 2,5.41 2,8.5C2,12.27 5.4,15.36 10.55,20.03L12,21.35L13.45,20.03C18.6,15.36 22,12.27 22,8.5C22,5.41 19.58,3 16.5,3Z","mdi:help":"M10,19H13V22H10V19M12,2C17.35,2.22 19.68,7.62 16.5,11.67C15.67,12.67 14.33,13.33 13.67,14.17C13,15 13,16 13,17H10C10,15.33 10,13.92 10.67,12.92C11.33,11.92 12.67,11.33 13.5,10.67C15.92,8.43 15.32,5.26 12,5A3,3 0 0,0 9,8H6A6,6 0 0,1 12,2Z","mdi:help-circle-outline":"M11,18H13V16H11V18M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M12,20C7.59,20 4,16.41 4,12C4,7.59 7.59,4 12,4C16.41,4 20,7.59 20,12C20,16.41 16.41,20 12,20M12,6A4,4 0 0,0 8,10H10A2,2 0 0,1 12,8A2,2 0 0,1 14,10C14,12 11,11.75 11,15H13C13,12.75 16,12.5 16,10A4,4 0 0,0 12,6Z","mdi:home-group":"M17,16H15V22H12V17H8V22H5V16H3L10,10L17,16M6,2L10,6H9V9H7V6H5V9H3V6H2L6,2M18,3L23,8H22V12H19V9H17V12H15.34L14,10.87V8H13L18,3Z","mdi:information":"M13,9H11V7H13M13,17H11V11H13M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z","mdi:lock":"M12,17A2,2 0 0,0 14,15C14,13.89 13.1,13 12,13A2,2 0 0,0 10,15A2,2 0 0,0 12,17M18,8A2,2 0 0,1 20,10V20A2,2 0 0,1 18,22H6A2,2 0 0,1 4,20V10C4,8.89 4.9,8 6,8H7V6A5,5 0 0,1 12,1A5,5 0 0,1 17,6V8H18M12,3A3,3 0 0,0 9,6V8H15V6A3,3 0 0,0 12,3Z","mdi:lock-open":"M18,8A2,2 0 0,1 20,10V20A2,2 0 0,1 18,22H6C4.89,22 4,21.1 4,20V10A2,2 0 0,1 6,8H15V6A3,3 0 0,0 12,3A3,3 0 0,0 9,6H7A5,5 0 0,1 12,1A5,5 0 0,1 17,6V8H18M12,17A2,2 0 0,0 14,15A2,2 0 0,0 12,13A2,2 0 0,0 10,15A2,2 0 0,0 12,17Z","mdi:menu":"M3,6H21V8H3V6M3,11H21V13H3V11M3,16H21V18H3V16Z","mdi:monitor-eye":"M3 4V16H21V4H3M3 2H21C22.1 2 23 2.89 23 4V16C23 16.53 22.79 17.04 22.41 17.41C22.04 17.79 21.53 18 21 18H14V20H16V22H8V20H10V18H3C2.47 18 1.96 17.79 1.59 17.41C1.21 17.04 1 16.53 1 16V4C1 2.89 1.89 2 3 2M10.84 8.93C11.15 8.63 11.57 8.45 12 8.45C12.43 8.46 12.85 8.63 13.16 8.94C13.46 9.24 13.64 9.66 13.64 10.09C13.64 10.53 13.46 10.94 13.16 11.25C12.85 11.56 12.43 11.73 12 11.73C11.57 11.73 11.15 11.55 10.84 11.25C10.54 10.94 10.36 10.53 10.36 10.09C10.36 9.66 10.54 9.24 10.84 8.93M10.07 12C10.58 12.53 11.28 12.82 12 12.82C12.72 12.82 13.42 12.53 13.93 12C14.44 11.5 14.73 10.81 14.73 10.09C14.73 9.37 14.44 8.67 13.93 8.16C13.42 7.65 12.72 7.36 12 7.36C11.28 7.36 10.58 7.65 10.07 8.16C9.56 8.67 9.27 9.37 9.27 10.09C9.27 10.81 9.56 11.5 10.07 12M6 10.09C6.94 7.7 9.27 6 12 6C14.73 6 17.06 7.7 18 10.09C17.06 12.5 14.73 14.18 12 14.18C9.27 14.18 6.94 12.5 6 10.09Z","mdi:router-wireless":"M20.2,5.9L21,5.1C19.6,3.7 17.8,3 16,3C14.2,3 12.4,3.7 11,5.1L11.8,5.9C13,4.8 14.5,4.2 16,4.2C17.5,4.2 19,4.8 20.2,5.9M19.3,6.7C18.4,5.8 17.2,5.3 16,5.3C14.8,5.3 13.6,5.8 12.7,6.7L13.5,7.5C14.2,6.8 15.1,6.5 16,6.5C16.9,6.5 17.8,6.8 18.5,7.5L19.3,6.7M19,13H17V9H15V13H5A2,2 0 0,0 3,15V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V15A2,2 0 0,0 19,13M8,18H6V16H8V18M11.5,18H9.5V16H11.5V18M15,18H13V16H15V18Z","mdi:sort-descending":"M19 7H22L18 3L14 7H17V21H19M2 17H12V19H2M6 5V7H2V5M2 11H9V13H2V11Z","mdi:transmission-tower":"M8.28,5.45L6.5,4.55L7.76,2H16.23L17.5,4.55L15.72,5.44L15,4H9L8.28,5.45M18.62,8H14.09L13.3,5H10.7L9.91,8H5.38L4.1,10.55L5.89,11.44L6.62,10H17.38L18.1,11.45L19.89,10.56L18.62,8M17.77,22H15.7L15.46,21.1L12,15.9L8.53,21.1L8.3,22H6.23L9.12,11H11.19L10.83,12.35L12,14.1L13.16,12.35L12.81,11H14.88L17.77,22M11.4,15L10.5,13.65L9.32,18.13L11.4,15M14.68,18.12L13.5,13.64L12.6,15L14.68,18.12Z","mdi:view-dashboard":"M13,3V9H21V3M13,21H21V11H13M3,21H11V15H3M3,13H11V3H3V13Z"}),sT=new Set;class lT extends At{constructor(){super(...arguments),this.icon=""}render(){if(!this.icon)return ft;const t=aT[this.icon];return t?ht``:(e=this.icon,sT.has(e)||(sT.add(e),console.warn(`SPAN: unknown icon "${e}". Add it to MDI_PATHS in span-icon.ts.`)),ft);var e}}lT.styles=T` + :host { + display: inline-flex; + align-items: center; + justify-content: center; + width: var(--mdc-icon-size, 24px); + height: var(--mdc-icon-size, 24px); + vertical-align: middle; + color: inherit; + flex-shrink: 0; + } + svg { + width: 100%; + height: 100%; + display: block; + fill: currentColor; + } + `,x([zt({type:String})],lT.prototype,"icon",void 0);try{customElements.get("span-icon")||customElements.define("span-icon",lT)}catch{}class cT extends At{constructor(){super(...arguments),this.checked=!1,this.disabled=!1,this._onActivate=t=>{if(this.disabled)return t.preventDefault(),void t.stopPropagation();this.checked=!this.checked,this.dispatchEvent(new Event("change",{bubbles:!0,composed:!0}))},this._onKeydown=t=>{" "!==t.key&&"Enter"!==t.key||(t.preventDefault(),this._onActivate(t))}}connectedCallback(){super.connectedCallback(),this.hasAttribute("tabindex")||this.setAttribute("tabindex","0"),this.hasAttribute("role")||this.setAttribute("role","switch"),this.setAttribute("aria-checked",String(this.checked)),this.addEventListener("click",this._onActivate),this.addEventListener("keydown",this._onKeydown)}disconnectedCallback(){this.removeEventListener("click",this._onActivate),this.removeEventListener("keydown",this._onKeydown),super.disconnectedCallback()}updated(t){t.has("checked")&&this.setAttribute("aria-checked",String(this.checked)),t.has("disabled")&&this.setAttribute("aria-disabled",String(this.disabled))}render(){return ht` +
+
+
+ `}}cT.styles=T` + :host { + --span-switch-on: var(--switch-checked-color, var(--primary-color, #4dd9af)); + --span-switch-off: var(--switch-unchecked-track-color, rgba(120, 120, 120, 0.45)); + --span-switch-thumb-on: var(--switch-checked-button-color, #fff); + --span-switch-thumb-off: var(--switch-unchecked-button-color, #fafafa); + --span-switch-track-w: 36px; + --span-switch-track-h: 20px; + --span-switch-thumb: 14px; + display: inline-flex; + align-items: center; + vertical-align: middle; + cursor: pointer; + user-select: none; + -webkit-tap-highlight-color: transparent; + } + :host([disabled]) { + cursor: not-allowed; + opacity: 0.5; + } + .track { + position: relative; + width: var(--span-switch-track-w); + height: var(--span-switch-track-h); + border-radius: calc(var(--span-switch-track-h) / 2); + background: var(--span-switch-off); + transition: background 0.2s ease; + } + .thumb { + position: absolute; + top: 50%; + left: 3px; + width: var(--span-switch-thumb); + height: var(--span-switch-thumb); + border-radius: 50%; + background: var(--span-switch-thumb-off); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.35); + transform: translateY(-50%); + transition: + left 0.2s ease, + background 0.2s ease; + } + :host([checked]) .track { + background: var(--span-switch-on); + } + :host([checked]) .thumb { + left: calc(var(--span-switch-track-w) - var(--span-switch-thumb) - 3px); + background: var(--span-switch-thumb-on); + } + :host(:focus-visible) .track { + outline: 2px solid var(--span-switch-on); + outline-offset: 2px; + } + `,x([zt({type:Boolean,reflect:!0})],cT.prototype,"checked",void 0),x([zt({type:Boolean,reflect:!0})],cT.prototype,"disabled",void 0);try{customElements.get("span-switch")||customElements.define("span-switch",cT)}catch{}const uT=[{name:"Kitchen",watts:"120",path:"M0,28 L8,26 L16,24 L24,22 L32,25 L40,20 L48,18 L56,22 L64,19 L72,16 L80,18 L88,15 L96,17 L104,14 L112,16 L120,13"},{name:"Living Room",watts:"85",path:"M0,22 L8,24 L16,20 L24,26 L32,18 L40,22 L48,16 L56,20 L64,24 L72,18 L80,22 L88,20 L96,16 L104,22 L112,18 L120,20"},{name:"Master Bed",watts:"193",path:"M0,8 L8,10 L16,8 L24,12 L32,10 L40,8 L48,10 L56,8 L64,10 L72,8 L80,12 L88,10 L96,8 L104,10 L112,8 L120,10"},{name:"HVAC",watts:"64",path:"M0,30 L8,28 L16,26 L24,22 L32,18 L40,14 L48,18 L56,22 L64,26 L72,22 L80,18 L88,22 L96,26 L104,22 L112,18 L120,22"}];let hT=class extends At{constructor(){super(...arguments),this._config={},this._discovered=!1,this._discovering=!1,this._topology=null,this._activeTab="panel",this._panelDevice=null,this._panelSize=0,this._historyLoaded=!1,this._ctrl=new Bk,this._listCtrl=new Zk(this._ctrl),this._errorStore=new Qk,this._areaUnsub=null,this._areaSubscribing=!1,this._tabBarCleanup=null}get _configEntryId(){return this._panelDevice?.config_entries?.[0]??null}get _root(){const t=this.shadowRoot;if(!t)throw new Error("span-panel-card: shadow root is not available");return t}connectedCallback(){super.connectedCallback(),this._ctrl.startIntervals(this._root)}disconnectedCallback(){this._ctrl.stopIntervals(),this._listCtrl.stop(),this._areaSubscribing=!1,this._areaUnsub&&(this._areaUnsub(),this._areaUnsub=null),this._tabBarCleanup&&(this._tabBarCleanup(),this._tabBarCleanup=null),this._errorStore.dispose(),super.disconnectedCallback()}setConfig(t){this._errorStore.clear(),this._config=t,this._discovered=!1,this._discovering=!1,this._historyLoaded=!1,this._topology=null,this._panelDevice=null,this._panelSize=0,this._activeTab="panel",this._ctrl.reset(),this._ctrl.setConfig(t),this._ctrl.errorStore=this._errorStore}getCardSize(){return Math.ceil(this._panelSize/2)+3}static getConfigElement(){return document.createElement("span-panel-card-editor")}static getStubConfig(){return{device_id:"",history_days:0,history_hours:0,history_minutes:5,chart_metric:o,show_panel:!0,show_battery:!0,show_evse:!0}}render(){if(n(this.hass?.language),!this._config.device_id)return this._renderPreview();if(!this._discovered){const t=this._errorStore.hasPersistent("discovery-failed");return ht` +
+ + ${t?ft:ht`
${Rt(i("card.connecting"))}
`} +
+ `}return ht` +
+ +
+
+
+ + `}updated(t){if(t.has("hass")&&this.hass&&(n(this.hass.language),this._ctrl.hass=this.hass,this._errorStore.updateHass(this.hass),this._config.device_id))if(this._discovered||this._discovering){if(this._discovered){this._ctrl.recordSamples(),this._ctrl.updateDOM(this._root);const t=this._root.querySelector("span-side-panel");t&&(t.hass=this.hass,t.errorStore=this._errorStore)}this._discovered&&"panel"!==this._activeTab&&this._topology&&this._listCtrl.updateCollapsedRows(this._root,this.hass,this._topology,this._config)}else this._startDiscovery()}async _startDiscovery(){this._discovering||(this._discovering=!0,await this._discoverTopology(),this._errorStore.hasPersistent("discovery-failed")?this._discovering=!1:(this._discovered=!0,this._discovering=!1,this._ctrl.init(this._topology,this._config,this.hass,this._configEntryId),this._topology?.panel_entities?.panel_status&&(this._errorStore.watchPanelStatus(this._topology.panel_entities.panel_status),this._errorStore.updateHass(this.hass)),this._topology&&(this._areaSubscribing=!0,async function(t,e,n,r){if(!t.connection)return()=>{};const o=async()=>{try{const i=new Map;for(const[t,n]of Object.entries(e.circuits))i.set(t,n.area);await Kk(t,e);for(const[t,r]of Object.entries(e.circuits))if(r.area!==i.get(t))return void n()}catch(t){console.warn("[span-panel] area registry update failed:",t),r?.add({key:"fetch:areas",level:"warning",message:i("error.areas_failed"),persistent:!1})}},[a,s]=await Promise.all([t.connection.subscribeEvents(o,"entity_registry_updated"),t.connection.subscribeEvents(o,"area_registry_updated")]);return()=>{a(),s()}}(this.hass,this._topology,()=>{"area"===this._activeTab&&this._discovered&&this._populateCardContent()},this._errorStore).then(t=>{this._areaSubscribing?this._areaUnsub=t:t()}).catch(t=>{this._areaSubscribing=!1,console.warn("SPAN Panel: area subscription failed",t),this._errorStore.add({key:"subscribe:area",level:"warning",message:i("error.areas_failed"),persistent:!1})})),await this.updateComplete,this._populateCardContent(),this._loadHistory(),this._ctrl.monitoringCache.fetch(this.hass,this._configEntryId).then(()=>{this._discovered&&this._ctrl.updateDOM(this._root)})))}async _discoverTopology(){if(!this.hass)return;const t=new Qt(this._errorStore);try{const e=await async function(t,e,n){if(!e)throw new Error(i("card.device_not_found"));const r={type:`${l}/panel_topology`,device_id:e},o=n?await n.callWS(t,r,{errorId:"fetch:topology"}):await t.callWS(r),a=o.panel_size??Jk(o.circuits);if(!a)throw new Error(i("card.topology_error"));const s={type:"config/device_registry/list"},c=tT((n?await n.callWS(t,s,{errorId:"fetch:topology"}):await t.callWS(s)).find(t=>t.id===e));return await Kk(t,o),{topology:o,panelDevice:c,panelSize:a}}(this.hass,this._config.device_id,t);this._topology=e.topology,this._panelDevice=e.panelDevice,this._panelSize=e.panelSize}catch(e){console.error("SPAN Panel: topology fetch failed, falling back to entity discovery",e);try{const e=await async function(t,e,n){const r={type:"config/device_registry/list"},o={type:"config/entity_registry/list"},[a,s]=await Promise.all([n?n.callWS(t,r,{errorId:"fetch:topology"}):t.callWS(r),n?n.callWS(t,o,{errorId:"fetch:topology"}):t.callWS(o)]),c=tT(a.find(t=>t.id===e));if(!c)return{topology:null,panelDevice:null,panelSize:0};const u=s.filter(t=>t.device_id===e),h=a.filter(t=>t.via_device_id===e),d=new Set(h.map(t=>t.id)),p=s.filter(t=>void 0!==t.device_id&&d.has(t.device_id)),f={},g=c.name_by_user??c.name??"";for(const e of[...u,...p]){const n=t.states[e.entity_id];if(!n)continue;const i=n.attributes,r=i.tabs;if("string"!=typeof r||!r.startsWith("tabs ["))continue;const o=r.slice(6,-1);let a;if(a=o.includes(":")?o.split(":").map(Number):[Number(o)],!a.every(Number.isFinite))continue;const s=e.unique_id.split("_");let l=null;for(let t=2;t=16&&/^[a-f0-9]+$/i.test(e)){l=e;break}}if(!l)continue;let c=("string"==typeof i.friendly_name?i.friendly_name:void 0)??e.entity_id;for(const t of[" Power"," Consumed Energy"," Produced Energy"])if(c.endsWith(t)){c=c.slice(0,-t.length);break}g&&c.startsWith(g+" ")&&(c=c.slice(g.length+1));const u=e.entity_id.replace(/^sensor\./,"").replace(/_power$/,""),h="number"==typeof i.voltage?i.voltage:2===a.length?240:120,d={power:e.entity_id,switch:`switch.${u}_breaker`,breaker_rating:`sensor.${u}_breaker_rating`};f[l]={tabs:a,name:c,voltage:h,device_type:"string"==typeof i.device_type?i.device_type:"circuit",relay_state:"string"==typeof i.relay_state?i.relay_state:"UNKNOWN",is_user_controllable:!0,breaker_rating_a:null,entities:d}}let v="";if(c.identifiers)for(const t of c.identifiers){if(!Array.isArray(t)||t.length<2)continue;const[e,n]=t;e===l&&"string"==typeof n&&(v=n)}let m=0;for(const e of u){const n=t.states[e.entity_id];if(n&&"number"==typeof n.attributes.panel_size){m=n.attributes.panel_size;break}}if(m||(m=Jk(f)),!m)throw new Error(i("card.panel_size_error"));const y={};for(const e of h){const n=s.filter(t=>t.device_id===e.id),i=(e.model??"").toLowerCase(),r=i.includes("battery")||(e.identifiers??[]).some(t=>t[1].toLowerCase().includes("bess")),o=i.includes("drive")||(e.identifiers??[]).some(t=>t[1].toLowerCase().includes("evse")),a={};for(const e of n){const n=e.entity_id.split(".")[0],i=t.states[e.entity_id],r=i?.attributes?.friendly_name;a[e.entity_id]={domain:n??"",original_name:"string"==typeof r?r:e.entity_id}}y[e.id]={name:e.name_by_user??e.name??"",type:r?"bess":o?"evse":"unknown",entities:a}}const _={serial:v,firmware:c.sw_version??"",panel_size:m,device_id:e,device_name:c.name_by_user??c.name??i("header.default_name"),circuits:f,sub_devices:y};return await Kk(t,_),{topology:_,panelDevice:c,panelSize:m}}(this.hass,this._config.device_id,t);this._topology=e.topology,this._panelDevice=e.panelDevice,this._panelSize=e.panelSize}catch(t){console.error("SPAN Panel: fallback discovery also failed",t),this._errorStore.add({key:"discovery-failed",level:"error",message:i("error.discovery_failed"),persistent:!0,retryFn:()=>{this._errorStore.remove("discovery-failed"),this._startDiscovery()}})}}}async _loadHistory(){if(!this._historyLoaded&&this._topology&&this.hass){this._historyLoaded=!0,await this._ctrl.fetchAndBuildHorizonMaps();try{await this._ctrl.loadHistory(),this._ctrl.updateDOM(this._root)}catch(t){console.warn("SPAN Panel: history fetch failed, charts will populate live",t)}}}_populateCardContent(){const t=this._root.querySelector("#card-content");if(!(t&&this.hass&&this._topology&&this._panelSize))return;const e=this._root.querySelector("#card-tabs");if(e){const t=[{id:"panel",label:i("tab.by_panel"),icon:"mdi:view-dashboard"},{id:"activity",label:i("tab.by_activity"),icon:"mdi:sort-descending"},{id:"area",label:i("tab.by_area"),icon:"mdi:home-group"}];e.innerHTML=(n=t,r=this._activeTab,o=this._config.tab_style??"text",`
${n.map(t=>{const e=t.id===r?" active":"",n=Rt(t.id);return"icon"===o?``:``}).join("")}
`),this._tabBarCleanup&&(this._tabBarCleanup(),this._tabBarCleanup=null),this._tabBarCleanup=function(t,e){const n=t=>{const n=t.target.closest(".shared-tab");if(n){const t=n.dataset.tab;t&&e(t)}};return t.addEventListener("click",n),()=>{t.removeEventListener("click",n)}}(e,t=>{["panel","activity","area"].includes(t)&&(this._activeTab=t,this._listCtrl.stop(),this._populateCardContent())})}var n,r,o;if("panel"===this._activeTab){const e=Math.ceil(this._panelSize/2),n=$t(this._topology,this._config),r=this._ctrl.monitoringCache.status,o=function(t){if(!t)return"";const e=Object.values(t.circuits??{}),n=Object.values(t.mains??{}),r=[...e,...n],o=r.filter(t=>void 0!==t.utilization_pct&&t.utilization_pct>=80&&t.utilization_pct<100).length,a=r.filter(t=>void 0!==t.utilization_pct&&t.utilization_pct>=100).length,s=r.filter(t=>t.has_override).length;return`\n
\n ✓ ${i("status.monitoring")} · ${e.length} ${i("status.circuits")} · ${n.length} ${i("status.mains")}\n \n ${o>0?`${o} ${i(o>1?"status.warnings":"status.warning")}`:""}\n ${a>0?`${a} ${i(a>1?"status.alerts":"status.alert")}`:""}\n ${s>0?`${s} ${i(s>1?"status.overrides":"status.override")}`:""}\n \n
\n `}(r),a=function(t,e,n,i,r){const o=new Map,a=new Set;for(const[e,n]of Object.entries(t.circuits)){const t=n.tabs;if(!t||0===t.length)continue;const i=Math.min(...t),r=1===t.length?"single":Xt(t)??"single";o.set(i,{uuid:e,circuit:n,layout:r});for(const e of t)a.add(e)}const s=new Set,l=new Set;for(const[t,e]of o)if("col-span"===e.layout){const n=e.circuit.tabs,i=qt(Math.max(...n));0===jt(t)?s.add(i):l.add(i)}function c(t){const e=t.circuit.entities?.current??t.circuit.entities?.power,i=r?ee(r,e??""):null;let o;if(t.circuit.always_on)o="always_on";else{const e=t.circuit.entities?.select;o=e&&n.states[e]?n.states[e].state:"unknown"}return{monInfo:i,sheddingPriority:o}}let u="";for(let t=1;t<=e;t++){const e=2*t-1,r=2*t,h=o.get(e),d=o.get(r);if(u+=`
${e}
`,h&&"row-span"===h.layout){const{monInfo:e,sheddingPriority:o}=c(h);u+=ie(h.uuid,h.circuit,t,"2 / 5","row-span",n,i,e,o),u+=`
${r}
`;continue}if(!s.has(t))if(!h||"col-span"!==h.layout&&"single"!==h.layout)a.has(e)||(u+=re(t,"2"));else{const{monInfo:e,sheddingPriority:r}=c(h);u+=ie(h.uuid,h.circuit,t,"2",h.layout,n,i,e,r)}if(!l.has(t))if(!d||"col-span"!==d.layout&&"single"!==d.layout)a.has(r)||(u+=re(t,"4"));else{const{monInfo:e,sheddingPriority:r}=c(d);u+=ie(d.uuid,d.circuit,t,"4",d.layout,n,i,e,r)}u+=`
${r}
`}return u}(this._topology,e,this.hass,this._config,r),s=function(t,e,n){const r=!1!==n.show_battery,o=!1!==n.show_evse;if(!t.sub_devices)return"";const a=Object.entries(t.sub_devices).filter(([,t])=>!(t.type===h&&!r||t.type===d&&!o));if(0===a.length)return"";const s=a.filter(([,t])=>t.type===d).length;let l=0,c="";for(const[t,r]of a){const o=r.type===d?i("subdevice.ev_charger"):r.type===h?i("subdevice.battery"):i("subdevice.fallback"),a=ue(r),u=a?e.states[a]:void 0,p=u&&parseFloat(u.state)||0,f=r.type===h,g=r.type===d,v=f?he(r):null,m=f?de(r):null,y=f?pe(r):null,_=fe(r,e,n,new Set([a,v,m,y].filter(t=>null!==t))),x=ge(t,0,f,a,v,m);let b="";f?b="sub-device-bess":g&&(l++,l===s&&s%2==1&&(b="sub-device-full")),c+=`\n
\n
\n ${Rt(o)}\n ${Rt(r.name||"")}\n ${a?`${Ut(p)} ${Wt(p)}`:""}\n \n
\n ${x}\n ${_}\n
\n `}return c}(this._topology,this.hass,this._config);t.innerHTML=`\n ${n}\n ${o}\n ${s?`
${s}
`:""}\n ${!1!==this._config.show_panel?`
${a}
`:""}\n `;const l=t.querySelector(".slide-confirm");if(l){const t=this._root.querySelector(".span-card");this._ctrl.bindSlideConfirm(l,t),t&&t.classList.add("switches-disabled")}const c=this._root.querySelector("span-side-panel");c&&(c.hass=this.hass,c.errorStore=this._errorStore),this._ctrl.recordSamples(),this._ctrl.updateDOM(this._root),this._ctrl.setupResizeObserver(this._root,this._root.querySelector(".span-card"))}else if("activity"===this._activeTab){t.innerHTML="";const e=$t(this._topology,this._config);this._listCtrl.setColumns(Bt()),this._listCtrl.renderActivityView(t,this.hass,this._topology,this._config,this._ctrl.monitoringCache.status,e),this._ctrl.updateDOM(this._root)}else if("area"===this._activeTab){t.innerHTML="";const e=$t(this._topology,this._config);this._listCtrl.setColumns(Bt()),this._listCtrl.renderAreaView(t,this.hass,this._topology,this._config,this._ctrl.monitoringCache.status,e),this._ctrl.updateDOM(this._root)}}_onCardClick(t){if("panel"!==this._activeTab)return;const e=t.target;if(!e)return;const n=e.closest(".unit-btn");if(n)return void this._onUnitToggle(n);if(e.closest(".toggle-pill"))return void this._ctrl.onToggleClick(t,this._root);e.closest(".gear-icon")&&this._ctrl.onGearClick(t,this._root)}async _onUnitToggle(t){const e=t.dataset.unit;e&&e!==(this._config.chart_metric??"power")&&(this._config={...this._config,chart_metric:e},this._ctrl.setConfig(this._config),this.dispatchEvent(new CustomEvent("config-changed",{detail:{config:this._config},bubbles:!0,composed:!0})),this._ctrl.powerHistory.clear(),this._historyLoaded=!1,this._populateCardContent(),await this._loadHistory(),this._ctrl.updateDOM(this._root))}async _onListUnitChanged(t){const e=t.detail;e&&e!==(this._config.chart_metric??"power")&&(this._config={...this._config,chart_metric:e},this._ctrl.setConfig(this._config),this.dispatchEvent(new CustomEvent("config-changed",{detail:{config:this._config},bubbles:!0,composed:!0})),this._ctrl.powerHistory.clear(),this._historyLoaded=!1,this._populateCardContent(),await this._loadHistory(),this._ctrl.updateDOM(this._root))}_onGraphSettingsChanged(){this._ctrl.onGraphSettingsChanged(this._root)}_onListColumnsChanged(t){const e=t.detail;"number"!=typeof e||1!==e&&2!==e&&3!==e||"activity"!==this._activeTab&&"area"!==this._activeTab||this._populateCardContent()}_onSidePanelClosed(){this._ctrl.monitoringCache.invalidate(),this._ctrl.graphSettingsCache.invalidate()}_renderPreview(){const t=uT.map(t=>ht` +
+
+ ${t.name} + ${t.watts}W +
+ + + +
+ `);return ht` +
+
+
+ SPAN Panel + Live Power +
+
${t}
+
${i("card.no_device")}
+
+
+ `}};hT.styles=k('\n :host {\n --span-accent: var(--primary-color, #4dd9af);\n }\n\n /* Card shell — replaces . Theme variables (--ha-card-*) are\n stable HA contracts (not the deprecated component APIs flagged by the\n 2026.4 frontend blog), so they stay in place to keep visual parity\n with the rest of HA\'s dashboards. */\n .span-card {\n display: block;\n padding: 24px;\n background: var(--card-background-color, #1c1c1c);\n color: var(--primary-text-color, #e0e0e0);\n border-radius: var(--ha-card-border-radius, 12px);\n border: var(--ha-card-border-width, 1px) solid var(--ha-card-border-color, var(--divider-color, #333));\n box-shadow: var(--ha-card-box-shadow, none);\n }\n\n .panel-header {\n display: flex;\n flex-wrap: wrap;\n justify-content: space-between;\n align-items: flex-start;\n gap: 8px 16px;\n margin-bottom: 20px;\n padding-bottom: 16px;\n border-bottom: 1px solid var(--divider-color, #333);\n }\n .header-left { flex: 1 1 300px; min-width: 0; }\n .header-center { flex: 0 0 auto; }\n .header-right { flex: 0 1 auto; min-width: 0; }\n\n .panel-identity {\n display: flex;\n align-items: center;\n flex-wrap: wrap;\n gap: 8px 12px;\n margin-bottom: 12px;\n }\n\n .panel-title {\n font-size: 1.8em;\n font-weight: 700;\n margin: 0;\n color: var(--primary-text-color, #fff);\n }\n\n .panel-serial {\n font-size: 0.85em;\n color: var(--secondary-text-color, #999);\n font-family: monospace;\n }\n\n .panel-stats {\n display: flex;\n flex-wrap: wrap;\n gap: 16px 32px;\n }\n\n /* Favorites view header: gear + slide-to-arm + right-anchored legend/W-A cluster. */\n .favorites-summary {\n padding: 8px 24px;\n border-bottom: 1px solid var(--divider-color, #e0e0e0);\n display: flex;\n align-items: center;\n gap: 12px;\n }\n /* Override the generic .gear-icon { margin-left: auto } rule so the\n favorites gear stays flush-left instead of floating to the right of\n the flex row (same idea as .panel-identity .panel-gear does for\n real-panel headers). */\n .favorites-summary .favorites-gear {\n margin-left: 0;\n }\n /* Right-anchored cluster wrapping the shedding legend + W/A unit toggle.\n margin-left:auto moved here from .favorites-summary-unit-toggle so the\n legend and toggle cluster together, matching the real-panel header\n layout. */\n .favorites-summary-right {\n margin-left: auto;\n display: flex;\n align-items: center;\n gap: 16px;\n }\n .favorites-subdevices-section {\n padding: 8px 16px 0;\n }\n\n /* Favorites view: responsive grid of per-contributing-panel status cards. */\n .favorites-panel-stats-grid {\n display: grid;\n grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));\n gap: 12px;\n padding: 12px 24px;\n border-bottom: 1px solid var(--divider-color, #333);\n }\n .favorites-panel-card {\n background: var(--secondary-background-color, rgba(255, 255, 255, 0.04));\n border: 1px solid var(--divider-color, #333);\n border-radius: 8px;\n padding: 10px 14px;\n display: flex;\n flex-direction: column;\n gap: 6px;\n }\n .favorites-panel-card-title {\n font-size: 0.85em;\n font-weight: 600;\n color: var(--primary-text-color);\n opacity: 0.85;\n }\n .favorites-panel-card .panel-stats {\n gap: 10px 20px;\n }\n .favorites-panel-card .stat-value {\n font-size: 1.15em;\n }\n\n .stat { display: flex; flex-direction: column; }\n .stat-label { font-size: 0.8em; color: var(--secondary-text-color, #999); margin-bottom: 2px; }\n .stat-row { display: flex; align-items: baseline; gap: 2px; }\n .stat-value { font-size: 1.5em; font-weight: 700; color: var(--primary-text-color, #fff); }\n .stat-unit { font-size: 0.7em; font-weight: 400; color: var(--secondary-text-color, #999); }\n\n .header-right { display: flex; flex-direction: column; align-items: flex-end; gap: 8px; padding-top: 8px; }\n .header-right-top { display: flex; gap: 20px; align-items: center; }\n .meta-item { font-size: 0.8em; color: var(--secondary-text-color, #999); }\n\n .shedding-legend { display: flex; gap: 12px; flex-wrap: wrap; justify-content: flex-end; }\n .shedding-legend-item { display: inline-flex; align-items: center; gap: 3px; }\n .shedding-legend-item span-icon { --mdc-icon-size: 16px; }\n .shedding-legend-secondary { --mdc-icon-size: 12px; opacity: 0.8; }\n .shedding-legend-text { font-size: 9px; font-weight: 600; }\n .shedding-legend-label { font-size: 0.7em; color: var(--secondary-text-color, #999); }\n\n .panel-gear {\n background: none;\n border: none;\n cursor: pointer;\n color: var(--secondary-text-color);\n opacity: 0.6;\n padding: 4px;\n margin-left: 8px;\n vertical-align: middle;\n }\n .panel-gear:hover { opacity: 1; }\n .header-center {\n display: flex;\n align-items: flex-start;\n justify-content: center;\n padding-top: 8px;\n }\n .panel-identity .panel-gear {\n margin-left: 0;\n }\n .slide-confirm {\n position: relative;\n display: inline-flex;\n align-items: center;\n width: 160px;\n height: 28px;\n border-radius: 14px;\n background: color-mix(in srgb, var(--primary-color, #4dd9af) 20%, var(--secondary-background-color, #333));\n vertical-align: middle;\n overflow: hidden;\n user-select: none;\n touch-action: none;\n }\n .slide-confirm-text {\n position: absolute;\n width: 100%;\n text-align: center;\n font-size: 0.65em;\n font-weight: 600;\n color: var(--secondary-text-color, #999);\n pointer-events: none;\n z-index: 0;\n }\n .slide-confirm-knob {\n position: absolute;\n left: 2px;\n top: 2px;\n width: 24px;\n height: 24px;\n border-radius: 50%;\n background: var(--secondary-text-color, #666);\n display: flex;\n align-items: center;\n justify-content: center;\n cursor: grab;\n z-index: 1;\n transition: none;\n }\n .slide-confirm-knob span-icon {\n --mdc-icon-size: 14px;\n color: var(--card-background-color, #1c1c1c);\n }\n .slide-confirm-knob.snapping {\n transition: left 0.25s ease;\n }\n .slide-confirm.confirmed {\n background: color-mix(in srgb, var(--state-active-color, var(--span-accent)) 25%, transparent);\n }\n .slide-confirm.confirmed .slide-confirm-text {\n color: var(--state-active-color, var(--span-accent));\n }\n .slide-confirm.confirmed .slide-confirm-knob {\n background: var(--state-active-color, var(--span-accent));\n }\n .switches-disabled .toggle-pill {\n opacity: 0.3;\n pointer-events: none;\n }\n .unit-toggle {\n display: inline-flex;\n background: var(--secondary-background-color, #333);\n border-radius: 6px;\n overflow: hidden;\n margin-left: 8px;\n }\n .unit-btn {\n padding: 4px 10px;\n border: none;\n background: none;\n color: var(--secondary-text-color);\n font-size: 0.75em;\n font-weight: 600;\n cursor: pointer;\n }\n .unit-btn.unit-active {\n background: var(--primary-color, #4dd9af);\n color: var(--text-primary-color, #000);\n }\n\n .monitoring-summary {\n display: flex;\n justify-content: space-between;\n align-items: center;\n padding: 6px 16px;\n font-size: 0.8em;\n background: rgba(76, 175, 80, 0.1);\n border: 1px solid var(--divider-color, #333);\n border-top: none;\n }\n .monitoring-active { color: #4caf50; }\n .monitoring-counts { display: flex; gap: 12px; }\n .count-warning { color: #ff9800; }\n .count-alert { color: #f44336; }\n .count-overrides { color: var(--secondary-text-color); }\n\n .panel-grid {\n display: grid;\n /* Five columns: left tab label, left cell, explicit 8px spacer,\n right cell, right tab label. Spacer is in-band rather than a\n column-gap so we can keep inter-cell space without paying an\n equal gap between each cell and its tab label. The tab columns\n are sized to fit a 2-digit breaker number (the font is 0.85em\n of the panel body ≈ 14px glyph width). */\n grid-template-columns: 14px 1fr 8px 1fr 14px;\n column-gap: 0;\n row-gap: 8px;\n align-items: stretch;\n }\n\n .tab-label {\n display: flex;\n align-items: center;\n font-size: 0.85em;\n font-weight: 600;\n color: var(--secondary-text-color, #999);\n user-select: none;\n }\n .tab-left { justify-content: flex-start; }\n .tab-right { justify-content: flex-end; }\n\n .circuit-slot {\n background: var(--secondary-background-color, var(--card-background-color, #2a2a2a));\n border: 1px solid var(--divider-color, #333);\n border-radius: 12px;\n padding: 14px 16px 20px;\n min-height: 140px;\n transition: opacity 0.3s;\n position: relative;\n overflow: hidden;\n }\n\n .circuit-col-span { min-height: 280px; }\n .circuit-row-span { border-left: 3px solid var(--span-accent); }\n .circuit-off .circuit-name,\n .circuit-off .breaker-badge,\n .circuit-off .power-value,\n .circuit-off .chart-container { opacity: 0.35; }\n .circuit-off .toggle-pill,\n .circuit-off .gear-icon { opacity: 1; }\n\n .circuit-empty {\n opacity: 0.2;\n min-height: 60px;\n display: flex;\n align-items: center;\n justify-content: center;\n border-style: dashed;\n }\n .empty-label { color: var(--secondary-text-color, #999); font-size: 0.85em; }\n\n .circuit-header {\n display: flex;\n justify-content: space-between;\n align-items: flex-start;\n margin-bottom: 6px;\n gap: 8px;\n }\n\n .circuit-info { display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0; }\n\n .breaker-badge {\n background: color-mix(in srgb, var(--span-accent) 15%, transparent);\n color: var(--span-accent);\n font-size: 0.7em;\n font-weight: 700;\n padding: 2px 3px;\n border-radius: 4px;\n white-space: nowrap;\n border: 1px solid color-mix(in srgb, var(--span-accent) 25%, transparent);\n flex-shrink: 0;\n }\n\n .circuit-name {\n font-size: 0.9em;\n font-weight: 500;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n color: var(--primary-text-color, #e0e0e0);\n }\n\n .circuit-controls { display: flex; align-items: center; gap: 10px; flex-shrink: 0; }\n\n /* Truncation-driven fold for By Panel breaker cells. The .is-folded\n class is added/removed by the JS observer in\n src/core/truncation-fold.ts when the .circuit-name actually\n ellipsizes. Pixel thresholds can\'t get this right because name\n length varies wildly per circuit (e.g. "Spa" vs\n "Commissioned PV System") — only measuring the live name vs its\n container catches the exact moment of truncation.\n\n When folded the nested flex wrappers (.circuit-header,\n .circuit-info, .circuit-controls, .circuit-status) collapse via\n \'display: contents\' so the leaf elements participate directly in\n the outer grid: name gets the whole first row, readings/controls/\n gear drop to a second row, chart stays as the full-width third. */\n .circuit-slot.is-folded {\n display: grid;\n /* Columns: badges + relay-toggle pack tight on the left, slack\n absorbed by the 1fr column between the relay and the power\n reading, keeping power + gear pinned to the right edge. The\n previous layout placed the slack between the shedding icon and\n the relay, which read as wasted padding the user pointed out. */\n grid-template-columns: auto auto auto auto 1fr auto auto;\n /* Rows: name and controls sized to content; chart absorbs any\n extra cell height. Without the explicit 1fr on row 3, a tall\n cell (e.g. .circuit-col-span\'s 280px min-height for 240V\n double-pole breakers) distributes excess space equally across\n all three rows via the default align-content:stretch, which\n pushes the chart down and vertically inflates the badge and\n relay toggle to fill the controls row. */\n grid-template-rows: auto auto 1fr;\n grid-template-areas:\n "name name name name name name name"\n "badge util shed status . power gear"\n "chart chart chart chart chart chart chart";\n row-gap: 6px;\n column-gap: 8px;\n }\n .circuit-slot.is-folded > .circuit-header,\n .circuit-slot.is-folded > .circuit-status,\n .circuit-slot.is-folded > .circuit-header > .circuit-info,\n .circuit-slot.is-folded > .circuit-header > .circuit-controls {\n display: contents;\n }\n .circuit-slot.is-folded .circuit-name {\n grid-area: name;\n justify-self: start;\n }\n .circuit-slot.is-folded .breaker-badge {\n grid-area: badge;\n }\n .circuit-slot.is-folded .utilization {\n grid-area: util;\n }\n .circuit-slot.is-folded .shedding-icon,\n .circuit-slot.is-folded .shedding-composite {\n grid-area: shed;\n }\n .circuit-slot.is-folded .toggle-pill {\n grid-area: status;\n justify-self: end;\n }\n .circuit-slot.is-folded .power-value {\n grid-area: power;\n justify-self: end;\n }\n .circuit-slot.is-folded .gear-icon.circuit-gear {\n grid-area: gear;\n justify-self: end;\n }\n .circuit-slot.is-folded > .chart-container {\n grid-area: chart;\n }\n\n .power-value { font-size: 0.9em; color: var(--primary-text-color, #fff); white-space: nowrap; }\n .power-value strong { font-weight: 700; font-size: 1.1em; }\n .power-unit { font-size: 0.8em; font-weight: 400; color: var(--secondary-text-color, #999); margin-left: 1px; }\n .circuit-producer .power-value strong { color: var(--info-color, #4fc3f7); }\n\n .toggle-pill {\n display: flex;\n align-items: center;\n gap: 3px;\n padding: 2px 4px;\n border-radius: 10px;\n cursor: pointer;\n font-size: 0.65em;\n font-weight: 600;\n transition: background 0.2s;\n user-select: none;\n min-width: 40px;\n }\n .toggle-on {\n padding-left: 6px;\n background: color-mix(in srgb, var(--state-active-color, var(--span-accent)) 25%, transparent);\n color: var(--state-active-color, var(--span-accent));\n }\n .toggle-off {\n padding-right: 6px;\n background: color-mix(in srgb, var(--secondary-text-color) 15%, transparent);\n color: var(--secondary-text-color, #999);\n }\n .toggle-knob {\n width: 14px;\n height: 14px;\n border-radius: 50%;\n transition: background 0.2s, margin 0.2s;\n }\n .toggle-on .toggle-knob {\n background: var(--state-active-color, var(--span-accent));\n margin-left: auto;\n }\n .toggle-off .toggle-knob {\n background: var(--secondary-text-color, #999);\n margin-right: auto;\n order: -1;\n }\n\n .circuit-status {\n display: flex;\n align-items: center;\n gap: 4px;\n margin-top: 4px;\n padding: 0 4px;\n }\n .shedding-icon { opacity: 0.8; cursor: default; }\n .shedding-composite {\n display: inline-flex;\n align-items: center;\n gap: 2px;\n }\n .shedding-icon-secondary { opacity: 0.8; }\n .shedding-label {\n font-size: 10px;\n font-weight: 600;\n opacity: 0.8;\n }\n .gear-icon {\n background: none;\n border: none;\n cursor: pointer;\n padding: 2px;\n opacity: 0.6;\n transition: opacity 0.2s;\n margin-left: auto;\n }\n .gear-icon:hover { opacity: 1; }\n .utilization {\n font-size: 0.75em;\n font-weight: 600;\n }\n .utilization-normal { color: #4caf50; }\n .utilization-warning { color: #ff9800; }\n .utilization-alert { color: #f44336; }\n .circuit-alert {\n border-color: #f44336 !important;\n box-shadow: 0 0 8px rgba(244, 67, 54, 0.3);\n }\n .chart-container {\n width: 100%;\n aspect-ratio: 4 / 1;\n margin-top: 4px;\n overflow: hidden;\n min-width: 0;\n }\n\n .sub-devices {\n display: grid;\n grid-template-columns: repeat(2, 1fr);\n gap: 12px;\n margin-bottom: 20px;\n padding-bottom: 16px;\n border-bottom: 1px solid var(--divider-color, #333);\n }\n\n .sub-device {\n background: var(--secondary-background-color, var(--card-background-color, #2a2a2a));\n border: 1px solid var(--divider-color, #333);\n border-radius: 12px;\n padding: 14px 16px;\n }\n .sub-device-bess,\n .sub-device-full {\n grid-column: 1 / -1;\n }\n\n .sub-device-header { display: flex; gap: 10px; align-items: baseline; margin-bottom: 8px; }\n .sub-device-type { font-size: 0.7em; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: var(--span-accent); }\n .sub-device-name { font-size: 0.85em; color: var(--secondary-text-color, #999); flex: 1; }\n .sub-power-value { font-size: 0.9em; color: var(--primary-text-color, #fff); white-space: nowrap; }\n .sub-power-value strong { font-weight: 700; font-size: 1.1em; }\n .sub-device .chart-container { margin-bottom: 8px; aspect-ratio: auto; }\n\n .bess-charts {\n display: grid;\n grid-template-columns: repeat(auto-fit, minmax(0, 1fr));\n gap: 12px;\n margin-bottom: 10px;\n }\n .bess-chart-col { min-width: 0; }\n .bess-chart-title {\n font-size: 0.75em;\n font-weight: 600;\n text-transform: uppercase;\n letter-spacing: 0.04em;\n color: var(--secondary-text-color, #999);\n margin-bottom: 4px;\n }\n .bess-chart-col .chart-container { aspect-ratio: auto; }\n .sub-entity { display: flex; gap: 6px; padding: 3px 0; font-size: 0.85em; }\n .sub-entity-name { color: var(--secondary-text-color, #999); }\n .sub-entity-value { font-weight: 500; color: var(--primary-text-color, #e0e0e0); }\n\n /* ── Shared tab bar ────────────────────────────────────── */\n\n .shared-tab-bar {\n display: flex;\n gap: 0;\n margin-bottom: 16px;\n border-bottom: 1px solid var(--divider-color, #333);\n }\n\n .shared-tab {\n padding: 8px 16px;\n cursor: pointer;\n font-size: 0.9em;\n font-weight: 500;\n color: var(--primary-text-color);\n opacity: 0.6;\n border: none;\n border-bottom: 2px solid transparent;\n background: none;\n transition: opacity 0.15s;\n }\n\n .shared-tab:hover {\n opacity: 0.85;\n }\n\n .shared-tab.active {\n opacity: 1;\n border-bottom-color: var(--span-accent);\n }\n\n /* ── List view search ──────────────────────────────────── */\n\n .list-search-container {\n margin-bottom: 12px;\n position: relative;\n }\n\n .list-search {\n width: 100%;\n padding: 8px 36px 8px 12px;\n border-radius: 8px;\n border: 1px solid var(--divider-color, #333);\n background: var(--secondary-background-color, #2a2a2a);\n color: var(--primary-text-color);\n font-size: 0.9em;\n box-sizing: border-box;\n outline: none;\n }\n\n .list-search:focus {\n border-color: var(--span-accent);\n }\n\n .list-search-clear {\n position: absolute;\n right: 8px;\n top: 50%;\n transform: translateY(-50%);\n background: none;\n border: none;\n color: var(--secondary-text-color);\n cursor: pointer;\n padding: 2px;\n display: flex;\n align-items: center;\n opacity: 0.7;\n }\n\n .list-search-clear:hover {\n opacity: 1;\n }\n\n .list-unit-toggle {\n display: inline-flex;\n margin-bottom: 12px;\n }\n\n /* ── List rows ─────────────────────────────────────────── */\n\n .list-view {\n display: flex;\n flex-direction: column;\n gap: 6px;\n }\n /* Each circuit is wrapped in a .list-cell so the row + its optional\n expanded chart stay together. In single-column flex mode the cell\n just stacks naturally. In multi-column grid mode the cell becomes\n one grid item, so the chart is always in the same column as its\n row. Area headers (rendered as siblings, not inside a cell) span\n all columns via their inline "grid-column: 1 / -1". */\n .list-cell {\n display: flex;\n flex-direction: column;\n min-width: 0;\n }\n .list-view[data-columns="2"],\n .list-view[data-columns="3"] {\n display: grid;\n grid-template-columns: repeat(var(--list-cols), minmax(0, 1fr));\n gap: 6px 8px;\n flex-direction: initial;\n }\n /* On narrow viewports a 2/3-column list would squeeze rows into an\n unreadable shape, so force stacking regardless of user preference. */\n @media (max-width: 599px) {\n .list-view[data-columns="2"],\n .list-view[data-columns="3"] {\n display: flex;\n flex-direction: column;\n }\n }\n\n .list-row {\n display: flex;\n align-items: center;\n padding: 12px 16px;\n gap: 10px;\n /* min-width: 0 lets the row shrink below the sum of its\n non-shrinking children when its parent .list-cell is in a\n narrow CSS-grid track (multi-column list mode). Without this\n the row would maintain its intrinsic min-content width and\n overflow the cell, leaving the name unshrunk and the\n truncation-fold observer with no signal to react to. */\n min-width: 0;\n background: var(--card-background-color, #1c1c1c);\n border: 1px solid var(--divider-color, #333);\n border-radius: 8px;\n cursor: pointer;\n transition: background 0.15s;\n }\n\n .list-row:hover {\n background: var(--secondary-background-color, #2a2a2a);\n }\n\n .list-row.circuit-off {\n opacity: 0.5;\n }\n\n .list-row.list-row-expanded {\n border-bottom-left-radius: 0;\n border-bottom-right-radius: 0;\n border-bottom-color: transparent;\n }\n\n .list-circuit-name {\n flex: 1;\n color: var(--primary-text-color);\n font-size: 0.9em;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n }\n\n .list-status-badge {\n font-size: 0.75em;\n font-weight: 600;\n padding: 2px 8px;\n border-radius: 4px;\n flex-shrink: 0;\n }\n\n .list-status-on {\n color: #4dd9af;\n }\n\n .list-status-off {\n color: #f44336;\n }\n\n .list-power-value {\n font-size: 0.9em;\n font-weight: 600;\n flex-shrink: 0;\n /* No min-width / text-align:right: the old 70px right-aligned\n cell left a visible blank column for short readings (e.g.\n "1.3A" in a 70px slot), which robbed horizontal space from\n .list-circuit-name on narrow rows. Let the value hug the\n preceding relay control and size to its content so the freed\n width flows back into the flex:1 name column. */\n }\n\n .list-expand-toggle {\n background: none;\n border: none;\n color: var(--secondary-text-color);\n cursor: pointer;\n padding: 4px;\n transition: transform 0.2s;\n display: flex;\n align-items: center;\n flex-shrink: 0;\n }\n\n .list-expand-toggle.expanded {\n transform: rotate(180deg);\n }\n\n .list-row .gear-icon {\n background: transparent;\n border: none;\n padding: 2px;\n cursor: pointer;\n color: #555;\n display: inline-flex;\n align-items: center;\n }\n .list-row .gear-icon:hover {\n color: var(--primary-text-color);\n }\n\n /* Truncation-driven fold for list rows. The .is-folded class is\n added/removed by the JS observer in src/core/truncation-fold.ts\n when the .list-circuit-name actually ellipsizes — pixel breakpoints\n can\'t track this because name length varies wildly per circuit\n ("Spa" vs "Commissioned PV System") and any single threshold\n misfires for the other end of the range. Switch to a two-row grid\n so the name gets the full width (paired only with the expand\n chevron) and the badges/controls/reading/gear drop to a secondary\n row underneath. Named areas keep the CSS readable despite the flat\n HTML child order. */\n .list-row.is-folded {\n display: grid;\n /* Row 1: name spans the row up to the chevron at the trailing\n column. Row 2: badge + util + shed + relay-toggle pack left,\n the 1fr column absorbs slack between the relay and the power\n reading, power + gear stay pinned to the right edge. The\n earlier layout placed the slack between the shedding icon and\n the relay, which the user flagged as wasted padding. */\n grid-template-columns: auto auto auto auto 1fr auto auto;\n grid-template-areas:\n "name name name name name name chevron"\n "badge util shed status . power gear";\n row-gap: 6px;\n column-gap: 8px;\n }\n .list-row.is-folded > .list-circuit-name {\n grid-area: name;\n justify-self: start;\n }\n .list-row.is-folded > .list-expand-toggle {\n grid-area: chevron;\n }\n .list-row.is-folded > .breaker-badge {\n grid-area: badge;\n }\n .list-row.is-folded > .utilization {\n grid-area: util;\n }\n .list-row.is-folded > .shedding-icon,\n .list-row.is-folded > .shedding-composite {\n grid-area: shed;\n }\n .list-row.is-folded > .toggle-pill,\n .list-row.is-folded > .list-status-badge {\n grid-area: status;\n }\n .list-row.is-folded > .list-power-value {\n grid-area: power;\n justify-self: end;\n }\n .list-row.is-folded > .gear-icon.circuit-gear {\n grid-area: gear;\n justify-self: end;\n }\n\n /* ── Expanded circuit content ──────────────────────────── */\n\n .list-expanded-content {\n padding: 0;\n background: var(--card-background-color, #1c1c1c);\n border: 1px solid var(--divider-color, #333);\n border-top: none;\n border-radius: 0 0 8px 8px;\n margin-top: -6px;\n margin-bottom: 2px;\n }\n\n .circuit-slot.circuit-chart-only {\n border: none;\n margin: 0;\n background: none;\n padding: 8px 12px;\n min-height: 0;\n }\n\n /* ── Area headers ──────────────────────────────────────── */\n\n .area-header {\n padding: 16px 12px 6px;\n font-weight: 600;\n font-size: 0.85em;\n color: var(--secondary-text-color);\n text-transform: uppercase;\n letter-spacing: 0.05em;\n }\n\n /* ── No results ────────────────────────────────────────── */\n\n .list-no-results {\n padding: 24px;\n text-align: center;\n color: var(--secondary-text-color);\n }\n\n'),x([zt({attribute:!1})],hT.prototype,"hass",void 0),x([Ot()],hT.prototype,"_config",void 0),x([Ot()],hT.prototype,"_discovered",void 0),x([Ot()],hT.prototype,"_discovering",void 0),x([Ot()],hT.prototype,"_topology",void 0),x([Ot()],hT.prototype,"_activeTab",void 0),hT=x([(t=>(e,n)=>{void 0!==n?n.addInitializer(()=>{customElements.define(t,e)}):customElements.define(t,e)})("span-panel-card")],hT);class dT extends HTMLElement{constructor(){super(...arguments),this._config={},this._hass=null,this._panels=null,this._availableRoles=null,this._built=!1,this._panelSelect=null,this._daysInput=null,this._hoursInput=null,this._minsInput=null,this._metricSelect=null,this._checkboxes={},this._entityContainers={},this._tabStyleSelect=null}setConfig(t){this._config={...t},this._updateControls()}set hass(t){this._hass=t,this._panels?this._built||this._buildEditor():this._discoverPanels()}async _discoverPanels(){if(!this._hass)return;const t=await this._hass.callWS({type:"config/device_registry/list"});this._panels=t.filter(t=>(t.identifiers??[]).some(t=>t[0]===l)&&!t.via_device_id).map(t=>{const e=(t.identifiers??[]).find(t=>t[0]===l)?.[1]??"",n=t.name_by_user??t.name??i("editor.panel_label");return{device_id:t.id,label:`${n} (${e})`}}),this._buildEditor()}_buildEditor(){this.innerHTML="",this._built=!0;const t=document.createElement("div");t.style.padding="16px";const e="\n width: 100%;\n padding: 10px 12px;\n border-radius: 8px;\n border: 1px solid var(--divider-color, #333);\n background: var(--card-background-color, var(--secondary-background-color, #1c1c1c));\n color: var(--primary-text-color, #e0e0e0);\n font-size: 1em;\n cursor: pointer;\n appearance: auto;\n box-sizing: border-box;\n ",n="display: block; font-weight: 500; margin-bottom: 8px; color: var(--primary-text-color);",i="margin-bottom: 16px;";this._buildPanelSelector(t,e,n,i),this._buildTimeWindow(t,e,n,i),this._buildMetricSelector(t,e,n,i),this._buildTabStyleSelector(t,e,n,i),this._buildSectionCheckboxes(t,n,i),this.appendChild(t),this._populateMetricSelect(),this._config.device_id&&this._discoverAvailableRoles(this._config.device_id)}_buildPanelSelector(t,e,n,r){const o=document.createElement("div");o.style.cssText=r;const a=document.createElement("label");a.textContent=i("editor.panel_label"),a.style.cssText=n;const s=document.createElement("select");s.style.cssText=e;const l=document.createElement("option");if(l.value="",l.textContent=i("editor.select_panel"),s.appendChild(l),this._panels)for(const t of this._panels){const e=document.createElement("option");e.value=t.device_id,e.textContent=t.label,t.device_id===this._config.device_id&&(e.selected=!0),s.appendChild(e)}s.addEventListener("change",()=>{this._config={...this._config,device_id:s.value},this._fireConfigChanged(),this._discoverAvailableRoles(s.value)}),o.appendChild(a),o.appendChild(s),t.appendChild(o),this._panelSelect=s}_buildTimeWindow(t,e,n,r){const o=document.createElement("div");o.style.cssText=r;const a=document.createElement("label");a.textContent=i("editor.chart_window"),a.style.cssText=n;const s=document.createElement("div");s.style.cssText="display: flex; gap: 12px; align-items: center; flex-wrap: wrap;";const l=e+"width: 70px; cursor: text;",c=(t,e,n,i)=>{const r=document.createElement("div");r.style.cssText="display: flex; align-items: center; gap: 6px;";const o=document.createElement("input");o.type="number",o.min=e,o.max=n,o.value=String(t),o.style.cssText=l;const a=document.createElement("span");return a.textContent=i,a.style.cssText="font-size: 0.9em; color: var(--secondary-text-color);",r.appendChild(o),r.appendChild(a),{wrap:r,input:o}},u=parseInt(String(this._config.history_days))||0,h=parseInt(String(this._config.history_hours))||0,d=parseInt(String(this._config.history_minutes))||0,p=c(u,"0","30",i("editor.days")),f=c(h,"0","23",i("editor.hours")),g=c(d,"0","59",i("editor.minutes")),v=()=>{this._config={...this._config,history_days:parseInt(p.input.value)||0,history_hours:parseInt(f.input.value)||0,history_minutes:parseInt(g.input.value)||0},this._fireConfigChanged()};p.input.addEventListener("change",v),f.input.addEventListener("change",v),g.input.addEventListener("change",v),s.appendChild(p.wrap),s.appendChild(f.wrap),s.appendChild(g.wrap),o.appendChild(a),o.appendChild(s),t.appendChild(o),this._daysInput=p.input,this._hoursInput=f.input,this._minsInput=g.input}_buildMetricSelector(t,e,n,r){const o=document.createElement("div");o.style.cssText=r;const a=document.createElement("label");a.textContent=i("editor.chart_metric"),a.style.cssText=n;const s=document.createElement("select");s.style.cssText=e,s.addEventListener("change",()=>{this._config={...this._config,chart_metric:s.value},this._fireConfigChanged()}),o.appendChild(a),o.appendChild(s),t.appendChild(o),this._metricSelect=s}_buildTabStyleSelector(t,e,n,r){const o=document.createElement("div");o.style.cssText=r;const a=document.createElement("label");a.textContent=i("editor.tab_style"),a.style.cssText=n;const s=document.createElement("select");s.style.cssText=e;const l=[{value:"text",text:i("editor.tab_style_text")},{value:"icon",text:i("editor.tab_style_icon")}];for(const t of l){const e=document.createElement("option");e.value=t.value,e.textContent=t.text,t.value===(this._config.tab_style??"text")&&(e.selected=!0),s.appendChild(e)}s.addEventListener("change",()=>{this._config={...this._config,tab_style:s.value},this._fireConfigChanged()}),o.appendChild(a),o.appendChild(s),t.appendChild(o),this._tabStyleSelect=s}_buildSectionCheckboxes(t,e,n){const r=document.createElement("div");r.style.cssText=n;const o=document.createElement("label");o.textContent=i("editor.visible_sections"),o.style.cssText=e,r.appendChild(o);const a=[{key:"show_panel",label:i("editor.panel_circuits"),subDeviceType:null},{key:"show_battery",label:i("editor.battery_bess"),subDeviceType:"bess"},{key:"show_evse",label:i("editor.ev_charger_evse"),subDeviceType:"evse"}];this._checkboxes={},this._entityContainers={};for(const t of a){const e=document.createElement("div");e.style.cssText="display: flex; align-items: center; gap: 8px; margin-bottom: 6px; cursor: pointer;";const n=document.createElement("input");n.type="checkbox",n.checked=!1!==this._config[t.key],n.style.cssText="width: 18px; height: 18px; cursor: pointer; accent-color: var(--primary-color);";const i=document.createElement("span");i.textContent=t.label,i.style.cssText="font-size: 0.9em; color: var(--primary-text-color); cursor: pointer;",e.appendChild(n),e.appendChild(i),r.appendChild(e),this._checkboxes[t.key]=n;let o=null;t.subDeviceType&&(o=document.createElement("div"),o.style.cssText="padding-left: 26px;",o.style.display=n.checked?"block":"none",r.appendChild(o),this._entityContainers[t.subDeviceType]=o),n.addEventListener("change",()=>{this._config={...this._config,[t.key]:n.checked},o&&(o.style.display=n.checked?"block":"none"),this._fireConfigChanged()})}t.appendChild(r)}_isChartEntity(t,e,n){const i=(e.original_name??"").toLowerCase(),r=e.unique_id??"";if("power"===i||"battery power"===i||r.endsWith("_power"))return!0;if("bess"===n){if("battery level"===i||"battery percentage"===i||r.endsWith("_battery_level")||r.endsWith("_battery_percentage"))return!0;if("state of energy"===i||r.endsWith("_soe_kwh"))return!0;if("nameplate capacity"===i||r.endsWith("_nameplate_capacity"))return!0}return!1}_populateEntityCheckboxes(t){const e=this._config.visible_sub_entities??{};for(const[,n]of Object.entries(t)){const t=n.type?this._entityContainers[n.type]:void 0;if(t&&(t.innerHTML="",n.entities))for(const[i,r]of Object.entries(n.entities)){if("sensor"===r.domain&&this._isChartEntity(i,r,n.type??""))continue;const o=document.createElement("div");o.style.cssText="display: flex; align-items: center; gap: 8px; margin-bottom: 5px; cursor: pointer;";const a=document.createElement("input");a.type="checkbox",a.checked=!0===e[i],a.style.cssText="width: 16px; height: 16px; cursor: pointer; accent-color: var(--primary-color);";const s=document.createElement("span");let l=r.original_name??i;const c=n.name??"";l.startsWith(c+" ")&&(l=l.slice(c.length+1)),s.textContent=l,s.style.cssText="font-size: 0.85em; color: var(--primary-text-color); cursor: pointer;",o.appendChild(a),o.appendChild(s),t.appendChild(o),a.addEventListener("change",()=>{const t={...this._config.visible_sub_entities??{}};a.checked?t[i]=!0:delete t[i],this._config={...this._config,visible_sub_entities:t},this._fireConfigChanged()})}}}async _discoverAvailableRoles(t){if(this._hass&&t)try{const e=await this._hass.callWS({type:`${l}/panel_topology`,device_id:t}),n=new Set;for(const t of Object.values(e.circuits??{}))for(const e of Object.keys(t.entities??{}))n.add(e);this._availableRoles=n,this._populateMetricSelect(),e.sub_devices&&this._populateEntityCheckboxes(e.sub_devices)}catch{this._availableRoles=null,this._populateMetricSelect()}}_populateMetricSelect(){const t=this._metricSelect;if(!t)return;const e=this._config.chart_metric??o;t.innerHTML="";for(const[n,i]of Object.entries(g)){if(this._availableRoles&&!this._availableRoles.has(i.entityRole))continue;const r=document.createElement("option");r.value=n,r.textContent=i.label(),n===e&&(r.selected=!0),t.appendChild(r)}}_updateControls(){if(this._panelSelect&&(this._panelSelect.value=this._config.device_id??""),this._daysInput&&(this._daysInput.value=String(parseInt(String(this._config.history_days))||0)),this._hoursInput&&(this._hoursInput.value=String(parseInt(String(this._config.history_hours))||0)),this._minsInput&&(this._minsInput.value=String(parseInt(String(this._config.history_minutes))||0)),this._metricSelect&&(this._metricSelect.value=this._config.chart_metric??o),this._checkboxes)for(const[t,e]of Object.entries(this._checkboxes))e.checked=!1!==this._config[t];this._tabStyleSelect&&(this._tabStyleSelect.value=this._config.tab_style??"text")}_fireConfigChanged(){this.dispatchEvent(new CustomEvent("config-changed",{detail:{config:this._config}}))}}try{customElements.get("span-panel-card-editor")||customElements.define("span-panel-card-editor",dT)}catch{}window.customCards=window.customCards??[],window.customCards.push({type:"span-panel-card",name:"SPAN Panel",description:"Physical panel layout with live power charts matching the SPAN frontend",preview:!0}),console.warn("%c SPAN-PANEL-CARD %c v0.9.4 ","background: var(--primary-color, #4dd9af); color: var(--text-primary-color, #000); font-weight: 700; padding: 2px 6px; border-radius: 4px 0 0 4px;","background: var(--secondary-background-color, #333); color: var(--primary-text-color, #fff); padding: 2px 6px; border-radius: 0 4px 4px 0;"); diff --git a/custom_components/span_panel/frontend/dist/span-panel.js b/custom_components/span_panel/frontend/dist/span-panel.js new file mode 100644 index 00000000..cf302770 --- /dev/null +++ b/custom_components/span_panel/frontend/dist/span-panel.js @@ -0,0 +1,384 @@ +let t="en";const e={en:{"tab.panel":"Panel","tab.by_panel":"By Panel","tab.by_activity":"By Activity","tab.by_area":"By Area","tab.monitoring":"Monitoring","tab.settings":"Settings","list.search_placeholder":"Search circuits...","list.unassigned_area":"Unassigned","list.no_results":"No circuits found","monitoring.heading":"Monitoring","monitoring.global_settings":"Global Settings","monitoring.enabled":"Enabled","monitoring.continuous":"Continuous (%)","monitoring.spike":"Spike (%)","monitoring.window":"Window (min)","monitoring.cooldown":"Cooldown (min)","monitoring.monitored_points":"Monitored Points","monitoring.col.name":"Name","monitoring.col.continuous":"Continuous","monitoring.col.spike":"Spike","monitoring.col.window":"Window","monitoring.col.cooldown":"Cooldown","monitoring.all_none":"All / None","monitoring.reset":"Reset","notification.heading":"Notification Settings","notification.targets":"Notify Targets","notification.none_selected":"None selected","notification.no_targets":"No notify targets found","notification.all_targets":"All","notification.event_bus_target":"Event Bus (HA event bus)","notification.priority":"Priority","notification.priority.default":"Default","notification.priority.passive":"Passive","notification.priority.active":"Active","notification.priority.time_sensitive":"Time-sensitive","notification.priority.critical":"Critical","notification.hint.critical":"Overrides silent/DND","notification.hint.time_sensitive":"Breaks through Focus","notification.hint.passive":"Delivers silently","notification.hint.active":"Standard delivery","notification.title_template":"Title Template","notification.message_template":"Message Template","notification.placeholders":"Placeholders:","notification.event_bus_help":"Event Bus fires event type","notification.event_bus_payload":"with payload:","notification.test_label":"Test Notification","notification.test_button":"Send Test","notification.test_sending":"Sending...","notification.test_sent":"Test notification sent","error.prefix":"Error:","error.failed_save":"Failed to save","error.failed":"Failed","error.panel_offline":"SPAN Panel unreachable","error.panel_reconnected":"SPAN Panel reconnected","error.panel_offline_named":"{name} unreachable","error.panel_reconnected_named":"{name} reconnected","error.discovery_failed":"Unable to connect to SPAN Panel","error.relay_failed":"Unable to toggle relay","error.shedding_failed":"Unable to update shedding priority","error.threshold_failed":"Unable to save threshold","error.graph_horizon_failed":"Unable to update graph time horizon","error.favorites_fetch_failed":"Unable to load favorites","error.favorites_toggle_failed":"Unable to update favorite","error.history_failed":"Unable to load historical data","error.monitoring_failed":"Unable to load monitoring status","error.graph_settings_failed":"Unable to load graph settings","error.areas_failed":"Area assignments may be out of sync","error.retry":"Retry","card.connecting":"Connecting to SPAN Panel...","settings.heading":"Settings","settings.description":"General integration settings (entity naming, device prefix, circuit numbers) are managed through the integration's options flow.","settings.open_link":"Open SPAN Panel Integration Settings","horizon.5m":"5 Minutes","horizon.1h":"1 Hour","horizon.1d":"1 Day","horizon.1w":"1 Week","horizon.1M":"1 Month","settings.graph_horizon_heading":"Graph Time Horizon","settings.graph_horizon_description":"Default time window for all circuit graphs. Individual circuits can override this in their settings panel.","settings.global_default":"Global Default","settings.default_scale":"Default Scale","settings.circuit_graph_scales":"Circuit Graph Scales","settings.col.circuit":"Circuit","settings.col.scale":"Scale","sidepanel.graph_horizon":"Graph Time Horizon","header.default_name":"SPAN Panel","header.monitoring_settings":"Panel monitoring settings","header.graph_settings":"Graph time horizon settings","header.site":"Site","header.grid":"Grid","header.upstream":"Upstream","header.downstream":"Downstream","header.solar":"Solar","header.battery":"Battery","header.toggle_units":"Toggle Watts / Amps","header.enable_switches":"Enable Switches","header.switches_enabled":"Switches Enabled","grid.unknown":"Unknown","grid.configure":"Configure circuit","grid.configure_subdevice":"Configure device","grid.on":"On","grid.off":"Off","subdevice.ev_charger":"EV Charger","subdevice.battery":"Battery","subdevice.fallback":"Sub-device","subdevice.soc":"SoC","subdevice.soe":"SoE","subdevice.power":"Power","sidepanel.graph_settings":"Graph Settings","sidepanel.global_defaults":"Global defaults for all circuits","sidepanel.favorites_subtitle":"Favorites","sidepanel.global_default":"Global Default","sidepanel.list_view_columns":"List View Columns","sidepanel.columns":"Columns","sidepanel.circuit_scales":"Circuit Graph Scales","sidepanel.subdevice_scales":"Sub-Device Graph Scales","sidepanel.reset_to_global":"Reset to global default","sidepanel.relay":"Relay","sidepanel.breaker":"Breaker","sidepanel.shedding_priority":"Shedding Priority","sidepanel.priority_label":"Priority","sidepanel.monitoring":"Monitoring","sidepanel.global":"Global","sidepanel.custom":"Custom","sidepanel.continuous_pct":"Continuous %","sidepanel.spike_pct":"Spike %","sidepanel.window_duration":"Window duration","sidepanel.cooldown":"Cooldown","sidepanel.favorite":"Favorite","sidepanel.save_to_favorites":"Save to favorites","panel.favorites":"Favorites","status.monitoring":"Monitoring","status.circuits":"circuits","status.mains":"mains","status.warning":"warning","status.warnings":"warnings","status.alert":"alert","status.alerts":"alerts","status.override":"override","status.overrides":"overrides","card.no_device":"Open the card editor and select your SPAN Panel device.","card.device_not_found":"Panel device not found. Check device_id in card config.","card.topology_error":"Topology response missing panel_size and no circuits found. Update the SPAN Panel integration.","card.panel_size_error":"Could not determine panel_size. No circuits found and no panel_size attribute. Update the SPAN Panel integration.","editor.panel_label":"SPAN Panel","editor.select_panel":"Select a panel...","editor.chart_window":"Chart time window","editor.days":"days","editor.hours":"hours","editor.minutes":"minutes","editor.chart_metric":"Chart metric","editor.visible_sections":"Visible sections","editor.panel_circuits":"Panel circuits","editor.battery_bess":"Battery (BESS)","editor.ev_charger_evse":"EV Charger (EVSE)","editor.tab_style":"Tab Style","editor.tab_style_text":"Text","editor.tab_style_icon":"Icon","metric.power":"Power","metric.current":"Current","metric.soc":"State of Charge","metric.soe":"State of Energy","shedding.always_on":"Critical","shedding.never":"Non-sheddable","shedding.soc_threshold":"SoC Threshold","shedding.off_grid":"Sheddable","shedding.unknown":"Unknown","shedding.select.never":"Stays on in an outage","shedding.select.soc_threshold":"Stays on until battery threshold","shedding.select.off_grid":"Turns off in an outage"},es:{"tab.panel":"Panel","tab.by_panel":"Por Panel","tab.by_activity":"Por Actividad","tab.by_area":"Por Área","tab.monitoring":"Monitoreo","tab.settings":"Configuración","list.search_placeholder":"Buscar circuitos...","list.unassigned_area":"Sin asignar","list.no_results":"No se encontraron circuitos","monitoring.heading":"Monitoreo","monitoring.global_settings":"Configuración Global","monitoring.enabled":"Activado","monitoring.continuous":"Continuo (%)","monitoring.spike":"Pico (%)","monitoring.window":"Ventana (min)","monitoring.cooldown":"Enfriamiento (min)","monitoring.monitored_points":"Puntos Monitoreados","monitoring.col.name":"Nombre","monitoring.col.continuous":"Continuo","monitoring.col.spike":"Pico","monitoring.col.window":"Ventana","monitoring.col.cooldown":"Enfriamiento","monitoring.all_none":"Todos / Ninguno","monitoring.reset":"Restablecer","notification.heading":"Configuración de Notificaciones","notification.targets":"Destinos de Notificación","notification.none_selected":"Ninguno seleccionado","notification.no_targets":"No se encontraron destinos de notificación","notification.all_targets":"Todos","notification.event_bus_target":"Bus de Eventos (bus de eventos de HA)","notification.priority":"Prioridad","notification.priority.default":"Predeterminado","notification.priority.passive":"Pasivo","notification.priority.active":"Activo","notification.priority.time_sensitive":"Urgente","notification.priority.critical":"Crítico","notification.hint.critical":"Anula silencio/No molestar","notification.hint.time_sensitive":"Atraviesa el modo Concentración","notification.hint.passive":"Entrega silenciosa","notification.hint.active":"Entrega estándar","notification.title_template":"Plantilla de Título","notification.message_template":"Plantilla de Mensaje","notification.placeholders":"Variables:","notification.event_bus_help":"El Bus de Eventos dispara el tipo de evento","notification.event_bus_payload":"con datos:","notification.test_label":"Notificación de prueba","notification.test_button":"Enviar prueba","notification.test_sending":"Enviando...","notification.test_sent":"Notificación de prueba enviada","error.prefix":"Error:","error.failed_save":"Error al guardar","error.failed":"Falló","error.panel_offline":"SPAN Panel inaccesible","error.panel_reconnected":"SPAN Panel reconectado","error.panel_offline_named":"{name} inaccesible","error.panel_reconnected_named":"{name} reconectado","error.discovery_failed":"No se puede conectar al SPAN Panel","error.relay_failed":"No se pudo cambiar el relé","error.shedding_failed":"No se pudo actualizar la prioridad de desconexión","error.threshold_failed":"No se pudo guardar el umbral","error.graph_horizon_failed":"No se pudo actualizar el horizonte temporal del gráfico","error.favorites_fetch_failed":"No se pudieron cargar los favoritos","error.favorites_toggle_failed":"No se pudo actualizar el favorito","error.history_failed":"No se pudieron cargar los datos históricos","error.monitoring_failed":"No se pudo cargar el estado de monitoreo","error.graph_settings_failed":"No se pudo cargar la configuración del gráfico","error.areas_failed":"Las asignaciones de áreas pueden estar desincronizadas","error.retry":"Reintentar","card.connecting":"Conectando al SPAN Panel...","settings.heading":"Configuración","settings.description":"La configuración general de la integración (nombres de entidades, prefijo de dispositivo, números de circuito) se administra a través del flujo de opciones de la integración.","settings.open_link":"Abrir Configuración de Integración SPAN Panel","horizon.5m":"5 Minutes","horizon.1h":"1 Hour","horizon.1d":"1 Day","horizon.1w":"1 Week","horizon.1M":"1 Month","settings.graph_horizon_heading":"Graph Time Horizon","settings.graph_horizon_description":"Default time window for all circuit graphs. Individual circuits can override this in their settings panel.","settings.global_default":"Global Default","settings.default_scale":"Default Scale","settings.circuit_graph_scales":"Circuit Graph Scales","settings.col.circuit":"Circuit","settings.col.scale":"Scale","sidepanel.graph_horizon":"Graph Time Horizon","header.default_name":"SPAN Panel","header.monitoring_settings":"Configuración de monitoreo del panel","header.graph_settings":"Configuración del horizonte temporal del gráfico","header.site":"Sitio","header.grid":"Red","header.upstream":"Aguas arriba","header.downstream":"Aguas abajo","header.solar":"Solar","header.battery":"Batería","header.toggle_units":"Alternar Watts / Amperios","header.enable_switches":"Habilitar Interruptores","header.switches_enabled":"Interruptores Habilitados","grid.unknown":"Desconocido","grid.configure":"Configurar circuito","grid.configure_subdevice":"Configurar dispositivo","grid.on":"Enc","grid.off":"Apag","subdevice.ev_charger":"Cargador EV","subdevice.battery":"Batería","subdevice.fallback":"Sub-dispositivo","subdevice.soc":"SoC","subdevice.soe":"SoE","subdevice.power":"Potencia","sidepanel.graph_settings":"Configuración de Gráficos","sidepanel.global_defaults":"Valores predeterminados globales para todos los circuitos","sidepanel.favorites_subtitle":"Favoritos","sidepanel.global_default":"Predeterminado Global","sidepanel.list_view_columns":"Columnas de la lista","sidepanel.columns":"Columnas","sidepanel.circuit_scales":"Escalas de Gráficos de Circuitos","sidepanel.subdevice_scales":"Escalas de Gráficos de Sub-Dispositivos","sidepanel.reset_to_global":"Restablecer al valor global","sidepanel.relay":"Relé","sidepanel.breaker":"Interruptor","sidepanel.shedding_priority":"Prioridad de Desconexción","sidepanel.priority_label":"Prioridad","sidepanel.monitoring":"Monitoreo","sidepanel.global":"Global","sidepanel.custom":"Personalizado","sidepanel.continuous_pct":"Continuo %","sidepanel.spike_pct":"Pico %","sidepanel.window_duration":"Duración de ventana","sidepanel.cooldown":"Enfriamiento","sidepanel.favorite":"Favorito","sidepanel.save_to_favorites":"Guardar en favoritos","panel.favorites":"Favoritos","status.monitoring":"Monitoreo","status.circuits":"circuitos","status.mains":"alimentación","status.warning":"advertencia","status.warnings":"advertencias","status.alert":"alerta","status.alerts":"alertas","status.override":"anulación","status.overrides":"anulaciones","card.no_device":"Abra el editor de tarjeta y seleccione su dispositivo SPAN Panel.","card.device_not_found":"Dispositivo de panel no encontrado. Verifique device_id en la configuración de la tarjeta.","card.topology_error":"La respuesta de topología no contiene panel_size y no se encontraron circuitos. Actualice la integración SPAN Panel.","card.panel_size_error":"No se pudo determinar panel_size. No se encontraron circuitos ni atributo panel_size. Actualice la integración SPAN Panel.","editor.panel_label":"SPAN Panel","editor.select_panel":"Seleccione un panel...","editor.chart_window":"Ventana de tiempo del gráfico","editor.days":"días","editor.hours":"horas","editor.minutes":"minutos","editor.chart_metric":"Métrica del gráfico","editor.visible_sections":"Secciones visibles","editor.panel_circuits":"Circuitos del panel","editor.battery_bess":"Batería (BESS)","editor.ev_charger_evse":"Cargador EV (EVSE)","editor.tab_style":"Estilo de pestañas","editor.tab_style_text":"Texto","editor.tab_style_icon":"Ícono","metric.power":"Potencia","metric.current":"Corriente","metric.soc":"Estado de Carga","metric.soe":"Estado de Energía","shedding.always_on":"Crítico","shedding.never":"No desconectable","shedding.soc_threshold":"Umbral SoC","shedding.off_grid":"Desconectable","shedding.unknown":"Desconocido","shedding.select.never":"Permanece encendido en un corte","shedding.select.soc_threshold":"Encendido hasta umbral de batería","shedding.select.off_grid":"Se apaga en un corte"},fr:{"tab.panel":"Panneau","tab.by_panel":"Par Panneau","tab.by_activity":"Par Activité","tab.by_area":"Par Zone","tab.monitoring":"Surveillance","tab.settings":"Paramètres","list.search_placeholder":"Rechercher des circuits...","list.unassigned_area":"Non attribué","list.no_results":"Aucun circuit trouvé","monitoring.heading":"Surveillance","monitoring.global_settings":"Paramètres Globaux","monitoring.enabled":"Activé","monitoring.continuous":"Continu (%)","monitoring.spike":"Pic (%)","monitoring.window":"Fenêtre (min)","monitoring.cooldown":"Refroidissement (min)","monitoring.monitored_points":"Points Surveillés","monitoring.col.name":"Nom","monitoring.col.continuous":"Continu","monitoring.col.spike":"Pic","monitoring.col.window":"Fenêtre","monitoring.col.cooldown":"Refroidissement","monitoring.all_none":"Tous / Aucun","monitoring.reset":"Réinitialiser","notification.heading":"Paramètres de Notification","notification.targets":"Cibles de Notification","notification.none_selected":"Aucune sélection","notification.no_targets":"Aucune cible de notification trouvée","notification.all_targets":"Tous","notification.event_bus_target":"Bus d'événements (bus d'événements HA)","notification.priority":"Priorité","notification.priority.default":"Par défaut","notification.priority.passive":"Passif","notification.priority.active":"Actif","notification.priority.time_sensitive":"Urgent","notification.priority.critical":"Critique","notification.hint.critical":"Outrepasse silencieux/NPD","notification.hint.time_sensitive":"Traverse le mode Concentration","notification.hint.passive":"Livraison silencieuse","notification.hint.active":"Livraison standard","notification.title_template":"Modèle de Titre","notification.message_template":"Modèle de Message","notification.placeholders":"Variables :","notification.event_bus_help":"Le Bus d'événements déclenche le type d'événement","notification.event_bus_payload":"avec les données :","notification.test_label":"Notification de test","notification.test_button":"Envoyer un test","notification.test_sending":"Envoi...","notification.test_sent":"Notification de test envoyée","error.prefix":"Erreur :","error.failed_save":"Échec de la sauvegarde","error.failed":"Échoué","error.panel_offline":"SPAN Panel inaccessible","error.panel_reconnected":"SPAN Panel reconnecté","error.panel_offline_named":"{name} inaccessible","error.panel_reconnected_named":"{name} reconnecté","error.discovery_failed":"Impossible de se connecter au SPAN Panel","error.relay_failed":"Impossible de basculer le relais","error.shedding_failed":"Impossible de mettre à jour la priorité de délestage","error.threshold_failed":"Impossible d'enregistrer le seuil","error.graph_horizon_failed":"Impossible de mettre à jour l'horizon temporel du graphique","error.favorites_fetch_failed":"Impossible de charger les favoris","error.favorites_toggle_failed":"Impossible de mettre à jour le favori","error.history_failed":"Impossible de charger les données historiques","error.monitoring_failed":"Impossible de charger l'état de surveillance","error.graph_settings_failed":"Impossible de charger les paramètres du graphique","error.areas_failed":"Les affectations de zones peuvent être désynchronisées","error.retry":"Réessayer","card.connecting":"Connexion au SPAN Panel...","settings.heading":"Paramètres","settings.description":"Les paramètres généraux de l'intégration (noms d'entités, préfixe de l'appareil, numéros de circuit) sont gérés via le flux d'options de l'intégration.","settings.open_link":"Ouvrir les Paramètres d'Intégration SPAN Panel","horizon.5m":"5 Minutes","horizon.1h":"1 Hour","horizon.1d":"1 Day","horizon.1w":"1 Week","horizon.1M":"1 Month","settings.graph_horizon_heading":"Graph Time Horizon","settings.graph_horizon_description":"Default time window for all circuit graphs. Individual circuits can override this in their settings panel.","settings.global_default":"Global Default","settings.default_scale":"Default Scale","settings.circuit_graph_scales":"Circuit Graph Scales","settings.col.circuit":"Circuit","settings.col.scale":"Scale","sidepanel.graph_horizon":"Graph Time Horizon","header.default_name":"SPAN Panel","header.monitoring_settings":"Paramètres de surveillance du panneau","header.graph_settings":"Paramètres d'horizon temporel du graphique","header.site":"Site","header.grid":"Réseau","header.upstream":"Amont","header.downstream":"Aval","header.solar":"Solaire","header.battery":"Batterie","header.toggle_units":"Basculer Watts / Ampères","header.enable_switches":"Activer les interrupteurs","header.switches_enabled":"Interrupteurs activés","grid.unknown":"Inconnu","grid.configure":"Configurer le circuit","grid.configure_subdevice":"Configurer l'appareil","grid.on":"On","grid.off":"Off","subdevice.ev_charger":"Chargeur VE","subdevice.battery":"Batterie","subdevice.fallback":"Sous-appareil","subdevice.soc":"SoC","subdevice.soe":"SoE","subdevice.power":"Puissance","sidepanel.graph_settings":"Paramètres des Graphiques","sidepanel.global_defaults":"Valeurs par défaut globales pour tous les circuits","sidepanel.favorites_subtitle":"Favoris","sidepanel.global_default":"Défaut Global","sidepanel.list_view_columns":"Colonnes de la liste","sidepanel.columns":"Colonnes","sidepanel.circuit_scales":"Échelles des Graphiques de Circuits","sidepanel.subdevice_scales":"Échelles des Graphiques de Sous-Appareils","sidepanel.reset_to_global":"Réinitialiser à la valeur globale","sidepanel.relay":"Relais","sidepanel.breaker":"Disjoncteur","sidepanel.shedding_priority":"Priorité de Délestage","sidepanel.priority_label":"Priorité","sidepanel.monitoring":"Surveillance","sidepanel.global":"Global","sidepanel.custom":"Personnalisé","sidepanel.continuous_pct":"Continu %","sidepanel.spike_pct":"Pic %","sidepanel.window_duration":"Durée de fenêtre","sidepanel.cooldown":"Refroidissement","sidepanel.favorite":"Favori","sidepanel.save_to_favorites":"Enregistrer dans les favoris","panel.favorites":"Favoris","status.monitoring":"Surveillance","status.circuits":"circuits","status.mains":"alimentation","status.warning":"avertissement","status.warnings":"avertissements","status.alert":"alerte","status.alerts":"alertes","status.override":"remplacement","status.overrides":"remplacements","card.no_device":"Ouvrez l'éditeur de carte et sélectionnez votre appareil SPAN Panel.","card.device_not_found":"Appareil de panneau introuvable. Vérifiez device_id dans la configuration de la carte.","card.topology_error":"La réponse de topologie ne contient pas panel_size et aucun circuit trouvé. Mettez à jour l'intégration SPAN Panel.","card.panel_size_error":"Impossible de déterminer panel_size. Aucun circuit trouvé et aucun attribut panel_size. Mettez à jour l'intégration SPAN Panel.","editor.panel_label":"SPAN Panel","editor.select_panel":"Sélectionnez un panneau...","editor.chart_window":"Fenêtre de temps du graphique","editor.days":"jours","editor.hours":"heures","editor.minutes":"minutes","editor.chart_metric":"Métrique du graphique","editor.visible_sections":"Sections visibles","editor.panel_circuits":"Circuits du panneau","editor.battery_bess":"Batterie (BESS)","editor.ev_charger_evse":"Chargeur VE (EVSE)","editor.tab_style":"Style des onglets","editor.tab_style_text":"Texte","editor.tab_style_icon":"Icône","metric.power":"Puissance","metric.current":"Courant","metric.soc":"État de Charge","metric.soe":"État d'Énergie","shedding.always_on":"Critique","shedding.never":"Non délestable","shedding.soc_threshold":"Seuil SoC","shedding.off_grid":"Délestable","shedding.unknown":"Inconnu","shedding.select.never":"Reste allumé en cas de coupure","shedding.select.soc_threshold":"Allumé jusqu'au seuil batterie","shedding.select.off_grid":"S'éteint en cas de coupure"},ja:{"tab.panel":"パネル","tab.by_panel":"パネル別","tab.by_activity":"活動別","tab.by_area":"エリア別","tab.monitoring":"モニタリング","tab.settings":"設定","list.search_placeholder":"回路を検索...","list.unassigned_area":"未割り当て","list.no_results":"回路が見つかりません","monitoring.heading":"モニタリング","monitoring.global_settings":"グローバル設定","monitoring.enabled":"有効","monitoring.continuous":"継続 (%)","monitoring.spike":"スパイク (%)","monitoring.window":"ウィンドウ (分)","monitoring.cooldown":"クールダウン (分)","monitoring.monitored_points":"監視ポイント","monitoring.col.name":"名前","monitoring.col.continuous":"継続","monitoring.col.spike":"スパイク","monitoring.col.window":"ウィンドウ","monitoring.col.cooldown":"クールダウン","monitoring.all_none":"全選択 / 全解除","monitoring.reset":"リセット","notification.heading":"通知設定","notification.targets":"通知先","notification.none_selected":"未選択","notification.no_targets":"通知先が見つかりません","notification.all_targets":"すべて","notification.event_bus_target":"イベントバス (HAイベントバス)","notification.priority":"優先度","notification.priority.default":"デフォルト","notification.priority.passive":"パッシブ","notification.priority.active":"アクティブ","notification.priority.time_sensitive":"緊急","notification.priority.critical":"重大","notification.hint.critical":"サイレント/おやすみモードを無視","notification.hint.time_sensitive":"集中モードを突破","notification.hint.passive":"サイレント配信","notification.hint.active":"標準配信","notification.title_template":"タイトルテンプレート","notification.message_template":"メッセージテンプレート","notification.placeholders":"プレースホルダー:","notification.event_bus_help":"イベントバスが発行するイベントタイプ","notification.event_bus_payload":"ペイロード:","notification.test_label":"テスト通知","notification.test_button":"テスト送信","notification.test_sending":"送信中...","notification.test_sent":"テスト通知を送信しました","error.prefix":"エラー:","error.failed_save":"保存に失敗","error.failed":"失敗","error.panel_offline":"SPANパネルに接続できません","error.panel_reconnected":"SPANパネルが再接続されました","error.panel_offline_named":"{name}に接続できません","error.panel_reconnected_named":"{name}が再接続されました","error.discovery_failed":"SPANパネルへの接続に失敗しました","error.relay_failed":"リレーの切り替えに失敗しました","error.shedding_failed":"シェディング優先度の更新に失敗しました","error.threshold_failed":"しきい値の保存に失敗しました","error.graph_horizon_failed":"グラフの時間範囲の更新に失敗しました","error.favorites_fetch_failed":"お気に入りの読み込みに失敗しました","error.favorites_toggle_failed":"お気に入りの更新に失敗しました","error.history_failed":"履歴データの読み込みに失敗しました","error.monitoring_failed":"監視ステータスの読み込みに失敗しました","error.graph_settings_failed":"グラフ設定の読み込みに失敗しました","error.areas_failed":"エリア割り当てが同期されていない可能性があります","error.retry":"再試行","card.connecting":"SPANパネルに接続中...","settings.heading":"設定","settings.description":"統合の一般設定(エンティティ名、デバイスプレフィックス、回路番号)は統合のオプションフローで管理されます。","settings.open_link":"SPAN Panel統合設定を開く","horizon.5m":"5 Minutes","horizon.1h":"1 Hour","horizon.1d":"1 Day","horizon.1w":"1 Week","horizon.1M":"1 Month","settings.graph_horizon_heading":"Graph Time Horizon","settings.graph_horizon_description":"Default time window for all circuit graphs. Individual circuits can override this in their settings panel.","settings.global_default":"Global Default","settings.default_scale":"Default Scale","settings.circuit_graph_scales":"Circuit Graph Scales","settings.col.circuit":"Circuit","settings.col.scale":"Scale","sidepanel.graph_horizon":"Graph Time Horizon","header.default_name":"SPAN Panel","header.monitoring_settings":"パネルモニタリング設定","header.graph_settings":"グラフ時間範囲設定","header.site":"サイト","header.grid":"グリッド","header.upstream":"上流","header.downstream":"下流","header.solar":"ソーラー","header.battery":"バッテリー","header.toggle_units":"ワット/アンペア切り替え","header.enable_switches":"スイッチを有効化","header.switches_enabled":"スイッチ有効","grid.unknown":"不明","grid.configure":"回路を設定","grid.configure_subdevice":"デバイスを設定","grid.on":"オン","grid.off":"オフ","subdevice.ev_charger":"EV充電器","subdevice.battery":"バッテリー","subdevice.fallback":"サブデバイス","subdevice.soc":"SoC","subdevice.soe":"SoE","subdevice.power":"電力","sidepanel.graph_settings":"グラフ設定","sidepanel.global_defaults":"全回路のグローバルデフォルト","sidepanel.favorites_subtitle":"お気に入り","sidepanel.global_default":"グローバルデフォルト","sidepanel.list_view_columns":"リスト表示の列数","sidepanel.columns":"列","sidepanel.circuit_scales":"回路グラフスケール","sidepanel.subdevice_scales":"サブデバイスグラフスケール","sidepanel.reset_to_global":"グローバルにリセット","sidepanel.relay":"リレー","sidepanel.breaker":"ブレーカー","sidepanel.shedding_priority":"シェディング優先度","sidepanel.priority_label":"優先度","sidepanel.monitoring":"モニタリング","sidepanel.global":"グローバル","sidepanel.custom":"カスタム","sidepanel.continuous_pct":"継続 %","sidepanel.spike_pct":"スパイク %","sidepanel.window_duration":"ウィンドウ時間","sidepanel.cooldown":"クールダウン","sidepanel.favorite":"お気に入り","sidepanel.save_to_favorites":"お気に入りに保存","panel.favorites":"お気に入り","status.monitoring":"モニタリング","status.circuits":"回路","status.mains":"主電源","status.warning":"警告","status.warnings":"警告","status.alert":"アラート","status.alerts":"アラート","status.override":"上書き","status.overrides":"上書き","card.no_device":"カードエディタを開いてSPAN Panelデバイスを選択してください。","card.device_not_found":"パネルデバイスが見つかりません。カード設定のdevice_idを確認してください。","card.topology_error":"トポロジー応答にpanel_sizeがなく、回路が見つかりません。SPAN Panel統合を更新してください。","card.panel_size_error":"panel_sizeを判定できません。回路がpanel_size属性が見つかりません。SPAN Panel統合を更新してください。","editor.panel_label":"SPAN Panel","editor.select_panel":"パネルを選択...","editor.chart_window":"グラフ時間ウィンドウ","editor.days":"日","editor.hours":"時間","editor.minutes":"分","editor.chart_metric":"グラフ指標","editor.visible_sections":"表示セクション","editor.panel_circuits":"パネル回路","editor.battery_bess":"バッテリー (BESS)","editor.ev_charger_evse":"EV充電器 (EVSE)","editor.tab_style":"タブスタイル","editor.tab_style_text":"テキスト","editor.tab_style_icon":"アイコン","metric.power":"電力","metric.current":"電流","metric.soc":"充電状態","metric.soe":"エネルギー状態","shedding.always_on":"重要","shedding.never":"切断不可","shedding.soc_threshold":"SoCしきい値","shedding.off_grid":"切断可能","shedding.unknown":"不明","shedding.select.never":"停電時もオンを維持","shedding.select.soc_threshold":"バッテリーしきい値までオン","shedding.select.off_grid":"停電時にオフ"},pt:{"tab.panel":"Painel","tab.by_panel":"Por Painel","tab.by_activity":"Por Atividade","tab.by_area":"Por Área","tab.monitoring":"Monitoramento","tab.settings":"Configurações","list.search_placeholder":"Pesquisar circuitos...","list.unassigned_area":"Não atribuído","list.no_results":"Nenhum circuito encontrado","monitoring.heading":"Monitoramento","monitoring.global_settings":"Configurações Globais","monitoring.enabled":"Ativado","monitoring.continuous":"Contínuo (%)","monitoring.spike":"Pico (%)","monitoring.window":"Janela (min)","monitoring.cooldown":"Resfriamento (min)","monitoring.monitored_points":"Pontos Monitorados","monitoring.col.name":"Nome","monitoring.col.continuous":"Contínuo","monitoring.col.spike":"Pico","monitoring.col.window":"Janela","monitoring.col.cooldown":"Resfriamento","monitoring.all_none":"Todos / Nenhum","monitoring.reset":"Redefinir","notification.heading":"Configurações de Notificação","notification.targets":"Destinos de Notificação","notification.none_selected":"Nenhum selecionado","notification.no_targets":"Nenhum destino de notificação encontrado","notification.all_targets":"Todos","notification.event_bus_target":"Barramento de Eventos (barramento de eventos do HA)","notification.priority":"Prioridade","notification.priority.default":"Padrão","notification.priority.passive":"Passivo","notification.priority.active":"Ativo","notification.priority.time_sensitive":"Urgente","notification.priority.critical":"Crítico","notification.hint.critical":"Substitui silencioso/Não perturbar","notification.hint.time_sensitive":"Atravessa o modo Foco","notification.hint.passive":"Entrega silenciosa","notification.hint.active":"Entrega padrão","notification.title_template":"Modelo de Título","notification.message_template":"Modelo de Mensagem","notification.placeholders":"Variáveis:","notification.event_bus_help":"O Barramento de Eventos dispara o tipo de evento","notification.event_bus_payload":"com dados:","notification.test_label":"Notificação de teste","notification.test_button":"Enviar teste","notification.test_sending":"Enviando...","notification.test_sent":"Notificação de teste enviada","error.prefix":"Erro:","error.failed_save":"Falha ao salvar","error.failed":"Falhou","error.panel_offline":"SPAN Panel inacessível","error.panel_reconnected":"SPAN Panel reconectado","error.panel_offline_named":"{name} inacessível","error.panel_reconnected_named":"{name} reconectado","error.discovery_failed":"Não foi possível conectar ao SPAN Panel","error.relay_failed":"Não foi possível alternar o relé","error.shedding_failed":"Não foi possível atualizar a prioridade de desligamento","error.threshold_failed":"Não foi possível salvar o limite","error.graph_horizon_failed":"Não foi possível atualizar o horizonte temporal do gráfico","error.favorites_fetch_failed":"Não foi possível carregar os favoritos","error.favorites_toggle_failed":"Não foi possível atualizar o favorito","error.history_failed":"Não foi possível carregar os dados históricos","error.monitoring_failed":"Não foi possível carregar o status de monitoramento","error.graph_settings_failed":"Não foi possível carregar as configurações do gráfico","error.areas_failed":"As atribuições de áreas podem estar fora de sincronização","error.retry":"Tentar novamente","card.connecting":"Conectando ao SPAN Panel...","settings.heading":"Configurações","settings.description":"As configurações gerais da integração (nomes de entidades, prefixo do dispositivo, números de circuito) são gerenciadas através do fluxo de opções da integração.","settings.open_link":"Abrir Configurações de Integração SPAN Panel","horizon.5m":"5 Minutes","horizon.1h":"1 Hour","horizon.1d":"1 Day","horizon.1w":"1 Week","horizon.1M":"1 Month","settings.graph_horizon_heading":"Graph Time Horizon","settings.graph_horizon_description":"Default time window for all circuit graphs. Individual circuits can override this in their settings panel.","settings.global_default":"Global Default","settings.default_scale":"Default Scale","settings.circuit_graph_scales":"Circuit Graph Scales","settings.col.circuit":"Circuit","settings.col.scale":"Scale","sidepanel.graph_horizon":"Graph Time Horizon","header.default_name":"SPAN Panel","header.monitoring_settings":"Configurações de monitoramento do painel","header.graph_settings":"Configurações do horizonte temporal do gráfico","header.site":"Local","header.grid":"Rede","header.upstream":"Montante","header.downstream":"Jusante","header.solar":"Solar","header.battery":"Bateria","header.toggle_units":"Alternar Watts / Amperes","header.enable_switches":"Ativar Interruptores","header.switches_enabled":"Interruptores Ativados","grid.unknown":"Desconhecido","grid.configure":"Configurar circuito","grid.configure_subdevice":"Configurar dispositivo","grid.on":"Lig","grid.off":"Des","subdevice.ev_charger":"Carregador VE","subdevice.battery":"Bateria","subdevice.fallback":"Sub-dispositivo","subdevice.soc":"SoC","subdevice.soe":"SoE","subdevice.power":"Potência","sidepanel.graph_settings":"Configurações de Gráficos","sidepanel.global_defaults":"Padrões globais para todos os circuitos","sidepanel.favorites_subtitle":"Favoritos","sidepanel.global_default":"Padrão Global","sidepanel.list_view_columns":"Colunas da Lista","sidepanel.columns":"Colunas","sidepanel.circuit_scales":"Escalas de Gráficos de Circuitos","sidepanel.subdevice_scales":"Escalas de Gráficos de Sub-Dispositivos","sidepanel.reset_to_global":"Redefinir para o padrão global","sidepanel.relay":"Relé","sidepanel.breaker":"Disjuntor","sidepanel.shedding_priority":"Prioridade de Desligamento","sidepanel.priority_label":"Prioridade","sidepanel.monitoring":"Monitoramento","sidepanel.global":"Global","sidepanel.custom":"Personalizado","sidepanel.continuous_pct":"Contínuo %","sidepanel.spike_pct":"Pico %","sidepanel.window_duration":"Duração da janela","sidepanel.cooldown":"Resfriamento","sidepanel.favorite":"Favorito","sidepanel.save_to_favorites":"Salvar nos favoritos","panel.favorites":"Favoritos","status.monitoring":"Monitoramento","status.circuits":"circuitos","status.mains":"alimentação","status.warning":"aviso","status.warnings":"avisos","status.alert":"alerta","status.alerts":"alertas","status.override":"substituição","status.overrides":"substituições","card.no_device":"Abra o editor do cartão e selecione seu dispositivo SPAN Panel.","card.device_not_found":"Dispositivo do painel não encontrado. Verifique device_id na configuração do cartão.","card.topology_error":"A resposta de topologia não contém panel_size e nenhum circuito encontrado. Atualize a integração SPAN Panel.","card.panel_size_error":"Não foi possível determinar panel_size. Nenhum circuito encontrado e nenhum atributo panel_size. Atualize a integração SPAN Panel.","editor.panel_label":"SPAN Panel","editor.select_panel":"Selecione um painel...","editor.chart_window":"Janela de tempo do gráfico","editor.days":"dias","editor.hours":"horas","editor.minutes":"minutos","editor.chart_metric":"Métrica do gráfico","editor.visible_sections":"Seções visíveis","editor.panel_circuits":"Circuitos do painel","editor.battery_bess":"Bateria (BESS)","editor.ev_charger_evse":"Carregador VE (EVSE)","editor.tab_style":"Estilo das abas","editor.tab_style_text":"Texto","editor.tab_style_icon":"Ícone","metric.power":"Potência","metric.current":"Corrente","metric.soc":"Estado de Carga","metric.soe":"Estado de Energia","shedding.always_on":"Crítico","shedding.never":"Não desligável","shedding.soc_threshold":"Limite SoC","shedding.off_grid":"Desligável","shedding.unknown":"Desconhecido","shedding.select.never":"Permanece ligado em uma queda","shedding.select.soc_threshold":"Ligado até limite da bateria","shedding.select.off_grid":"Desliga em uma queda"}};function n(n){return e[t]?.[n]??e.en?.[n]??n}function i(n,i){return(e[t]?.[n]??e.en?.[n]??n).replace(/\{(\w+)\}/g,(t,e)=>Object.prototype.hasOwnProperty.call(i,e)?i[e]:`{${e}}`)}const r="power",o="5m",a={"5m":{ms:3e5,refreshMs:1e3,useRealtime:!0},"1h":{ms:36e5,refreshMs:3e4,useRealtime:!1},"1d":{ms:864e5,refreshMs:6e4,useRealtime:!1},"1w":{ms:6048e5,refreshMs:6e4,useRealtime:!1},"1M":{ms:2592e6,refreshMs:6e4,useRealtime:!1}},s="span_panel",l="CLOSED",c="pv",u="bess",h="evse",d="sub_",p=500,f={power:{entityRole:"power",label:()=>n("metric.power"),unit:t=>Math.abs(t)>=1e3?"kW":"W",format:t=>{const e=Math.abs(t);return e>=1e3?(e/1e3).toFixed(1):e<10&&e>0?e.toFixed(1):String(Math.round(e))}},current:{entityRole:"current",label:()=>n("metric.current"),unit:()=>"A",format:t=>Math.abs(t).toFixed(1)}},g={soc:{entityRole:"soc",label:()=>n("metric.soc"),unit:()=>"%",format:t=>String(Math.round(t)),fixedMin:0,fixedMax:100},soe:{entityRole:"soe",label:()=>n("metric.soe"),unit:()=>"kWh",format:t=>t.toFixed(1)},power:f.power},v={always_on:{icon:"mdi:battery",icon2:"mdi:router-wireless",color:"#4caf50",label:()=>n("shedding.always_on")},never:{icon:"mdi:battery",color:"#4caf50",label:()=>n("shedding.never")},soc_threshold:{icon:"mdi:battery-alert-variant-outline",color:"#9c27b0",label:()=>n("shedding.soc_threshold"),textLabel:"SoC"},off_grid:{icon:"mdi:transmission-tower",color:"#ff9800",label:()=>n("shedding.off_grid")},unknown:{icon:"mdi:help-circle-outline",color:"#888",label:()=>n("shedding.unknown")}};var m=function(t,e){return m=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var n in e)Object.prototype.hasOwnProperty.call(e,n)&&(t[n]=e[n])},m(t,e)};function y(t,e){if("function"!=typeof e&&null!==e)throw new TypeError("Class extends value "+String(e)+" is not a constructor or null");function n(){this.constructor=t}m(t,e),t.prototype=null===e?Object.create(e):(n.prototype=e.prototype,new n)}function _(t,e,n,i){var r,o=arguments.length,a=o<3?e:null===i?i=Object.getOwnPropertyDescriptor(e,n):i;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)a=Reflect.decorate(t,e,n,i);else for(var s=t.length-1;s>=0;s--)(r=t[s])&&(a=(o<3?r(a):o>3?r(e,n,a):r(e,n))||a);return o>3&&a&&Object.defineProperty(e,n,a),a}"function"==typeof SuppressedError&&SuppressedError; +/** + * @license + * Copyright 2019 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +const b=globalThis,x=b.ShadowRoot&&(void 0===b.ShadyCSS||b.ShadyCSS.nativeShadow)&&"adoptedStyleSheets"in Document.prototype&&"replace"in CSSStyleSheet.prototype,w=Symbol(),S=new WeakMap;let C=class{constructor(t,e,n){if(this._$cssResult$=!0,n!==w)throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead.");this.cssText=t,this.t=e}get styleSheet(){let t=this.o;const e=this.t;if(x&&void 0===t){const n=void 0!==e&&1===e.length;n&&(t=S.get(e)),void 0===t&&((this.o=t=new CSSStyleSheet).replaceSync(this.cssText),n&&S.set(e,t))}return t}toString(){return this.cssText}};const k=t=>new C("string"==typeof t?t:t+"",void 0,w),M=(t,...e)=>{const n=1===t.length?t[0]:e.reduce((e,n,i)=>e+(t=>{if(!0===t._$cssResult$)return t.cssText;if("number"==typeof t)return t;throw Error("Value passed to 'css' function must be a 'css' function result: "+t+". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security.")})(n)+t[i+1],t[0]);return new C(n,t,w)},T=x?t=>t:t=>t instanceof CSSStyleSheet?(t=>{let e="";for(const n of t.cssRules)e+=n.cssText;return k(e)})(t):t,{is:I,defineProperty:D,getOwnPropertyDescriptor:A,getOwnPropertyNames:P,getOwnPropertySymbols:L,getPrototypeOf:E}=Object,z=globalThis,N=z.trustedTypes,O=N?N.emptyScript:"",R=z.reactiveElementPolyfillSupport,$=(t,e)=>t,H={toAttribute(t,e){switch(e){case Boolean:t=t?O:null;break;case Object:case Array:t=null==t?t:JSON.stringify(t)}return t},fromAttribute(t,e){let n=t;switch(e){case Boolean:n=null!==t;break;case Number:n=null===t?null:Number(t);break;case Object:case Array:try{n=JSON.parse(t)}catch(t){n=null}}return n}},F=(t,e)=>!I(t,e),B={attribute:!0,type:String,converter:H,reflect:!1,useDefault:!1,hasChanged:F}; +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */Symbol.metadata??=Symbol("metadata"),z.litPropertyMetadata??=new WeakMap;let V=class extends HTMLElement{static addInitializer(t){this._$Ei(),(this.l??=[]).push(t)}static get observedAttributes(){return this.finalize(),this._$Eh&&[...this._$Eh.keys()]}static createProperty(t,e=B){if(e.state&&(e.attribute=!1),this._$Ei(),this.prototype.hasOwnProperty(t)&&((e=Object.create(e)).wrapped=!0),this.elementProperties.set(t,e),!e.noAccessor){const n=Symbol(),i=this.getPropertyDescriptor(t,n,e);void 0!==i&&D(this.prototype,t,i)}}static getPropertyDescriptor(t,e,n){const{get:i,set:r}=A(this.prototype,t)??{get(){return this[e]},set(t){this[e]=t}};return{get:i,set(e){const o=i?.call(this);r?.call(this,e),this.requestUpdate(t,o,n)},configurable:!0,enumerable:!0}}static getPropertyOptions(t){return this.elementProperties.get(t)??B}static _$Ei(){if(this.hasOwnProperty($("elementProperties")))return;const t=E(this);t.finalize(),void 0!==t.l&&(this.l=[...t.l]),this.elementProperties=new Map(t.elementProperties)}static finalize(){if(this.hasOwnProperty($("finalized")))return;if(this.finalized=!0,this._$Ei(),this.hasOwnProperty($("properties"))){const t=this.properties,e=[...P(t),...L(t)];for(const n of e)this.createProperty(n,t[n])}const t=this[Symbol.metadata];if(null!==t){const e=litPropertyMetadata.get(t);if(void 0!==e)for(const[t,n]of e)this.elementProperties.set(t,n)}this._$Eh=new Map;for(const[t,e]of this.elementProperties){const n=this._$Eu(t,e);void 0!==n&&this._$Eh.set(n,t)}this.elementStyles=this.finalizeStyles(this.styles)}static finalizeStyles(t){const e=[];if(Array.isArray(t)){const n=new Set(t.flat(1/0).reverse());for(const t of n)e.unshift(T(t))}else void 0!==t&&e.push(T(t));return e}static _$Eu(t,e){const n=e.attribute;return!1===n?void 0:"string"==typeof n?n:"string"==typeof t?t.toLowerCase():void 0}constructor(){super(),this._$Ep=void 0,this.isUpdatePending=!1,this.hasUpdated=!1,this._$Em=null,this._$Ev()}_$Ev(){this._$ES=new Promise(t=>this.enableUpdating=t),this._$AL=new Map,this._$E_(),this.requestUpdate(),this.constructor.l?.forEach(t=>t(this))}addController(t){(this._$EO??=new Set).add(t),void 0!==this.renderRoot&&this.isConnected&&t.hostConnected?.()}removeController(t){this._$EO?.delete(t)}_$E_(){const t=new Map,e=this.constructor.elementProperties;for(const n of e.keys())this.hasOwnProperty(n)&&(t.set(n,this[n]),delete this[n]);t.size>0&&(this._$Ep=t)}createRenderRoot(){const t=this.shadowRoot??this.attachShadow(this.constructor.shadowRootOptions);return((t,e)=>{if(x)t.adoptedStyleSheets=e.map(t=>t instanceof CSSStyleSheet?t:t.styleSheet);else for(const n of e){const e=document.createElement("style"),i=b.litNonce;void 0!==i&&e.setAttribute("nonce",i),e.textContent=n.cssText,t.appendChild(e)}})(t,this.constructor.elementStyles),t}connectedCallback(){this.renderRoot??=this.createRenderRoot(),this.enableUpdating(!0),this._$EO?.forEach(t=>t.hostConnected?.())}enableUpdating(t){}disconnectedCallback(){this._$EO?.forEach(t=>t.hostDisconnected?.())}attributeChangedCallback(t,e,n){this._$AK(t,n)}_$ET(t,e){const n=this.constructor.elementProperties.get(t),i=this.constructor._$Eu(t,n);if(void 0!==i&&!0===n.reflect){const r=(void 0!==n.converter?.toAttribute?n.converter:H).toAttribute(e,n.type);this._$Em=t,null==r?this.removeAttribute(i):this.setAttribute(i,r),this._$Em=null}}_$AK(t,e){const n=this.constructor,i=n._$Eh.get(t);if(void 0!==i&&this._$Em!==i){const t=n.getPropertyOptions(i),r="function"==typeof t.converter?{fromAttribute:t.converter}:void 0!==t.converter?.fromAttribute?t.converter:H;this._$Em=i;const o=r.fromAttribute(e,t.type);this[i]=o??this._$Ej?.get(i)??o,this._$Em=null}}requestUpdate(t,e,n,i=!1,r){if(void 0!==t){const o=this.constructor;if(!1===i&&(r=this[t]),n??=o.getPropertyOptions(t),!((n.hasChanged??F)(r,e)||n.useDefault&&n.reflect&&r===this._$Ej?.get(t)&&!this.hasAttribute(o._$Eu(t,n))))return;this.C(t,e,n)}!1===this.isUpdatePending&&(this._$ES=this._$EP())}C(t,e,{useDefault:n,reflect:i,wrapped:r},o){n&&!(this._$Ej??=new Map).has(t)&&(this._$Ej.set(t,o??e??this[t]),!0!==r||void 0!==o)||(this._$AL.has(t)||(this.hasUpdated||n||(e=void 0),this._$AL.set(t,e)),!0===i&&this._$Em!==t&&(this._$Eq??=new Set).add(t))}async _$EP(){this.isUpdatePending=!0;try{await this._$ES}catch(t){Promise.reject(t)}const t=this.scheduleUpdate();return null!=t&&await t,!this.isUpdatePending}scheduleUpdate(){return this.performUpdate()}performUpdate(){if(!this.isUpdatePending)return;if(!this.hasUpdated){if(this.renderRoot??=this.createRenderRoot(),this._$Ep){for(const[t,e]of this._$Ep)this[t]=e;this._$Ep=void 0}const t=this.constructor.elementProperties;if(t.size>0)for(const[e,n]of t){const{wrapped:t}=n,i=this[e];!0!==t||this._$AL.has(e)||void 0===i||this.C(e,void 0,n,i)}}let t=!1;const e=this._$AL;try{t=this.shouldUpdate(e),t?(this.willUpdate(e),this._$EO?.forEach(t=>t.hostUpdate?.()),this.update(e)):this._$EM()}catch(e){throw t=!1,this._$EM(),e}t&&this._$AE(e)}willUpdate(t){}_$AE(t){this._$EO?.forEach(t=>t.hostUpdated?.()),this.hasUpdated||(this.hasUpdated=!0,this.firstUpdated(t)),this.updated(t)}_$EM(){this._$AL=new Map,this.isUpdatePending=!1}get updateComplete(){return this.getUpdateComplete()}getUpdateComplete(){return this._$ES}shouldUpdate(t){return!0}update(t){this._$Eq&&=this._$Eq.forEach(t=>this._$ET(t,this[t])),this._$EM()}updated(t){}firstUpdated(t){}};V.elementStyles=[],V.shadowRootOptions={mode:"open"},V[$("elementProperties")]=new Map,V[$("finalized")]=new Map,R?.({ReactiveElement:V}),(z.reactiveElementVersions??=[]).push("2.1.2"); +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +const W=globalThis,U=t=>t,G=W.trustedTypes,q=G?G.createPolicy("lit-html",{createHTML:t=>t}):void 0,j="$lit$",X=`lit$${Math.random().toFixed(9).slice(2)}$`,Y="?"+X,Z=`<${Y}>`,K=document,Q=()=>K.createComment(""),J=t=>null===t||"object"!=typeof t&&"function"!=typeof t,tt=Array.isArray,et="[ \t\n\f\r]",nt=/<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g,it=/-->/g,rt=/>/g,ot=RegExp(`>|${et}(?:([^\\s"'>=/]+)(${et}*=${et}*(?:[^ \t\n\f\r"'\`<>=]|("|')|))|$)`,"g"),at=/'/g,st=/"/g,lt=/^(?:script|style|textarea|title)$/i,ct=t=>(e,...n)=>({_$litType$:t,strings:e,values:n}),ut=ct(1),ht=ct(2),dt=Symbol.for("lit-noChange"),pt=Symbol.for("lit-nothing"),ft=new WeakMap,gt=K.createTreeWalker(K,129);function vt(t,e){if(!tt(t)||!t.hasOwnProperty("raw"))throw Error("invalid template strings array");return void 0!==q?q.createHTML(e):e}const mt=(t,e)=>{const n=t.length-1,i=[];let r,o=2===e?"":3===e?"":"",a=nt;for(let e=0;e"===l[0]?(a=r??nt,c=-1):void 0===l[1]?c=-2:(c=a.lastIndex-l[2].length,s=l[1],a=void 0===l[3]?ot:'"'===l[3]?st:at):a===st||a===at?a=ot:a===it||a===rt?a=nt:(a=ot,r=void 0);const h=a===ot&&t[e+1].startsWith("/>")?" ":"";o+=a===nt?n+Z:c>=0?(i.push(s),n.slice(0,c)+j+n.slice(c)+X+h):n+X+(-2===c?e:h)}return[vt(t,o+(t[n]||"")+(2===e?"":3===e?"":"")),i]};class yt{constructor({strings:t,_$litType$:e},n){let i;this.parts=[];let r=0,o=0;const a=t.length-1,s=this.parts,[l,c]=mt(t,e);if(this.el=yt.createElement(l,n),gt.currentNode=this.el.content,2===e||3===e){const t=this.el.content.firstChild;t.replaceWith(...t.childNodes)}for(;null!==(i=gt.nextNode())&&s.length0){i.textContent=G?G.emptyScript:"";for(let n=0;ntt(t)||"function"==typeof t?.[Symbol.iterator])(t)?this.k(t):this._(t)}O(t){return this._$AA.parentNode.insertBefore(t,this._$AB)}T(t){this._$AH!==t&&(this._$AR(),this._$AH=this.O(t))}_(t){this._$AH!==pt&&J(this._$AH)?this._$AA.nextSibling.data=t:this.T(K.createTextNode(t)),this._$AH=t}$(t){const{values:e,_$litType$:n}=t,i="number"==typeof n?this._$AC(t):(void 0===n.el&&(n.el=yt.createElement(vt(n.h,n.h[0]),this.options)),n);if(this._$AH?._$AD===i)this._$AH.p(e);else{const t=new bt(i,this),n=t.u(this.options);t.p(e),this.T(n),this._$AH=t}}_$AC(t){let e=ft.get(t.strings);return void 0===e&&ft.set(t.strings,e=new yt(t)),e}k(t){tt(this._$AH)||(this._$AH=[],this._$AR());const e=this._$AH;let n,i=0;for(const r of t)i===e.length?e.push(n=new xt(this.O(Q()),this.O(Q()),this,this.options)):n=e[i],n._$AI(r),i++;i2||""!==n[0]||""!==n[1]?(this._$AH=Array(n.length-1).fill(new String),this.strings=n):this._$AH=pt}_$AI(t,e=this,n,i){const r=this.strings;let o=!1;if(void 0===r)t=_t(this,t,e,0),o=!J(t)||t!==this._$AH&&t!==dt,o&&(this._$AH=t);else{const i=t;let a,s;for(t=r[0],a=0;a{const i=n?.renderBefore??e;let r=i._$litPart$;if(void 0===r){const t=n?.renderBefore??null;i._$litPart$=r=new xt(e.insertBefore(Q(),t),t,void 0,n??{})}return r._$AI(t),r})(e,this.renderRoot,this.renderOptions)}connectedCallback(){super.connectedCallback(),this._$Do?.setConnected(!0)}disconnectedCallback(){super.disconnectedCallback(),this._$Do?.setConnected(!1)}render(){return dt}};Dt._$litElement$=!0,Dt.finalized=!0,It.litElementHydrateSupport?.({LitElement:Dt});const At=It.litElementPolyfillSupport;At?.({LitElement:Dt}),(It.litElementVersions??=[]).push("4.2.2"); +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +const Pt={attribute:!0,type:String,converter:H,reflect:!1,hasChanged:F},Lt=(t=Pt,e,n)=>{const{kind:i,metadata:r}=n;let o=globalThis.litPropertyMetadata.get(r);if(void 0===o&&globalThis.litPropertyMetadata.set(r,o=new Map),"setter"===i&&((t=Object.create(t)).wrapped=!0),o.set(n.name,t),"accessor"===i){const{name:i}=n;return{set(n){const r=e.get.call(this);e.set.call(this,n),this.requestUpdate(i,r,t,!0,n)},init(e){return void 0!==e&&this.C(i,void 0,t,e),e}}}if("setter"===i){const{name:i}=n;return function(n){const r=this[i];e.call(this,n),this.requestUpdate(i,r,t,!0,n)}}throw Error("Unsupported decorator location: "+i)}; +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */function Et(t){return(e,n)=>"object"==typeof n?Lt(t,e,n):((t,e,n)=>{const i=e.hasOwnProperty(n);return e.constructor.createProperty(n,t),i?Object.getOwnPropertyDescriptor(e,n):void 0})(t,e,n)} +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */function zt(t){return Et({...t,state:!0,attribute:!1})} +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */const Nt=2;class Ot{constructor(t){}get _$AU(){return this._$AM._$AU}_$AT(t,e,n){this._$Ct=t,this._$AM=e,this._$Ci=n}_$AS(t,e){return this.update(t,e)}update(t,e){return this.render(...e)}} +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */let Rt=class extends Ot{constructor(t){if(super(t),this.it=pt,t.type!==Nt)throw Error(this.constructor.directiveName+"() can only be used in child bindings")}render(t){if(t===pt||null==t)return this._t=void 0,this.it=t;if(t===dt)return t;if("string"!=typeof t)throw Error(this.constructor.directiveName+"() called with a non-string value");if(t===this.it)return this._t;this.it=t;const e=[t];return e.raw=e,this._t={_$litType$:this.constructor.resultType,strings:e,values:[]}}};Rt.directiveName="unsafeHTML",Rt.resultType=1;const $t=(t=>(...e)=>({_$litDirective$:t,values:e}))(Rt);class Ht{constructor(){this._persistent=new Map,this._transient=null,this._transientTimer=null,this._subscribers=new Set,this._watchedPanels=new Map}add(t){const e={...t,timestamp:Date.now()};if(e.persistent)this._persistent.set(e.key,e);else{this._clearTransient(),this._transient=e;const t=e.ttl??5e3;this._transientTimer=setTimeout(()=>{this._transient=null,this._transientTimer=null,this._notify()},t)}this._notify()}remove(t){if(this._persistent.has(t))return this._persistent.delete(t),void this._notify();this._transient?.key===t&&(this._clearTransient(),this._notify())}clear(t){void 0===t?(this._persistent.clear(),this._clearTransient(),this._watchedPanels.clear()):!0===t.persistent?this._persistent.clear():!1===t.persistent&&this._clearTransient(),this._notify()}get active(){const t=[...this._persistent.values()];return null!==this._transient&&t.push(this._transient),t}hasPersistent(t){return this._persistent.has(t)}hasAnyPanelOffline(){for(const t of this._persistent.keys())if("panel-offline"===t||t.startsWith("panel-offline:"))return!0;return!1}subscribe(t){return this._subscribers.add(t),()=>{this._subscribers.delete(t)}}watchPanelStatus(t){this.watchPanelStatuses([{entityId:t,panelName:null}])}watchPanelStatuses(t){const e=this._watchedPanels,n=new Map;for(const i of t){const t=e.get(i.entityId);n.set(i.entityId,{panelName:i.panelName??null,wasOffline:t?.wasOffline??!1})}const i=this._isSingleUnnamed(e),r=this._isSingleUnnamed(n);for(const t of e.keys()){n.has(t)&&i===r||this._persistent.delete(this._offlineKey(t,i))}this._watchedPanels=n,this._notify()}clearPanelStatusWatch(){if(0===this._watchedPanels.size)return;const t=this._isSingleUnnamed(this._watchedPanels);for(const e of this._watchedPanels.keys())this._persistent.delete(this._offlineKey(e,t));this._watchedPanels.clear(),this._notify()}updateHass(t){if(0===this._watchedPanels.size)return;const e=this._isSingleUnnamed(this._watchedPanels);for(const[r,o]of this._watchedPanels){const a=t.states[r]?.state,s="on"===a,l=this._offlineKey(r,e),c=this._reconnectKey(r,e);if(s){const t=o.wasOffline;o.wasOffline=!1,this.remove(l),t&&this.add({key:c,level:"info",message:null===o.panelName?n("error.panel_reconnected"):i("error.panel_reconnected_named",{name:o.panelName}),persistent:!1})}else o.wasOffline=!0,this.hasPersistent(l)||this.add({key:l,level:"error",message:null===o.panelName?n("error.panel_offline"):i("error.panel_offline_named",{name:o.panelName}),persistent:!0})}}dispose(){this._clearTransient(),this._persistent.clear(),this._subscribers.clear(),this._watchedPanels.clear()}_isSingleUnnamed(t){if(1!==t.size)return!1;for(const e of t.values())return null===e.panelName;return!1}_offlineKey(t,e){return e?"panel-offline":`panel-offline:${t}`}_reconnectKey(t,e){return e?"panel-reconnected":`panel-reconnected:${t}`}_clearTransient(){null!==this._transientTimer&&(clearTimeout(this._transientTimer),this._transientTimer=null),this._transient=null}_notify(){for(const t of this._subscribers)try{t()}catch(t){console.warn("SPAN Panel: error-store subscriber threw",t)}}}const Ft={"&":"&","<":"<",">":">",'"':""","'":"'"};function Bt(t){return String(t).replace(/[&<>"']/g,t=>Ft[t]??t)}const Vt="span_panel_list_columns";function Wt(){try{const t=localStorage.getItem(Vt);if(!t)return 1;const e=parseInt(t,10);return 1===e||2===e||3===e?e:1}catch{return 1}}function Ut(t){try{localStorage.setItem(Vt,String(t))}catch{}}function Gt(t){return new Promise(e=>setTimeout(e,t))}class qt{constructor(t){this._store=t}async callWS(t,e,n){const i=n?.retries??3,r=n?.errorId??`ws:${String(e.type??"unknown")}`;return this._withRetry(()=>t.callWS(e),i,r,n?.errorMessage)}async callService(t,e,n,i,r,o){const a=o?.retries??3,s=o?.errorId??`svc:${e}.${n}`;return this._withRetry(()=>t.callService(e,n,i,r),a,s,o?.errorMessage)}async _withRetry(t,e,i,r){if(this._store.hasAnyPanelOffline())try{const e=await t();return this._store.remove(i),e}catch(t){const e=t instanceof Error?t:new Error(String(t));throw this._store.add({key:i,level:"error",message:r??n("error.panel_offline"),persistent:!1}),e}let o;for(let n=0;n<=e;n++)try{const e=await t();return this._store.remove(i),e}catch(t){if(o=t instanceof Error?t:new Error(String(t)),n{try{const e={type:"call_service",domain:s,service:"get_favorites",service_data:{},return_response:!0},r=this._retry?await this._retry.callWS(t,e,{errorId:"fetch:favorites",errorMessage:n("error.favorites_fetch_failed")}):await t.callWS(e),o=r?.response?.favorites??{};return i===this._generation&&(this._map=o,this._lastFetch=Date.now()),o}catch(t){return console.warn("SPAN Panel: favorites fetch failed",t),this._retry||this._errorStore?.add({key:"fetch:favorites",level:"warning",message:n("error.favorites_fetch_failed"),persistent:!1}),this._map??{}}finally{this._inflight?.gen===i&&(this._inflight=null)}})();return this._inflight={gen:i,promise:r},r}invalidate(){this._lastFetch=0,this._generation++}clear(){this._map=null,this._lastFetch=0,this._generation++}get map(){return this._map??{}}}function Zt(t){for(const e of Object.values(t)){if((e.circuits?.length??0)>0)return!0;if((e.sub_devices?.length??0)>0)return!0}return!1}const Kt=Object.keys(v).filter(t=>"unknown"!==t&&"always_on"!==t);class Qt extends HTMLElement{constructor(){super(),this.errorStore=null,this.attachShadow({mode:"open"}),this._hass=null,this._config=null,this._debounceTimers={}}set hass(t){this._hass=t,this.hasAttribute("open")&&this._config&&this._updateLiveState()}get hass(){return this._hass}disconnectedCallback(){this._clearDebounceTimers(),this._config=null}open(t){this._config=t,this._render(),this.offsetHeight,this.setAttribute("open",""),this.setAttribute("data-mode",this._modeFor(t))}close(){this._clearDebounceTimers(),this.removeAttribute("open"),this.removeAttribute("data-mode"),this._config=null,this.dispatchEvent(new CustomEvent("side-panel-closed",{bubbles:!0,composed:!0}))}_clearDebounceTimers(){for(const t of Object.keys(this._debounceTimers))clearTimeout(this._debounceTimers[t]);this._debounceTimers={}}_modeFor(t){return t.favoritesMode?"favorites":t.panelMode?"panel":t.subDeviceMode?"subDevice":"circuit"}_render(){const t=this._config;if(!t)return;const e=this.shadowRoot;if(!e)return;e.innerHTML="";const n=document.createElement("style");n.textContent='\n :host {\n display: block;\n position: fixed;\n top: 0;\n right: 0;\n bottom: 0;\n width: 360px;\n max-width: 90vw;\n z-index: 1000;\n transform: translateX(100%);\n transition: transform 0.3s ease;\n pointer-events: none;\n }\n :host([open]) {\n transform: translateX(0);\n pointer-events: auto;\n }\n\n .backdrop {\n display: none;\n position: fixed;\n top: 0;\n left: 0;\n right: 0;\n bottom: 0;\n background: rgba(0, 0, 0, 0.3);\n z-index: -1;\n }\n :host([open]) .backdrop {\n display: block;\n }\n\n .panel {\n height: 100%;\n background: var(--card-background-color, #fff);\n border-left: 1px solid var(--divider-color, #e0e0e0);\n display: flex;\n flex-direction: column;\n overflow: hidden;\n }\n\n .panel-header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 16px;\n border-bottom: 1px solid var(--divider-color, #e0e0e0);\n }\n .panel-header .title {\n font-size: 18px;\n font-weight: 500;\n color: var(--primary-text-color, #212121);\n margin: 0;\n }\n .panel-header .subtitle {\n font-size: 13px;\n color: var(--secondary-text-color, #727272);\n margin: 2px 0 0 0;\n }\n .close-btn {\n background: none;\n border: none;\n cursor: pointer;\n color: var(--secondary-text-color, #727272);\n padding: 4px;\n line-height: 1;\n font-size: 20px;\n }\n\n .panel-body {\n flex: 1;\n overflow-y: auto;\n padding: 16px;\n }\n\n .section {\n margin-bottom: 20px;\n }\n .section-label {\n font-size: 12px;\n font-weight: 600;\n text-transform: uppercase;\n color: var(--secondary-text-color, #727272);\n margin: 0 0 8px 0;\n letter-spacing: 0.5px;\n }\n\n .field-row {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 8px 0;\n }\n .field-label {\n font-size: 14px;\n color: var(--primary-text-color, #212121);\n }\n\n select {\n padding: 6px 8px;\n border: 1px solid var(--divider-color, #e0e0e0);\n border-radius: 4px;\n background: var(--card-background-color, #fff);\n color: var(--primary-text-color, #212121);\n font-size: 14px;\n }\n\n input[type="number"] {\n width: 72px;\n padding: 6px 8px;\n border: 1px solid var(--divider-color, #e0e0e0);\n border-radius: 4px;\n background: var(--card-background-color, #fff);\n color: var(--primary-text-color, #212121);\n font-size: 14px;\n text-align: right;\n }\n input[type="number"]:disabled {\n opacity: 0.5;\n }\n\n .radio-group {\n display: flex;\n gap: 16px;\n padding: 8px 0;\n }\n .radio-group label {\n display: flex;\n align-items: center;\n gap: 6px;\n font-size: 14px;\n color: var(--primary-text-color, #212121);\n cursor: pointer;\n }\n\n .horizon-bar {\n display: flex;\n border: 1px solid var(--divider-color, #e0e0e0);\n border-radius: 6px;\n overflow: hidden;\n margin-top: 4px;\n }\n .horizon-segment {\n flex: 1;\n padding: 6px 0;\n text-align: center;\n font-size: 13px;\n cursor: pointer;\n background: var(--card-background-color, #fff);\n color: var(--primary-text-color, #212121);\n border: none;\n border-right: 1px solid var(--divider-color, #e0e0e0);\n transition: background 0.15s ease, color 0.15s ease;\n user-select: none;\n line-height: 1.4;\n }\n .horizon-segment:last-child {\n border-right: none;\n }\n .horizon-segment:hover:not(.active) {\n background: var(--secondary-background-color, #f5f5f5);\n }\n .horizon-segment.active {\n background: var(--primary-color, #03a9f4);\n color: #fff;\n font-weight: 600;\n }\n .horizon-segment.referenced {\n box-shadow: inset 0 -3px 0 var(--primary-color, #03a9f4);\n }\n\n .unit-toggle {\n display: inline-flex;\n border: 1px solid var(--divider-color, #e0e0e0);\n border-radius: 6px;\n overflow: hidden;\n }\n .unit-btn {\n padding: 4px 10px;\n border: none;\n border-right: 1px solid var(--divider-color, #e0e0e0);\n background: var(--card-background-color, #fff);\n color: var(--primary-text-color, #212121);\n font-size: 13px;\n font-weight: 500;\n cursor: pointer;\n transition: background 0.15s ease, color 0.15s ease;\n }\n .unit-btn:last-child {\n border-right: none;\n }\n .unit-btn:hover:not(.unit-active) {\n background: var(--secondary-background-color, #f5f5f5);\n }\n .unit-btn.unit-active {\n background: var(--primary-color, #03a9f4);\n color: #fff;\n font-weight: 600;\n }\n\n .monitoring-header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n }\n\n .fav-heart {\n background: none;\n border: 1px solid var(--divider-color, #e0e0e0);\n color: var(--secondary-text-color, #727272);\n border-radius: 4px;\n padding: 2px 6px;\n cursor: pointer;\n font-size: 0.9em;\n margin-right: 6px;\n line-height: 1;\n display: inline-flex;\n align-items: center;\n }\n .fav-heart.active {\n color: var(--primary-color, #03a9f4);\n border-color: var(--primary-color, #03a9f4);\n }\n .fav-heart:hover:not(.active) {\n background: var(--secondary-background-color, #f5f5f5);\n }\n .fav-heart span-icon {\n --mdc-icon-size: 16px;\n }\n\n .panel-mode-info {\n font-size: 14px;\n color: var(--primary-text-color, #212121);\n line-height: 1.6;\n }\n .panel-mode-info p {\n margin: 0 0 12px 0;\n }\n\n',e.appendChild(n);const i=document.createElement("div");i.className="backdrop",i.addEventListener("click",()=>this.close()),e.appendChild(i);const r=document.createElement("div");r.className="panel",e.appendChild(r),t.favoritesMode?this._renderFavoritesMode(r):t.panelMode?this._renderPanelMode(r):t.subDeviceMode?this._renderSubDeviceMode(r,t):this._renderCircuitMode(r,t)}_renderPanelMode(t){const e=this._config,i=this._createHeader(n("sidepanel.graph_settings"),n("sidepanel.global_defaults"));t.appendChild(i);const r=document.createElement("div");r.className="panel-body";const s=e.graphSettings,l=e.topology,c=s?.global_horizon??o,u=s?.circuits??{};r.appendChild(this._buildListColumnsSection());const h=document.createElement("div");h.className="section";const d=document.createElement("div");d.className="section-label",d.textContent=n("sidepanel.graph_horizon"),h.appendChild(d);const f=document.createElement("div");f.className="field-row";const g=document.createElement("span");g.className="field-label",g.textContent=n("sidepanel.global_default"),f.appendChild(g);const v=document.createElement("select");for(const t of Object.keys(a)){const e=document.createElement("option");e.value=t;const i=`horizon.${t}`,r=n(i);e.textContent=r!==i?r:t,t===c&&(e.selected=!0),v.appendChild(e)}if(v.addEventListener("change",()=>{const t={horizon:v.value};e.configEntryId&&(t.config_entry_id=e.configEntryId),this._callDomainService("set_graph_time_horizon",t).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(t=>{console.warn("SPAN Panel: graph horizon service failed",t),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:n("error.graph_horizon_failed"),persistent:!1})})}),f.appendChild(v),h.appendChild(f),r.appendChild(h),l?.circuits){const t=document.createElement("div");t.className="section";const i=document.createElement("div");i.className="section-label",i.textContent=n("sidepanel.circuit_scales"),t.appendChild(i);const o=Object.entries(l.circuits).sort(([,t],[,e])=>(t.name||"").localeCompare(e.name||""));for(const[n,i]of o){const r=this._buildPanelModeCircuitRow(n,i,u[n],c,e.configEntryId??null,e.showFavorites??!1,e.favoritePanelDeviceId,e.favoriteCircuitUuids);t.appendChild(r)}r.appendChild(t)}const m=s?.sub_devices??{};if(l?.sub_devices){const t=document.createElement("div");t.className="section";const i=document.createElement("div");i.className="section-label",i.textContent=n("sidepanel.subdevice_scales"),t.appendChild(i);const o=Object.entries(l.sub_devices).sort(([,t],[,e])=>(t.name||"").localeCompare(e.name||""));for(const[i,r]of o){const o=document.createElement("div");o.className="field-row";const s=document.createElement("span");if(s.className="field-label",s.textContent=r.name||i,s.style.cssText="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;flex:1;",o.appendChild(s),e.showFavorites&&e.favoritePanelDeviceId){const t=this._buildSubDeviceFavoriteHeart(r.entities,e.favoriteSubDeviceIds?.has(i)??!1);t&&o.appendChild(t)}const l=m[i]||{horizon:c,has_override:!1},u=l.has_override?l.horizon:c,h=document.createElement("select");h.dataset.subdevId=i;for(const t of Object.keys(a)){const e=document.createElement("option");e.value=t;const i=`horizon.${t}`,r=n(i);e.textContent=r!==i?r:t,t===u&&(e.selected=!0),h.appendChild(e)}if(h.addEventListener("change",()=>{this._debounce(`subdev-${i}`,p,()=>{const t={subdevice_id:i,horizon:h.value};e.configEntryId&&(t.config_entry_id=e.configEntryId),this._callDomainService("set_subdevice_graph_horizon",t).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(t=>{console.warn("SPAN Panel: graph horizon service failed",t),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:n("error.graph_horizon_failed"),persistent:!1})})})}),o.appendChild(h),l.has_override){const t=document.createElement("button");t.textContent="↺",t.title=n("sidepanel.reset_to_global"),Object.assign(t.style,{background:"none",border:"1px solid var(--divider-color, #e0e0e0)",color:"var(--primary-text-color)",borderRadius:"4px",padding:"3px 6px",cursor:"pointer",marginLeft:"4px",fontSize:"0.85em"}),t.addEventListener("click",()=>{const r={subdevice_id:i};e.configEntryId&&(r.config_entry_id=e.configEntryId),this._callDomainService("clear_subdevice_graph_horizon",r).then(()=>{h.value=c,t.remove(),this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(t=>{console.warn("SPAN Panel: graph horizon service failed",t),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:n("error.graph_horizon_failed"),persistent:!1})})}),o.appendChild(t)}t.appendChild(o)}r.appendChild(t)}t.appendChild(r)}_buildPanelModeCircuitRow(t,e,i,r,o,s,l,c){const u=document.createElement("div");u.className="field-row";const h=document.createElement("span");if(h.className="field-label",h.textContent=e.name||t,h.style.cssText="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;flex:1;",u.appendChild(h),s&&l){const n=this._buildFavoriteHeart(e.entities,c?.has(t)??!1);n&&u.appendChild(n)}const d=i||{horizon:r,has_override:!1},f=d.has_override?d.horizon:r,g=document.createElement("select");g.dataset.uuid=t;for(const t of Object.keys(a)){const e=document.createElement("option");e.value=t;const i=`horizon.${t}`,r=n(i);e.textContent=r!==i?r:t,t===f&&(e.selected=!0),g.appendChild(e)}if(g.addEventListener("change",()=>{this._debounce(`circuit-${t}`,p,()=>{const e={circuit_id:t,horizon:g.value};o&&(e.config_entry_id=o),this._callDomainService("set_circuit_graph_horizon",e).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(t=>{console.warn("SPAN Panel: graph horizon service failed",t),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:n("error.graph_horizon_failed"),persistent:!1})})})}),u.appendChild(g),d.has_override){const e=document.createElement("button");e.textContent="↺",e.title=n("sidepanel.reset_to_global"),Object.assign(e.style,{background:"none",border:"1px solid var(--divider-color, #e0e0e0)",color:"var(--primary-text-color)",borderRadius:"4px",padding:"3px 6px",cursor:"pointer",marginLeft:"4px",fontSize:"0.85em"}),e.addEventListener("click",()=>{const i={circuit_id:t};o&&(i.config_entry_id=o),this._callDomainService("clear_circuit_graph_horizon",i).then(()=>{g.value=r,e.remove(),this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(t=>{console.warn("SPAN Panel: graph horizon service failed",t),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:n("error.graph_horizon_failed"),persistent:!1})})}),u.appendChild(e)}return u}_renderFavoritesMode(t){const e=this._config,i=this._createHeader(n("sidepanel.graph_settings"),n("sidepanel.favorites_subtitle"));t.appendChild(i);const r=document.createElement("div");r.className="panel-body",r.appendChild(this._buildListColumnsSection());for(const t of e.perPanelSections)r.appendChild(this._buildFavoritesPanelSection(t));t.appendChild(r)}_buildFavoritesPanelSection(t){const e=document.createElement("div");e.className="section";const n=document.createElement("div");n.className="section-label",n.textContent=t.panelName,e.appendChild(n);const i=t.graphSettings?.global_horizon??o,r=t.graphSettings?.circuits??{},a=function(t){const e=t.circuits??{};return Object.entries(e).map(([t,e])=>({uuid:t,circuit:e})).sort((t,e)=>(t.circuit.name||"").localeCompare(e.circuit.name||""))}(t.topology);for(const{uuid:n,circuit:o}of a){const a=this._buildPanelModeCircuitRow(n,o,r[n],i,t.configEntryId,!0,t.panelDeviceId,t.favoriteCircuitUuids);e.appendChild(a)}return e}_renderCircuitMode(t,e){const n=`${Bt(String(e.breaker_rating_a))}A · ${Bt(String(e.voltage))}V · Tabs [${Bt(String(e.tabs))}]`,i=this._createHeader(Bt(e.name),n);t.appendChild(i);const r=document.createElement("div");r.className="panel-body",t.appendChild(r),this._renderRelaySection(r,e),e.showFavorites&&this._renderFavoriteSection(r,e),this._renderSheddingSection(r,e),this._renderGraphHorizonSection(r,e),e.showMonitoring&&this._renderMonitoringSection(r,e)}_favoriteEntityId(t){return t?.current??t?.power??null}_subDeviceFavoriteEntityId(t){if(!t)return null;let e=null;for(const[n,i]of Object.entries(t)){if("sensor"===i.domain)return n;e||(e=n)}return e}_buildSubDeviceFavoriteHeart(t,e){const n=this._subDeviceFavoriteEntityId(t);return n?this._buildHeartButton(n,e):null}_buildListColumnsSection(){const t=document.createElement("div");t.className="section";const e=document.createElement("div");e.className="section-label",e.textContent=n("sidepanel.list_view_columns"),t.appendChild(e);const i=document.createElement("div");i.className="field-row";const r=document.createElement("span");r.className="field-label",r.textContent=n("sidepanel.columns"),i.appendChild(r);const o=Wt(),a=document.createElement("div");a.className="unit-toggle";for(const t of[1,2,3]){const e=document.createElement("button");e.type="button",e.className="unit-btn"+(t===o?" unit-active":""),e.dataset.columns=String(t),e.textContent=String(t),e.addEventListener("click",()=>{Ut(t);for(const t of a.querySelectorAll(".unit-btn"))t.classList.toggle("unit-active",t===e);this.dispatchEvent(new CustomEvent("list-columns-changed",{detail:t,bubbles:!0,composed:!0}))}),a.appendChild(e)}return i.appendChild(a),t.appendChild(i),t}_buildFavoriteHeart(t,e){const n=this._favoriteEntityId(t);return n?this._buildHeartButton(n,e):(console.warn("SPAN Panel: circuit has no current/power sensor; favorite heart suppressed"),null)}_buildHeartButton(t,e){const i=document.createElement("button");i.type="button",i.className=e?"fav-heart active":"fav-heart",i.dataset.role="fav-heart",i.title=n("sidepanel.save_to_favorites"),i.setAttribute("role","switch"),i.setAttribute("aria-checked",String(e)),i.setAttribute("aria-label",n("sidepanel.save_to_favorites"));const r=document.createElement("span-icon");return r.setAttribute("icon",e?"mdi:heart":"mdi:heart-outline"),i.appendChild(r),i.addEventListener("click",e=>{e.stopPropagation(),this._toggleFavoriteEntity(i,r,t).catch(()=>{})}),i}async _toggleFavoriteEntity(t,e,i){if(!this._hass)return;const r=t.classList.contains("active"),o=!r;t.classList.toggle("active",o),e.setAttribute("icon",o?"mdi:heart":"mdi:heart-outline"),t.setAttribute("aria-checked",String(o));try{o?await async function(t,e){const n=await Xt(t,"add_favorite",{entity_id:e});return document.dispatchEvent(new CustomEvent(jt)),n?.favorites??{}}(this._hass,i):await async function(t,e){const n=await Xt(t,"remove_favorite",{entity_id:e});return document.dispatchEvent(new CustomEvent(jt)),n?.favorites??{}}(this._hass,i)}catch(i){throw t.classList.toggle("active",r),e.setAttribute("icon",r?"mdi:heart":"mdi:heart-outline"),t.setAttribute("aria-checked",String(r)),console.warn("SPAN Panel: favorite toggle failed",i),this.errorStore?.add({key:"service:favorites",level:"error",message:n("error.favorites_toggle_failed"),persistent:!1}),i}}_renderFavoriteSection(t,e){const n=this._favoriteEntityId(e.entities);n&&this._appendFavoriteHeartSection(t,n,!0===e.isFavorite)}_appendFavoriteHeartSection(t,e,i){const r=document.createElement("div");r.className="section",r.innerHTML=``;const o=document.createElement("div");o.className="field-row";const a=document.createElement("span");a.className="field-label",a.textContent=n("sidepanel.save_to_favorites"),o.appendChild(a),o.appendChild(this._buildHeartButton(e,i)),r.appendChild(o),t.appendChild(r)}_renderSubDeviceMode(t,e){const n=this._createHeader(Bt(e.name),Bt(e.deviceType));t.appendChild(n);const i=document.createElement("div");i.className="panel-body",t.appendChild(i),e.showFavorites&&this._renderSubDeviceFavoriteSection(i,e),this._renderSubDeviceHorizonSection(i,e)}_renderSubDeviceFavoriteSection(t,e){const n=this._subDeviceFavoriteEntityId(e.entities);n&&this._appendFavoriteHeartSection(t,n,!0===e.isFavorite)}_renderSubDeviceHorizonSection(t,e){const i=document.createElement("div");i.className="section";const r=document.createElement("div");r.className="section-label",r.textContent=n("sidepanel.graph_horizon"),i.appendChild(r);const s=e.graphHorizonInfo,l=!0===s?.has_override,c=s?.horizon||o,u=s?.globalHorizon||o,h=document.createElement("div");h.className="horizon-bar";const d=[{key:"global",label:n("sidepanel.global")}];for(const t of Object.keys(a))d.push({key:t,label:t});const p=l?c:"global",f=t=>{for(const e of h.querySelectorAll(".horizon-segment")){const n=e.dataset.horizon;e.classList.toggle("active",n===t),e.classList.toggle("referenced","global"===t&&n===u)}};for(const{key:t,label:i}of d){const r=document.createElement("button");r.type="button",r.className="horizon-segment",r.dataset.horizon=t,r.textContent=i,r.classList.toggle("active",t===p),r.classList.toggle("referenced","global"===p&&t===u),r.addEventListener("click",()=>{if(r.classList.contains("active"))return;const i={subdevice_id:e.subDeviceId};e.configEntryId&&(i.config_entry_id=e.configEntryId),"global"===t?(f("global"),this._callDomainService("clear_subdevice_graph_horizon",i).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(t=>{console.warn("SPAN Panel: graph horizon service failed",t),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:n("error.graph_horizon_failed"),persistent:!1})})):(f(t),this._callDomainService("set_subdevice_graph_horizon",{...i,horizon:t}).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(t=>{console.warn("SPAN Panel: graph horizon service failed",t),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:n("error.graph_horizon_failed"),persistent:!1})}))}),h.appendChild(r)}i.appendChild(h),t.appendChild(i)}_createHeader(t,e){const n=document.createElement("div");n.className="panel-header";const i=document.createElement("div"),r=Bt(t),o=Bt(e);i.innerHTML=`
${r}
`+(o?`
${o}
`:"");const a=document.createElement("button");return a.className="close-btn",a.innerHTML="✕",a.addEventListener("click",()=>this.close()),n.appendChild(i),n.appendChild(a),n}_renderRelaySection(t,e){if(!1===e.is_user_controllable||!e.entities?.switch)return;const i=document.createElement("div");i.className="section",i.innerHTML=``;const r=document.createElement("div");r.className="field-row";const o=document.createElement("span");o.className="field-label",o.textContent=n("sidepanel.breaker");const a=document.createElement("span-switch");a.dataset.role="relay-toggle";const s=e.entities.switch,l=this._hass?.states?.[s]?.state;"on"===l&&a.setAttribute("checked",""),a.addEventListener("change",()=>{const t=a.hasAttribute("checked")||a.checked;this._callService("switch",t?"turn_on":"turn_off",{entity_id:s}).catch(t=>{console.warn("SPAN Panel: relay toggle failed",t),this.errorStore?.add({key:"service:relay",level:"error",message:n("error.relay_failed"),persistent:!1})})}),r.appendChild(o),r.appendChild(a),i.appendChild(r),t.appendChild(i)}_renderSheddingSection(t,e){if(!e.entities?.select)return;const i=document.createElement("div");i.className="section",i.innerHTML=``;const r=document.createElement("div");r.className="field-row";const o=document.createElement("span");o.className="field-label",o.textContent=n("sidepanel.priority_label");const a=document.createElement("select");a.dataset.role="shedding-select";const s=e.entities.select,l=this._hass?.states?.[s]?.state||"";for(const t of Kt){const e=v[t];if(!e)continue;const i=document.createElement("option");i.value=t,i.textContent=n(`shedding.select.${t}`)||e.label(),t===l&&(i.selected=!0),a.appendChild(i)}a.addEventListener("change",()=>{this._callService("select","select_option",{entity_id:s,option:a.value}).catch(t=>{console.warn("SPAN Panel: shedding update failed",t),this.errorStore?.add({key:"service:shedding",level:"error",message:n("error.shedding_failed"),persistent:!1})})}),r.appendChild(o),r.appendChild(a),i.appendChild(r),t.appendChild(i)}_renderGraphHorizonSection(t,e){const i=document.createElement("div");i.className="section";const r=document.createElement("div");r.className="section-label",r.textContent=n("sidepanel.graph_horizon"),i.appendChild(r);const s=e.graphHorizonInfo,l=!0===s?.has_override,c=s?.horizon||o,u=s?.globalHorizon||o,h=document.createElement("div");h.className="horizon-bar";const d=[{key:"global",label:n("sidepanel.global")}];for(const t of Object.keys(a))d.push({key:t,label:t});const p=l?c:"global",f=t=>{for(const e of h.querySelectorAll(".horizon-segment")){const n=e.dataset.horizon;e.classList.toggle("active",n===t),e.classList.toggle("referenced","global"===t&&n===u)}};for(const{key:t,label:i}of d){const r=document.createElement("button");r.type="button",r.className="horizon-segment",r.dataset.horizon=t,r.textContent=i,r.classList.toggle("active",t===p),r.classList.toggle("referenced","global"===p&&t===u),r.addEventListener("click",()=>{if(r.classList.contains("active"))return;const i={circuit_id:e.uuid};e.configEntryId&&(i.config_entry_id=e.configEntryId),"global"===t?(f("global"),this._callDomainService("clear_circuit_graph_horizon",i).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(t=>{console.warn("SPAN Panel: graph horizon service failed",t),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:n("error.graph_horizon_failed"),persistent:!1})})):(f(t),this._callDomainService("set_circuit_graph_horizon",{...i,horizon:t}).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(t=>{console.warn("SPAN Panel: graph horizon service failed",t),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:n("error.graph_horizon_failed"),persistent:!1})}))}),h.appendChild(r)}i.appendChild(h),t.appendChild(i)}_renderMonitoringSection(t,e){const i=document.createElement("div");i.className="section";const r=document.createElement("div");r.className="monitoring-header";const o=document.createElement("div");o.className="section-label",o.textContent=n("sidepanel.monitoring"),o.style.margin="0";const a=document.createElement("span-switch");a.dataset.role="monitoring-toggle";const s=e.monitoringInfo,l=null!=s&&!1!==s.monitoring_enabled;l&&a.setAttribute("checked",""),r.appendChild(o),r.appendChild(a),i.appendChild(r);const c=document.createElement("div");c.dataset.role="monitoring-details",c.style.display=l?"block":"none",i.appendChild(c);const u=!0===s?.has_override,h=document.createElement("div");h.className="radio-group",h.innerHTML=`\n \n \n `,c.appendChild(h);const d=document.createElement("div");d.dataset.role="threshold-fields",d.style.display=u?"block":"none";const p=s?.continuous_threshold_pct??80,f=s?.spike_threshold_pct??100,g=s?.window_duration_m??15,v=s?.cooldown_duration_m??15;d.appendChild(this._createThresholdRow(n("sidepanel.continuous_pct"),"continuous",p,e)),d.appendChild(this._createThresholdRow(n("sidepanel.spike_pct"),"spike",f,e)),d.appendChild(this._createDurationRow(n("sidepanel.window_duration"),"window-m",g,1,180,"m",e)),d.appendChild(this._createDurationRow(n("sidepanel.cooldown"),"cooldown-m",v,1,180,"m",e)),c.appendChild(d),a.addEventListener("change",()=>{const t=a.checked;c.style.display=t?"block":"none";const i={circuit_id:e.entities?.power||e.uuid,monitoring_enabled:t};e.configEntryId&&(i.config_entry_id=e.configEntryId),this._callDomainService("set_circuit_threshold",i).catch(t=>{console.warn("SPAN Panel: monitoring update failed",t),this.errorStore?.add({key:"service:monitoring",level:"error",message:n("error.threshold_failed"),persistent:!1})})});const m=h.querySelectorAll('input[type="radio"]');for(const t of m)t.addEventListener("change",()=>{const i="custom"===t.value&&t.checked;if(d.style.display=i?"block":"none",!i&&t.checked){const t={circuit_id:e.entities?.power||e.uuid};e.configEntryId&&(t.config_entry_id=e.configEntryId),this._callDomainService("clear_circuit_threshold",t).catch(t=>{console.warn("SPAN Panel: monitoring update failed",t),this.errorStore?.add({key:"service:monitoring",level:"error",message:n("error.threshold_failed"),persistent:!1})})}});t.appendChild(i)}_createThresholdRow(t,e,i,r){const o=document.createElement("div");o.className="field-row";const a=document.createElement("span");a.className="field-label",a.textContent=t;const s=document.createElement("input");return s.type="number",s.min="0",s.max="200",s.value=String(i),s.dataset.role=`threshold-${e}`,s.addEventListener("input",()=>{this._debounce(`threshold-${e}`,p,()=>{const t=this.shadowRoot;if(!t)return;const e=t.querySelector('[data-role="threshold-continuous"]'),i=t.querySelector('[data-role="threshold-spike"]'),o=t.querySelector('[data-role="threshold-window-m"]'),a=t.querySelector('[data-role="threshold-cooldown-m"]'),s={circuit_id:r.entities?.power||r.uuid,continuous_threshold_pct:e?Number(e.value):void 0,spike_threshold_pct:i?Number(i.value):void 0,window_duration_m:o?Number(o.value):void 0,cooldown_duration_m:a?Number(a.value):void 0};r.configEntryId&&(s.config_entry_id=r.configEntryId),this._callDomainService("set_circuit_threshold",s).catch(t=>{console.warn("SPAN Panel: monitoring update failed",t),this.errorStore?.add({key:"service:monitoring",level:"error",message:n("error.threshold_failed"),persistent:!1})})})}),o.appendChild(a),o.appendChild(s),o}_createDurationRow(t,e,i,r,o,a,s,l=!1){const c=document.createElement("div");c.className="field-row";const u=document.createElement("span");u.className="field-label",u.textContent=t;const h=document.createElement("div"),d=document.createElement("input");d.type="number",d.min=String(r),d.max=String(o),d.value=String(i),d.dataset.role=`threshold-${e}`,l&&(d.disabled=!0);const f=document.createElement("span");return f.textContent=a,h.appendChild(d),h.appendChild(f),l||d.addEventListener("input",()=>{this._debounce(`threshold-${e}`,p,()=>{const t=this.shadowRoot;if(!t)return;const e=t.querySelector('[data-role="threshold-continuous"]'),i=t.querySelector('[data-role="threshold-spike"]'),r=t.querySelector('[data-role="threshold-window-m"]'),o={circuit_id:s.uuid,continuous_threshold_pct:e?Number(e.value):void 0,spike_threshold_pct:i?Number(i.value):void 0,window_duration_m:r?Number(r.value):void 0};s.configEntryId&&(o.config_entry_id=s.configEntryId),this._callDomainService("set_circuit_threshold",o).catch(t=>{console.warn("SPAN Panel: monitoring update failed",t),this.errorStore?.add({key:"service:monitoring",level:"error",message:n("error.threshold_failed"),persistent:!1})})})}),c.appendChild(u),c.appendChild(h),c}_updateLiveState(){if(!this._config||this._config.panelMode)return;const t=this._config;if(!t.subDeviceMode&&!t.favoritesMode){if(t.entities?.switch){const e=this.shadowRoot?.querySelector('[data-role="relay-toggle"]');if(e){const n=this._hass?.states?.[t.entities.switch]?.state;"on"===n?e.setAttribute("checked",""):e.removeAttribute("checked")}}if(t.entities?.select){const e=this.shadowRoot?.querySelector('[data-role="shedding-select"]');if(e){const n=this._hass?.states?.[t.entities.select]?.state||"";e.value=n}}}}_callService(t,e,n){return this._hass?Promise.resolve(this._hass.callService(t,e,n)):Promise.resolve()}_callDomainService(t,e){return this._hass?this._hass.callWS({type:"call_service",domain:s,service:t,service_data:e}):Promise.resolve()}_debounce(t,e,n){this._debounceTimers[t]&&clearTimeout(this._debounceTimers[t]),this._debounceTimers[t]=setTimeout(()=>{delete this._debounceTimers[t],n()},e)}}try{customElements.get("span-side-panel")||customElements.define("span-side-panel",Qt)}catch{}class Jt extends Dt{constructor(){super(...arguments),this._store=null,this._unsub=null,this._errors=[]}set store(t){if(this._store===t)return;this._unsub?.(),this._unsub=null,this._store=t,this._errors=t.active;const e=t;this._unsub=t.subscribe(()=>{this._errors=e.active})}connectedCallback(){if(super.connectedCallback(),this._store&&!this._unsub){const t=this._store;this._errors=t.active,this._unsub=t.subscribe(()=>{this._errors=t.active})}}disconnectedCallback(){super.disconnectedCallback(),this._unsub?.(),this._unsub=null}render(){return 0===this._errors.length?pt:ut`${this._errors.map(t=>ut` + + `)}`}_iconForLevel(t){switch(t){case"error":return"mdi:alert-circle";case"warning":return"mdi:alert";default:return"mdi:information"}}}Jt.styles=M` + :host { + display: block; + } + .banner-row { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + font-size: 13px; + line-height: 1.4; + } + .banner-row + .banner-row { + border-top: 1px solid rgba(128, 128, 128, 0.2); + } + .banner-row.level-error { + background: color-mix(in srgb, var(--error-color, #db4437) 15%, transparent); + color: var(--error-color, #db4437); + } + .banner-row.level-warning { + background: color-mix(in srgb, var(--warning-color, #ff9800) 15%, transparent); + color: var(--warning-color, #ff9800); + } + .banner-row.level-info { + background: color-mix(in srgb, var(--info-color, #4285f4) 15%, transparent); + color: var(--info-color, #4285f4); + } + .icon { + flex-shrink: 0; + width: 18px; + height: 18px; + --mdc-icon-size: 18px; + } + .message { + flex: 1; + min-width: 0; + } + .retry-btn { + flex-shrink: 0; + background: none; + border: 1px solid currentColor; + border-radius: 4px; + color: inherit; + cursor: pointer; + font-size: 12px; + padding: 2px 8px; + } + .retry-btn:hover { + opacity: 0.8; + } + `,_([zt()],Jt.prototype,"_errors",void 0);try{customElements.get("span-error-banner")||customElements.define("span-error-banner",Jt)}catch{}var te="M3,6H21V8H3V6M3,11H21V13H3V11M3,16H21V18H3V16Z";const ee=Object.freeze({"mdi:alert":"M13 14H11V9H13M13 18H11V16H13M1 21H23L12 2L1 21Z","mdi:alert-circle":"M13,13H11V7H13M13,17H11V15H13M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z","mdi:battery":"M16.67,4H15V2H9V4H7.33A1.33,1.33 0 0,0 6,5.33V20.67C6,21.4 6.6,22 7.33,22H16.67A1.33,1.33 0 0,0 18,20.67V5.33C18,4.6 17.4,4 16.67,4Z","mdi:battery-alert-variant-outline":"M14 20H6V6H14M14.67 4H13V2H7V4H5.33C4.6 4 4 4.6 4 5.33V20.67C4 21.4 4.6 22 5.33 22H14.67C15.4 22 16 21.4 16 20.67V5.33C16 4.6 15.4 4 14.67 4M21 7H19V13H21V8M21 15H19V17H21V15Z","mdi:chevron-down":"M7.41,8.58L12,13.17L16.59,8.58L18,10L12,16L6,10L7.41,8.58Z","mdi:close":"M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z","mdi:cog":"M12,15.5A3.5,3.5 0 0,1 8.5,12A3.5,3.5 0 0,1 12,8.5A3.5,3.5 0 0,1 15.5,12A3.5,3.5 0 0,1 12,15.5M19.43,12.97C19.47,12.65 19.5,12.33 19.5,12C19.5,11.67 19.47,11.34 19.43,11L21.54,9.37C21.73,9.22 21.78,8.95 21.66,8.73L19.66,5.27C19.54,5.05 19.27,4.96 19.05,5.05L16.56,6.05C16.04,5.66 15.5,5.32 14.87,5.07L14.5,2.42C14.46,2.18 14.25,2 14,2H10C9.75,2 9.54,2.18 9.5,2.42L9.13,5.07C8.5,5.32 7.96,5.66 7.44,6.05L4.95,5.05C4.73,4.96 4.46,5.05 4.34,5.27L2.34,8.73C2.21,8.95 2.27,9.22 2.46,9.37L4.57,11C4.53,11.34 4.5,11.67 4.5,12C4.5,12.33 4.53,12.65 4.57,12.97L2.46,14.63C2.27,14.78 2.21,15.05 2.34,15.27L4.34,18.73C4.46,18.95 4.73,19.03 4.95,18.95L7.44,17.94C7.96,18.34 8.5,18.68 9.13,18.93L9.5,21.58C9.54,21.82 9.75,22 10,22H14C14.25,22 14.46,21.82 14.5,21.58L14.87,18.93C15.5,18.67 16.04,18.34 16.56,17.94L19.05,18.95C19.27,19.03 19.54,18.95 19.66,18.73L21.66,15.27C21.78,15.05 21.73,14.78 21.54,14.63L19.43,12.97Z","mdi:heart":"M12,21.35L10.55,20.03C5.4,15.36 2,12.27 2,8.5C2,5.41 4.42,3 7.5,3C9.24,3 10.91,3.81 12,5.08C13.09,3.81 14.76,3 16.5,3C19.58,3 22,5.41 22,8.5C22,12.27 18.6,15.36 13.45,20.03L12,21.35Z","mdi:heart-outline":"M12.1,18.55L12,18.65L11.89,18.55C7.14,14.24 4,11.39 4,8.5C4,6.5 5.5,5 7.5,5C9.04,5 10.54,6 11.07,7.36H12.93C13.46,6 14.96,5 16.5,5C18.5,5 20,6.5 20,8.5C20,11.39 16.86,14.24 12.1,18.55M16.5,3C14.76,3 13.09,3.81 12,5.08C10.91,3.81 9.24,3 7.5,3C4.42,3 2,5.41 2,8.5C2,12.27 5.4,15.36 10.55,20.03L12,21.35L13.45,20.03C18.6,15.36 22,12.27 22,8.5C22,5.41 19.58,3 16.5,3Z","mdi:help":"M10,19H13V22H10V19M12,2C17.35,2.22 19.68,7.62 16.5,11.67C15.67,12.67 14.33,13.33 13.67,14.17C13,15 13,16 13,17H10C10,15.33 10,13.92 10.67,12.92C11.33,11.92 12.67,11.33 13.5,10.67C15.92,8.43 15.32,5.26 12,5A3,3 0 0,0 9,8H6A6,6 0 0,1 12,2Z","mdi:help-circle-outline":"M11,18H13V16H11V18M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M12,20C7.59,20 4,16.41 4,12C4,7.59 7.59,4 12,4C16.41,4 20,7.59 20,12C20,16.41 16.41,20 12,20M12,6A4,4 0 0,0 8,10H10A2,2 0 0,1 12,8A2,2 0 0,1 14,10C14,12 11,11.75 11,15H13C13,12.75 16,12.5 16,10A4,4 0 0,0 12,6Z","mdi:home-group":"M17,16H15V22H12V17H8V22H5V16H3L10,10L17,16M6,2L10,6H9V9H7V6H5V9H3V6H2L6,2M18,3L23,8H22V12H19V9H17V12H15.34L14,10.87V8H13L18,3Z","mdi:information":"M13,9H11V7H13M13,17H11V11H13M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z","mdi:lock":"M12,17A2,2 0 0,0 14,15C14,13.89 13.1,13 12,13A2,2 0 0,0 10,15A2,2 0 0,0 12,17M18,8A2,2 0 0,1 20,10V20A2,2 0 0,1 18,22H6A2,2 0 0,1 4,20V10C4,8.89 4.9,8 6,8H7V6A5,5 0 0,1 12,1A5,5 0 0,1 17,6V8H18M12,3A3,3 0 0,0 9,6V8H15V6A3,3 0 0,0 12,3Z","mdi:lock-open":"M18,8A2,2 0 0,1 20,10V20A2,2 0 0,1 18,22H6C4.89,22 4,21.1 4,20V10A2,2 0 0,1 6,8H15V6A3,3 0 0,0 12,3A3,3 0 0,0 9,6H7A5,5 0 0,1 12,1A5,5 0 0,1 17,6V8H18M12,17A2,2 0 0,0 14,15A2,2 0 0,0 12,13A2,2 0 0,0 10,15A2,2 0 0,0 12,17Z","mdi:menu":te,"mdi:monitor-eye":"M3 4V16H21V4H3M3 2H21C22.1 2 23 2.89 23 4V16C23 16.53 22.79 17.04 22.41 17.41C22.04 17.79 21.53 18 21 18H14V20H16V22H8V20H10V18H3C2.47 18 1.96 17.79 1.59 17.41C1.21 17.04 1 16.53 1 16V4C1 2.89 1.89 2 3 2M10.84 8.93C11.15 8.63 11.57 8.45 12 8.45C12.43 8.46 12.85 8.63 13.16 8.94C13.46 9.24 13.64 9.66 13.64 10.09C13.64 10.53 13.46 10.94 13.16 11.25C12.85 11.56 12.43 11.73 12 11.73C11.57 11.73 11.15 11.55 10.84 11.25C10.54 10.94 10.36 10.53 10.36 10.09C10.36 9.66 10.54 9.24 10.84 8.93M10.07 12C10.58 12.53 11.28 12.82 12 12.82C12.72 12.82 13.42 12.53 13.93 12C14.44 11.5 14.73 10.81 14.73 10.09C14.73 9.37 14.44 8.67 13.93 8.16C13.42 7.65 12.72 7.36 12 7.36C11.28 7.36 10.58 7.65 10.07 8.16C9.56 8.67 9.27 9.37 9.27 10.09C9.27 10.81 9.56 11.5 10.07 12M6 10.09C6.94 7.7 9.27 6 12 6C14.73 6 17.06 7.7 18 10.09C17.06 12.5 14.73 14.18 12 14.18C9.27 14.18 6.94 12.5 6 10.09Z","mdi:router-wireless":"M20.2,5.9L21,5.1C19.6,3.7 17.8,3 16,3C14.2,3 12.4,3.7 11,5.1L11.8,5.9C13,4.8 14.5,4.2 16,4.2C17.5,4.2 19,4.8 20.2,5.9M19.3,6.7C18.4,5.8 17.2,5.3 16,5.3C14.8,5.3 13.6,5.8 12.7,6.7L13.5,7.5C14.2,6.8 15.1,6.5 16,6.5C16.9,6.5 17.8,6.8 18.5,7.5L19.3,6.7M19,13H17V9H15V13H5A2,2 0 0,0 3,15V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V15A2,2 0 0,0 19,13M8,18H6V16H8V18M11.5,18H9.5V16H11.5V18M15,18H13V16H15V18Z","mdi:sort-descending":"M19 7H22L18 3L14 7H17V21H19M2 17H12V19H2M6 5V7H2V5M2 11H9V13H2V11Z","mdi:transmission-tower":"M8.28,5.45L6.5,4.55L7.76,2H16.23L17.5,4.55L15.72,5.44L15,4H9L8.28,5.45M18.62,8H14.09L13.3,5H10.7L9.91,8H5.38L4.1,10.55L5.89,11.44L6.62,10H17.38L18.1,11.45L19.89,10.56L18.62,8M17.77,22H15.7L15.46,21.1L12,15.9L8.53,21.1L8.3,22H6.23L9.12,11H11.19L10.83,12.35L12,14.1L13.16,12.35L12.81,11H14.88L17.77,22M11.4,15L10.5,13.65L9.32,18.13L11.4,15M14.68,18.12L13.5,13.64L12.6,15L14.68,18.12Z","mdi:view-dashboard":"M13,3V9H21V3M13,21H21V11H13M3,21H11V15H3M3,13H11V3H3V13Z"}),ne=new Set;class ie extends Dt{constructor(){super(...arguments),this.icon=""}render(){if(!this.icon)return pt;const t=ee[this.icon];return t?ut``:(e=this.icon,ne.has(e)||(ne.add(e),console.warn(`SPAN: unknown icon "${e}". Add it to MDI_PATHS in span-icon.ts.`)),pt);var e}}ie.styles=M` + :host { + display: inline-flex; + align-items: center; + justify-content: center; + width: var(--mdc-icon-size, 24px); + height: var(--mdc-icon-size, 24px); + vertical-align: middle; + color: inherit; + flex-shrink: 0; + } + svg { + width: 100%; + height: 100%; + display: block; + fill: currentColor; + } + `,_([Et({type:String})],ie.prototype,"icon",void 0);try{customElements.get("span-icon")||customElements.define("span-icon",ie)}catch{}class re extends Dt{constructor(){super(...arguments),this.checked=!1,this.disabled=!1,this._onActivate=t=>{if(this.disabled)return t.preventDefault(),void t.stopPropagation();this.checked=!this.checked,this.dispatchEvent(new Event("change",{bubbles:!0,composed:!0}))},this._onKeydown=t=>{" "!==t.key&&"Enter"!==t.key||(t.preventDefault(),this._onActivate(t))}}connectedCallback(){super.connectedCallback(),this.hasAttribute("tabindex")||this.setAttribute("tabindex","0"),this.hasAttribute("role")||this.setAttribute("role","switch"),this.setAttribute("aria-checked",String(this.checked)),this.addEventListener("click",this._onActivate),this.addEventListener("keydown",this._onKeydown)}disconnectedCallback(){this.removeEventListener("click",this._onActivate),this.removeEventListener("keydown",this._onKeydown),super.disconnectedCallback()}updated(t){t.has("checked")&&this.setAttribute("aria-checked",String(this.checked)),t.has("disabled")&&this.setAttribute("aria-disabled",String(this.disabled))}render(){return ut` +
+
+
+ `}}re.styles=M` + :host { + --span-switch-on: var(--switch-checked-color, var(--primary-color, #4dd9af)); + --span-switch-off: var(--switch-unchecked-track-color, rgba(120, 120, 120, 0.45)); + --span-switch-thumb-on: var(--switch-checked-button-color, #fff); + --span-switch-thumb-off: var(--switch-unchecked-button-color, #fafafa); + --span-switch-track-w: 36px; + --span-switch-track-h: 20px; + --span-switch-thumb: 14px; + display: inline-flex; + align-items: center; + vertical-align: middle; + cursor: pointer; + user-select: none; + -webkit-tap-highlight-color: transparent; + } + :host([disabled]) { + cursor: not-allowed; + opacity: 0.5; + } + .track { + position: relative; + width: var(--span-switch-track-w); + height: var(--span-switch-track-h); + border-radius: calc(var(--span-switch-track-h) / 2); + background: var(--span-switch-off); + transition: background 0.2s ease; + } + .thumb { + position: absolute; + top: 50%; + left: 3px; + width: var(--span-switch-thumb); + height: var(--span-switch-thumb); + border-radius: 50%; + background: var(--span-switch-thumb-off); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.35); + transform: translateY(-50%); + transition: + left 0.2s ease, + background 0.2s ease; + } + :host([checked]) .track { + background: var(--span-switch-on); + } + :host([checked]) .thumb { + left: calc(var(--span-switch-track-w) - var(--span-switch-thumb) - 3px); + background: var(--span-switch-thumb-on); + } + :host(:focus-visible) .track { + outline: 2px solid var(--span-switch-on); + outline-offset: 2px; + } + `,_([Et({type:Boolean,reflect:!0})],re.prototype,"checked",void 0),_([Et({type:Boolean,reflect:!0})],re.prototype,"disabled",void 0);try{customElements.get("span-switch")||customElements.define("span-switch",re)}catch{}class oe extends Dt{constructor(){super(...arguments),this.narrow=!1,this._toggle=()=>{this.dispatchEvent(new CustomEvent("hass-toggle-menu",{bubbles:!0,composed:!0}))}}render(){return ut` + + `}}oe.styles=M` + :host { + display: none; + align-items: center; + justify-content: center; + } + :host([narrow]) { + display: inline-flex; + } + button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + padding: 0; + background: transparent; + border: none; + border-radius: 50%; + color: inherit; + cursor: pointer; + transition: background 0.15s ease; + -webkit-tap-highlight-color: transparent; + } + button:hover, + button:focus-visible { + background: color-mix(in srgb, currentColor 12%, transparent); + outline: none; + } + svg { + width: 24px; + height: 24px; + fill: currentColor; + } + `,_([Et({type:Boolean,reflect:!0})],oe.prototype,"narrow",void 0);try{customElements.get("span-menu-button")||customElements.define("span-menu-button",oe)}catch{}async function ae(t,e){const[n,i,r]=await Promise.all([t.callWS({type:"config/area_registry/list"}),t.callWS({type:"config/entity_registry/list"}),t.callWS({type:"config/device_registry/list"})]),o=new Map;for(const t of n)o.set(t.area_id,t.name);const a=new Map;for(const t of i)t.area_id&&a.set(t.entity_id,t.area_id);const s=new Map;for(const t of r)s.set(t.id,t.area_id);let l;if(e.device_id){const t=s.get(e.device_id);t&&(l=o.get(t))}for(const t of Object.values(e.circuits)){let e;for(const n of Object.values(t.entities)){if(!n)continue;const t=a.get(n);if(t){e=o.get(t);break}}e||(e=l),t.area=e}}async function se(t,e,i){if(!e)throw new Error(n("card.device_not_found"));const r={type:`${s}/panel_topology`,device_id:e},o=i?await i.callWS(t,r,{errorId:"fetch:topology"}):await t.callWS(r),a=o.panel_size??function(t){let e=0;for(const n of Object.values(t))if(n)for(const t of n.tabs)t>e&&(e=t);return e>0?e+e%2:0}(o.circuits);if(!a)throw new Error(n("card.topology_error"));const l={type:"config/device_registry/list"},c=i?await i.callWS(t,l,{errorId:"fetch:topology"}):await t.callWS(l),u=(h=c.find(t=>t.id===e),h?{id:h.id,name:h.name,name_by_user:h.name_by_user,config_entries:h.config_entries,identifiers:h.identifiers,via_device_id:h.via_device_id,sw_version:h.sw_version,model:h.model}:null);var h;return await ae(t,o),{topology:o,panelDevice:u,panelSize:a}}function le(){return`
\n ${Object.entries(v).filter(([t])=>"unknown"!==t).map(([,t])=>{const e=Bt(t.icon),n=Bt(t.color),i=Bt(t.label());let r;if(t.icon2){r=``}else if(t.textLabel){r=`${Bt(t.textLabel)}`}else r=``;return`
${r}${i}
`}).join("")}\n
`}function ce(t,e,i){const r="current"===(e.chart_metric||"power"),o=!!t.panel_entities?.site_power,a=!!t.panel_entities?.dsm_state,s=!!t.panel_entities?.current_power,l=!!t.panel_entities?.feedthrough_power,c=!!t.panel_entities?.pv_power,u=!!t.panel_entities?.battery_level;return`\n
\n ${o?`\n
\n ${n("header.site")}\n
\n 0\n ${r?"A":"kW"}\n
\n
`:""}\n ${a?`\n
\n ${n("header.grid")}\n
\n --\n
\n
`:""}\n ${s?`\n
\n ${n("header.upstream")}\n
\n --\n ${r?"A":"kW"}\n
\n
`:""}\n ${l?`\n
\n ${n("header.downstream")}\n
\n --\n ${r?"A":"kW"}\n
\n
`:""}\n ${c?`\n
\n ${n("header.solar")}\n
\n --\n ${r?"A":"kW"}\n
\n
`:""}\n ${u?`\n
\n ${n("header.battery")}\n
\n \n %\n
\n
`:""}\n
\n `}function ue(t,e,i={}){const r=Bt(t.device_name||n("header.default_name")),o=Bt(t.serial||""),a=Bt(t.firmware||""),s="current"===(e.chart_metric||"power"),l=!1!==i.showSwitches;return`\n
\n
\n
\n

${r}

\n ${o}\n \n ${l?`
\n ${Bt(n("header.enable_switches"))}\n
\n \n
\n
`:""}\n
\n ${ce(t,e)}\n
\n
\n
\n ${a}\n
\n \n \n
\n
\n ${le()}\n
\n
\n `}const he=f.power;function de(t){return he.unit(t)}function pe(t){return(t<0?"-":"")+he.format(t)}function fe(t){return(Math.abs(t)/1e3).toFixed(1)}function ge(t){return Math.ceil(t/2)}function ve(t){return t%2==0?1:0}function me(t){if(2!==t.length)return null;const[e,n]=[Math.min(...t),Math.max(...t)];return ge(e)===ge(n)?"row-span":ve(e)===ve(n)?"col-span":"row-span"}function ye(t){const e=t.chart_metric??r;return f[e]??f[r]}function _e(t,e){const n=function(t){return ye(t).entityRole}(e);return t.entities?.[n]??t.entities?.power??null}class be{constructor(){this._status=null,this._lastFetch=0,this._inflight=null,this._generation=0,this._errorStore=null,this._retry=null}get errorStore(){return this._errorStore}set errorStore(t){this._errorStore=t,this._retry=t?new qt(t):null}async fetch(t,e){const i=Date.now();if(this._inflight&&this._inflight.gen===this._generation)return this._inflight.promise;if(this._status&&i-this._lastFetch<3e4)return this._status;const r=this._generation,o=(async()=>{try{const i={};e&&(i.config_entry_id=e);const o={type:"call_service",domain:s,service:"get_monitoring_status",service_data:i,return_response:!0},a=this._retry?await this._retry.callWS(t,o,{errorId:"fetch:monitoring",errorMessage:n("error.monitoring_failed")}):await t.callWS(o),l=a?.response??null;return r===this._generation&&(this._status=l,this._lastFetch=Date.now()),l}catch(t){return console.warn("SPAN Panel: monitoring status fetch failed",t),r===this._generation&&(this._status=null),this._retry||this._errorStore?.add({key:"fetch:monitoring",level:"warning",message:n("error.monitoring_failed"),persistent:!1}),null}finally{this._inflight?.gen===r&&(this._inflight=null)}})();return this._inflight={gen:r,promise:o},o}invalidate(){this._lastFetch=0,this._generation++}get status(){return this._status}clear(){this._status=null,this._lastFetch=0,this._generation++}}class xe{constructor(){this._caches=new Map,this._errorStore=null}get errorStore(){return this._errorStore}set errorStore(t){this._errorStore=t;for(const e of this._caches.values())e.errorStore=t}async fetchOne(t,e){let n=this._caches.get(e);return n||(n=new be,n.errorStore=this._errorStore,this._caches.set(e,n)),n.fetch(t,e)}invalidate(){for(const t of this._caches.values())t.invalidate()}clear(){this._caches.clear()}}function we(t,e){return t?.circuits?t.circuits[e]??null:null}function Se(t,e,n,i){const r=[];return n||r.push("circuit-off"),i&&r.push("circuit-producer"),function(t){return!!t&&null!=t.over_threshold_since}(e)&&r.push("circuit-alert"),r.join(" ")}function Ce(t,e,i,r,o,a,s,u,h,d=!1){const p=e.entities?.power,f=p?a.states[p]:null,g=f&&parseFloat(f.state)||0,m=e.device_type===c||g<0,y=e.entities?.switch,_=y?a.states[y]:null,b=_?"on"===_.state:(f?.attributes?.relay_state||e.relay_state)===l,x=e.breaker_rating_a,w=x?`${Math.round(x)}A`:"",S=Bt(e.name||n("grid.unknown")),C=ye(s);let k;if("current"===C.entityRole){const t=e.entities?.current,n=t?a.states[t]:null,i=n&&parseFloat(n.state)||0;k=`${C.format(i)}A`}else k=`${pe(g)}${de(g)}`;const M=h||"unknown";let T="";if("unknown"!==M){const t=v[M]??v.unknown??{icon:"mdi:help",color:"#999",label:()=>"Unknown"},e=Bt(t.label()),n=Bt(t.icon),i=Bt(t.color);if(t.icon2){T=`\n \n \n `}else if(t.textLabel){T=`\n \n ${Bt(t.textLabel)}\n `}else T=``}const I=``;let D="",A=u?.utilization_pct??null;if(null==A&&e.breaker_rating_a){const t=e.entities?.current,n=t?a.states[t]:null,i=n?Math.abs(parseFloat(n.state)||0):0;A=Math.round(i/e.breaker_rating_a*1e3)/10}if(null!=A){D=`=80?"utilization-warning":"utilization-normal"}">${Math.round(A)}%`}return`\n
\n
\n
\n ${w?`${w}`:""}\n ${D}\n ${S}\n
\n
\n \n ${k}\n \n ${!1!==e.is_user_controllable&&e.entities?.switch?`\n
\n ${n(b?"grid.on":"grid.off")}\n \n
\n `:""}\n
\n
\n
\n ${T}\n ${I}\n
\n
\n
\n `}function ke(t,e){return`\n
\n \n
\n `}const Me={names:["power","battery power"],suffixes:["_power"]},Te={names:["battery level","battery percentage"],suffixes:["_battery_level","_battery_percentage"]},Ie={names:["state of energy"],suffixes:["_soe_kwh"]},De={names:["nameplate capacity"],suffixes:["_nameplate_capacity"]};function Ae(t,e){if(!t.entities)return null;for(const[n,i]of Object.entries(t.entities)){if("sensor"!==i.domain)continue;const t=(i.original_name??"").toLowerCase();if(e.names.some(e=>t===e))return n;if(i.unique_id&&e.suffixes.some(t=>i.unique_id.endsWith(t)))return n}return null}function Pe(t){return Ae(t,Me)}function Le(t){return Ae(t,Te)}function Ee(t){return Ae(t,Ie)}function ze(t){return Ae(t,De)}function Ne(t,e,i){const r=!1!==i.show_battery,o=!1!==i.show_evse;if(!t.sub_devices)return"";const a=Object.entries(t.sub_devices).filter(([,t])=>!(t.type===u&&!r)&&!(t.type===h&&!o));if(0===a.length)return"";const s=a.filter(([,t])=>t.type===h).length;let l=0,c="";for(const[t,r]of a){const o=r.type===h?n("subdevice.ev_charger"):r.type===u?n("subdevice.battery"):n("subdevice.fallback"),a=Pe(r),d=a?e.states[a]:void 0,p=d&&parseFloat(d.state)||0,f=r.type===u,g=r.type===h,v=f?Le(r):null,m=f?Ee(r):null,y=f?ze(r):null,_=Oe(r,e,i,new Set([a,v,m,y].filter(t=>null!==t))),b=Re(t,r,f,a,v,m);let x="";f?x="sub-device-bess":g&&(l++,l===s&&s%2==1&&(x="sub-device-full")),c+=`\n
\n
\n ${Bt(o)}\n ${Bt(r.name||"")}\n ${a?`${pe(p)} ${de(p)}`:""}\n \n
\n ${b}\n ${_}\n
\n `}return c}function Oe(t,e,n,i){const r=n.visible_sub_entities||{};let o="";if(!t.entities)return o;for(const[n,a]of Object.entries(t.entities)){if(i.has(n))continue;if(!0!==r[n])continue;const s=e.states[n];if(!s)continue;let l=a.original_name||s.attributes.friendly_name||n;const c=t.name||"";let u;if(l.startsWith(c+" ")&&(l=l.slice(c.length+1)),e.formatEntityState)u=e.formatEntityState(s);else{u=s.state;const t=s.attributes.unit_of_measurement||"";t&&(u+=" "+t)}if("Wh"===(s.attributes.unit_of_measurement||"")){const t=parseFloat(s.state);isNaN(t)||(u=(t/1e3).toFixed(1)+" kWh")}o+=`\n
\n ${Bt(l)}:\n ${Bt(u)}\n
\n `}return o}function Re(t,e,i,r,o,a){if(i){const e=[{key:`${d}${t}_soc`,title:n("subdevice.soc"),available:!!o},{key:`${d}${t}_soe`,title:n("subdevice.soe"),available:!!a},{key:`${d}${t}_power`,title:n("subdevice.power"),available:!!r}].filter(t=>t.available);return`\n
\n ${e.map(t=>`\n
\n
${Bt(t.title)}
\n
\n
\n `).join("")}\n
\n `}return r?`
`:""}function $e(t){const e=void 0!==t.history_days||void 0!==t.history_hours||void 0!==t.history_minutes,n=60*(60*(24*(e&&parseInt(String(t.history_days))||0)+(e&&parseInt(String(t.history_hours))||0))+(e?parseInt(String(t.history_minutes))||0:5))*1e3;return Math.max(n,6e4)}function He(t){const e=a[t];return e?e.ms:a[o].ms}function Fe(t){const e=t/1e3;return e<=600?Math.ceil(e):Math.min(5e3,Math.ceil(e/5))}function Be(t){return Math.max(500,Math.floor(t/5e3))}function Ve(t,e,n,i,r,o){t.has(e)||t.set(e,[]);const a=t.get(e);a.push({time:i,value:n});const s=a.findIndex(t=>t.time>=r);s>0?a.splice(0,s):-1===s&&(a.length=0),a.length>o&&a.splice(0,a.length-o)}function We(t,e,n=500){if(0===t.length)return t;t.sort((t,e)=>t.time-e.time);const i=[t[0]];for(let e=1;e=n&&i.push(t[e]);return i.length>e&&i.splice(0,i.length-e),i}async function Ue(t,e,n,i,r){const o=new Date(Date.now()-i).toISOString(),a=i/36e5>72?"hour":"5minute",s=await t.callWS({type:"recorder/statistics_during_period",start_time:o,statistic_ids:e,period:a,types:["mean"]});for(const[t,e]of Object.entries(s)){const i=n.get(t);if(!i||!e)continue;const o=[];for(const t of e){const e=t.mean;if(null==e||!Number.isFinite(e))continue;const n=t.start;n>0&&o.push({time:n,value:e})}if(o.length>0){const t=r.get(i)||[],e=[...o,...t];e.sort((t,e)=>t.time-e.time),r.set(i,e)}}}async function Ge(t,e,n,i,r){const o=new Date(Date.now()-i).toISOString(),a=await t.callWS({type:"history/history_during_period",start_time:o,entity_ids:e,minimal_response:!0,significant_changes_only:!0,no_attributes:!0}),s=Fe(i),l=Be(i);for(const[t,e]of Object.entries(a)){const i=n.get(t);if(!i||!e)continue;const o=[];for(const t of e){const e=parseFloat(t.s);if(!Number.isFinite(e))continue;const n=1e3*(t.lu||t.lc||0);n>0&&o.push({time:n,value:e})}if(o.length>0){const t=r.get(i)||[],e=[...o,...t];r.set(i,We(e,s,l))}}}function qe(t){if(!t.sub_devices)return[];const e=[];for(const[n,i]of Object.entries(t.sub_devices)){const t={power:Pe(i)};i.type===u&&(t.soc=Le(i),t.soe=Ee(i));for(const[i,r]of Object.entries(t))r&&e.push({entityId:r,key:`${d}${n}_${i}`,devId:n})}return e}async function je(t,e,n,i,r,o){if(!e||!t)return;const a=new Map;for(const[t,i]of Object.entries(e.circuits)){const e=_e(i,n);if(!e)continue;let o;o=r&&r.has(t)?He(r.get(t)):$e(n),a.has(o)||a.set(o,{entityIds:[],uuidByEntity:new Map});const s=a.get(o);s.entityIds.push(e),s.uuidByEntity.set(e,t)}for(const{entityId:t,key:i,devId:r}of qe(e)){let e;e=o&&o.has(r)?He(o.get(r)):$e(n),a.has(e)||a.set(e,{entityIds:[],uuidByEntity:new Map});const s=a.get(e);s.entityIds.push(t),s.uuidByEntity.set(t,i)}const s=[];for(const[e,n]of a){if(0===n.entityIds.length)continue;e>2592e5?s.push(Ue(t,n.entityIds,n.uuidByEntity,e,i)):s.push(Ge(t,n.entityIds,n.uuidByEntity,e,i))}await Promise.all(s)}var Xe=function(){this.firefox=!1,this.ie=!1,this.edge=!1,this.newEdge=!1,this.weChat=!1},Ye=new function(){this.browser=new Xe,this.node=!1,this.wxa=!1,this.worker=!1,this.svgSupported=!1,this.touchEventsSupported=!1,this.pointerEventsSupported=!1,this.domSupported=!1,this.transformSupported=!1,this.transform3dSupported=!1,this.hasGlobalWindow="undefined"!=typeof window};"object"==typeof wx&&"function"==typeof wx.getSystemInfoSync?(Ye.wxa=!0,Ye.touchEventsSupported=!0):"undefined"==typeof document&&"undefined"!=typeof self?Ye.worker=!0:!Ye.hasGlobalWindow||"Deno"in window||"undefined"!=typeof navigator&&"string"==typeof navigator.userAgent&&navigator.userAgent.indexOf("Node.js")>-1?(Ye.node=!0,Ye.svgSupported=!0):function(t,e){var n=e.browser,i=t.match(/Firefox\/([\d.]+)/),r=t.match(/MSIE\s([\d.]+)/)||t.match(/Trident\/.+?rv:(([\d.]+))/),o=t.match(/Edge?\/([\d.]+)/),a=/micromessenger/i.test(t);i&&(n.firefox=!0,n.version=i[1]);r&&(n.ie=!0,n.version=r[1]);o&&(n.edge=!0,n.version=o[1],n.newEdge=+o[1].split(".")[0]>18);a&&(n.weChat=!0);e.svgSupported="undefined"!=typeof SVGRect,e.touchEventsSupported="ontouchstart"in window&&!n.ie&&!n.edge,e.pointerEventsSupported="onpointerdown"in window&&(n.edge||n.ie&&+n.version>=11);var s=e.domSupported="undefined"!=typeof document;if(s){var l=document.documentElement.style;e.transform3dSupported=(n.ie&&"transition"in l||n.edge||"WebKitCSSMatrix"in window&&"m11"in new WebKitCSSMatrix||"MozPerspective"in l)&&!("OTransition"in l),e.transformSupported=e.transform3dSupported||n.ie&&+n.version>=9}}(navigator.userAgent,Ye);var Ze="sans-serif",Ke="12px "+Ze;var Qe,Je,tn=function(t){var e={};if("undefined"==typeof JSON)return e;for(var n=0;n=0)o=r*t.length;else for(var a=0;a>1)%2;a.style.cssText=["position: absolute","visibility: hidden","padding: 0","margin: 0","border-width: 0","user-select: none","width:0","height:0",i[s]+":0",r[l]+":0",i[1-s]+":auto",r[1-l]+":auto",""].join("!important;"),t.appendChild(a),n.push(a)}return e.clearMarkers=function(){Cn(n,function(t){t.parentNode&&t.parentNode.removeChild(t)})},n}(e,o),s=function(t,e,n){for(var i=n?"invTrans":"trans",r=e[i],o=e.srcCoords,a=[],s=[],l=!0,c=0;c<4;c++){var u=t[c].getBoundingClientRect(),h=2*c,d=u.left,p=u.top;a.push(d,p),l=l&&o&&d===o[h]&&p===o[h+1],s.push(t[c].offsetLeft,t[c].offsetTop)}return l&&r?r:(e.srcCoords=a,e[i]=n?Si(s,a):Si(a,s))}(a,o,r);if(s)return s(t,n,i),!0}return!1}function Ti(t){return"CANVAS"===t.nodeName.toUpperCase()}var Ii=/([&<>"'])/g,Di={"&":"&","<":"<",">":">",'"':""","'":"'"};function Ai(t){return null==t?"":(t+"").replace(Ii,function(t,e){return Di[e]})}var Pi=/^(?:mouse|pointer|contextmenu|drag|drop)|click/,Li=[],Ei=Ye.browser.firefox&&+Ye.browser.version.split(".")[0]<39;function zi(t,e,n,i){return n=n||{},i?Ni(t,e,n):Ei&&null!=e.layerX&&e.layerX!==e.offsetX?(n.zrX=e.layerX,n.zrY=e.layerY):null!=e.offsetX?(n.zrX=e.offsetX,n.zrY=e.offsetY):Ni(t,e,n),n}function Ni(t,e,n){if(Ye.domSupported&&t.getBoundingClientRect){var i=e.clientX,r=e.clientY;if(Ti(t)){var o=t.getBoundingClientRect();return n.zrX=i-o.left,void(n.zrY=r-o.top)}if(Mi(Li,t,i,r))return n.zrX=Li[0],void(n.zrY=Li[1])}n.zrX=n.zrY=0}function Oi(t){return t||window.event}function Ri(t,e,n){if(null!=(e=Oi(e)).zrX)return e;var i=e.type;if(i&&i.indexOf("touch")>=0){var r="touchend"!==i?e.targetTouches[0]:e.changedTouches[0];r&&zi(t,r,e,n)}else{zi(t,e,e,n);var o=function(t){var e=t.wheelDelta;if(e)return e;var n=t.deltaX,i=t.deltaY;if(null==n||null==i)return e;return 3*(0!==i?Math.abs(i):Math.abs(n))*(i>0?-1:i<0?1:n>0?-1:1)}(e);e.zrDelta=o?o/120:-(e.detail||0)/3}var a=e.button;return null==e.which&&void 0!==a&&Pi.test(e.type)&&(e.which=1&a?1:2&a?3:4&a?2:0),e}function $i(t,e,n,i){t.removeEventListener(e,n,i)}var Hi=function(t){t.preventDefault(),t.stopPropagation(),t.cancelBubble=!0},Fi=function(){function t(){this._track=[]}return t.prototype.recognize=function(t,e,n){return this._doTrack(t,e,n),this._recognize(t)},t.prototype.clear=function(){return this._track.length=0,this},t.prototype._doTrack=function(t,e,n){var i=t.touches;if(i){for(var r={points:[],touches:[],target:e,event:t},o=0,a=i.length;o1&&r&&r.length>1){var a=Bi(r)/Bi(o);!isFinite(a)&&(a=1),e.pinchScale=a;var s=[((i=r)[0][0]+i[1][0])/2,(i[0][1]+i[1][1])/2];return e.pinchX=s[0],e.pinchY=s[1],{type:"pinch",target:t[0].target,event:e}}}}};function Wi(t){return t[0]=1,t[1]=0,t[2]=0,t[3]=1,t[4]=0,t[5]=0,t}function Ui(t,e){return t[0]=e[0],t[1]=e[1],t[2]=e[2],t[3]=e[3],t[4]=e[4],t[5]=e[5],t}function Gi(t,e,n){var i=e[0]*n[0]+e[2]*n[1],r=e[1]*n[0]+e[3]*n[1],o=e[0]*n[2]+e[2]*n[3],a=e[1]*n[2]+e[3]*n[3],s=e[0]*n[4]+e[2]*n[5]+e[4],l=e[1]*n[4]+e[3]*n[5]+e[5];return t[0]=i,t[1]=r,t[2]=o,t[3]=a,t[4]=s,t[5]=l,t}function qi(t,e,n){return t[0]=e[0],t[1]=e[1],t[2]=e[2],t[3]=e[3],t[4]=e[4]+n[0],t[5]=e[5]+n[1],t}function ji(t,e,n,i){void 0===i&&(i=[0,0]);var r=e[0],o=e[2],a=e[4],s=e[1],l=e[3],c=e[5],u=Math.sin(n),h=Math.cos(n);return t[0]=r*h+s*u,t[1]=-r*u+s*h,t[2]=o*h+l*u,t[3]=-o*u+h*l,t[4]=h*(a-i[0])+u*(c-i[1])+i[0],t[5]=h*(c-i[1])-u*(a-i[0])+i[1],t}function Xi(t,e){var n=e[0],i=e[2],r=e[4],o=e[1],a=e[3],s=e[5],l=n*a-o*i;return l?(l=1/l,t[0]=a*l,t[1]=-o*l,t[2]=-i*l,t[3]=n*l,t[4]=(i*s-a*r)*l,t[5]=(o*r-n*s)*l,t):null}var Yi=function(){function t(t,e){this.x=t||0,this.y=e||0}return t.prototype.copy=function(t){return this.x=t.x,this.y=t.y,this},t.prototype.clone=function(){return new t(this.x,this.y)},t.prototype.set=function(t,e){return this.x=t,this.y=e,this},t.prototype.equal=function(t){return t.x===this.x&&t.y===this.y},t.prototype.add=function(t){return this.x+=t.x,this.y+=t.y,this},t.prototype.scale=function(t){this.x*=t,this.y*=t},t.prototype.scaleAndAdd=function(t,e){this.x+=t.x*e,this.y+=t.y*e},t.prototype.sub=function(t){return this.x-=t.x,this.y-=t.y,this},t.prototype.dot=function(t){return this.x*t.x+this.y*t.y},t.prototype.len=function(){return Math.sqrt(this.x*this.x+this.y*this.y)},t.prototype.lenSquare=function(){return this.x*this.x+this.y*this.y},t.prototype.normalize=function(){var t=this.len();return this.x/=t,this.y/=t,this},t.prototype.distance=function(t){var e=this.x-t.x,n=this.y-t.y;return Math.sqrt(e*e+n*n)},t.prototype.distanceSquare=function(t){var e=this.x-t.x,n=this.y-t.y;return e*e+n*n},t.prototype.negate=function(){return this.x=-this.x,this.y=-this.y,this},t.prototype.transform=function(t){if(t){var e=this.x,n=this.y;return this.x=t[0]*e+t[2]*n+t[4],this.y=t[1]*e+t[3]*n+t[5],this}},t.prototype.toArray=function(t){return t[0]=this.x,t[1]=this.y,t},t.prototype.fromArray=function(t){this.x=t[0],this.y=t[1]},t.set=function(t,e,n){t.x=e,t.y=n},t.copy=function(t,e){t.x=e.x,t.y=e.y},t.len=function(t){return Math.sqrt(t.x*t.x+t.y*t.y)},t.lenSquare=function(t){return t.x*t.x+t.y*t.y},t.dot=function(t,e){return t.x*e.x+t.y*e.y},t.add=function(t,e,n){t.x=e.x+n.x,t.y=e.y+n.y},t.sub=function(t,e,n){t.x=e.x-n.x,t.y=e.y-n.y},t.scale=function(t,e,n){t.x=e.x*n,t.y=e.y*n},t.scaleAndAdd=function(t,e,n,i){t.x=e.x+n.x*i,t.y=e.y+n.y*i},t.lerp=function(t,e,n,i){var r=1-i;t.x=r*e.x+i*n.x,t.y=r*e.y+i*n.y},t}(),Zi=Math.min,Ki=Math.max,Qi=Math.abs,Ji=["x","y"],tr=["width","height"],er=new Yi,nr=new Yi,ir=new Yi,rr=new Yi,or=pr(),ar=or.minTv,sr=or.maxTv,lr=[0,0],cr=function(){function t(e,n,i,r){t.set(this,e,n,i,r)}return t.set=function(t,e,n,i,r){return i<0&&(e+=i,i=-i),r<0&&(n+=r,r=-r),t.x=e,t.y=n,t.width=i,t.height=r,t},t.prototype.union=function(t){var e=Zi(t.x,this.x),n=Zi(t.y,this.y);isFinite(this.x)&&isFinite(this.width)?this.width=Ki(t.x+t.width,this.x+this.width)-e:this.width=t.width,isFinite(this.y)&&isFinite(this.height)?this.height=Ki(t.y+t.height,this.y+this.height)-n:this.height=t.height,this.x=e,this.y=n},t.prototype.applyTransform=function(e){t.applyTransform(this,this,e)},t.prototype.calculateTransform=function(t){var e=this,n=t.width/e.width,i=t.height/e.height,r=[1,0,0,1,0,0];return qi(r,r,[-e.x,-e.y]),function(t,e,n){var i=n[0],r=n[1];t[0]=e[0]*i,t[1]=e[1]*r,t[2]=e[2]*i,t[3]=e[3]*r,t[4]=e[4]*i,t[5]=e[5]*r}(r,r,[n,i]),qi(r,r,[t.x,t.y]),r},t.prototype.intersect=function(e,n,i){return t.intersect(this,e,n,i)},t.intersect=function(e,n,i,r){i&&Yi.set(i,0,0);var o=r&&r.outIntersectRect||null,a=r&&r.clamp;if(o&&(o.x=o.y=o.width=o.height=NaN),!e||!n)return!1;e instanceof t||(e=t.set(ur,e.x,e.y,e.width,e.height)),n instanceof t||(n=t.set(hr,n.x,n.y,n.width,n.height));var s=!!i;or.reset(r,s);var l=or.touchThreshold,c=e.x+l,u=e.x+e.width-l,h=e.y+l,d=e.y+e.height-l,p=n.x+l,f=n.x+n.width-l,g=n.y+l,v=n.y+n.height-l;if(c>u||h>d||p>f||g>v)return!1;var m=!(u=t.x&&e<=t.x+t.width&&n>=t.y&&n<=t.y+t.height},t.prototype.contain=function(e,n){return t.contain(this,e,n)},t.prototype.clone=function(){return new t(this.x,this.y,this.width,this.height)},t.prototype.copy=function(e){t.copy(this,e)},t.prototype.plain=function(){return{x:this.x,y:this.y,width:this.width,height:this.height}},t.prototype.isFinite=function(){return isFinite(this.x)&&isFinite(this.y)&&isFinite(this.width)&&isFinite(this.height)},t.prototype.isZero=function(){return 0===this.width||0===this.height},t.create=function(e){return new t(e.x,e.y,e.width,e.height)},t.copy=function(t,e){return t.x=e.x,t.y=e.y,t.width=e.width,t.height=e.height,t},t.applyTransform=function(e,n,i){if(i){if(i[1]<1e-5&&i[1]>-1e-5&&i[2]<1e-5&&i[2]>-1e-5){var r=i[0],o=i[3],a=i[4],s=i[5];return e.x=n.x*r+a,e.y=n.y*o+s,e.width=n.width*r,e.height=n.height*o,e.width<0&&(e.x+=e.width,e.width=-e.width),void(e.height<0&&(e.y+=e.height,e.height=-e.height))}er.x=ir.x=n.x,er.y=rr.y=n.y,nr.x=rr.x=n.x+n.width,nr.y=ir.y=n.y+n.height,er.transform(i),rr.transform(i),nr.transform(i),ir.transform(i),e.x=Zi(er.x,nr.x,ir.x,rr.x),e.y=Zi(er.y,nr.y,ir.y,rr.y);var l=Ki(er.x,nr.x,ir.x,rr.x),c=Ki(er.y,nr.y,ir.y,rr.y);e.width=l-e.x,e.height=c-e.y}else e!==n&&t.copy(e,n)},t}(),ur=new cr(0,0,0,0),hr=new cr(0,0,0,0);function dr(t,e,n,i,r,o,a,s){var l=Qi(e-n),c=Qi(i-t),u=Zi(l,c),h=Ji[r],d=Ji[1-r],p=tr[r];e=c||!or.bidirectional)&&(ar[h]=-c,ar[d]=0,or.useDir&&or.calcDirMTV())))}function pr(){var t=0,e=new Yi,n=new Yi,i={minTv:new Yi,maxTv:new Yi,useDir:!1,dirMinTv:new Yi,touchThreshold:0,bidirectional:!0,negativeSize:!1,reset:function(r,o){i.touchThreshold=0,r&&null!=r.touchThreshold&&(i.touchThreshold=Ki(0,r.touchThreshold)),i.negativeSize=!1,o&&(i.minTv.set(1/0,1/0),i.maxTv.set(0,0),i.useDir=!1,r&&null!=r.direction&&(i.useDir=!0,i.dirMinTv.copy(i.minTv),n.copy(i.minTv),t=r.direction,i.bidirectional=null==r.bidirectional||!!r.bidirectional,i.bidirectional||e.set(Math.cos(t),Math.sin(t))))},calcDirMTV:function(){var o=i.minTv,a=i.dirMinTv,s=o.y*o.y+o.x*o.x,l=Math.sin(t),c=Math.cos(t),u=l*o.y+c*o.x;r(u)?r(o.x)&&r(o.y)&&a.set(0,0):(n.x=s*c/u,n.y=s*l/u,r(n.x)&&r(n.y)?a.set(0,0):(i.bidirectional||e.dot(n)>0)&&n.len()=0;c--){var u=i[c];u===n||u.ignore||u.ignoreCoarsePointer||u.parent&&u.parent.ignoreCoarsePointer||(_r.copy(u.getBoundingRect()),u.transform&&_r.applyTransform(u.transform),_r.intersect(l)&&o.push(u))}if(o.length)for(var h=Math.PI/12,d=2*Math.PI,p=0;p=0;o--){var a=t[o],s=void 0;if(a!==r&&!a.ignore&&(s=xr(a,n,i))&&(!e.topTarget&&(e.topTarget=a),s!==fr)){e.target=a;break}}}function Sr(t,e,n){var i=t.painter;return e<0||e>i.getWidth()||n<0||n>i.getHeight()}Cn(["click","mousedown","mouseup","mousewheel","dblclick","contextmenu"],function(t){br.prototype[t]=function(e){var n,i,r=e.zrX,o=e.zrY,a=Sr(this,r,o);if("mouseup"===t&&a||(i=(n=this.findHover(r,o)).target),"mousedown"===t)this._downEl=i,this._downPoint=[e.zrX,e.zrY],this._upEl=i;else if("mouseup"===t)this._upEl=i;else if("click"===t){if(this._downEl!==this._upEl||!this._downPoint||pi(this._downPoint,[e.zrX,e.zrY])>4)return;this._downPoint=null}this.dispatchToElement(n,t,e)}});function Cr(t,e,n,i){var r=e+1;if(r===n)return 1;if(i(t[r++],t[e])<0){for(;r=0;)r++;return r-e}function kr(t,e,n,i,r){for(i===e&&i++;i>>1])<0?l=o:s=o+1;var c=i-s;switch(c){case 3:t[s+3]=t[s+2];case 2:t[s+2]=t[s+1];case 1:t[s+1]=t[s];break;default:for(;c>0;)t[s+c]=t[s+c-1],c--}t[s]=a}}function Mr(t,e,n,i,r,o){var a=0,s=0,l=1;if(o(t,e[n+r])>0){for(s=i-r;l0;)a=l,(l=1+(l<<1))<=0&&(l=s);l>s&&(l=s),a+=r,l+=r}else{for(s=r+1;ls&&(l=s);var c=a;a=r-l,l=r-c}for(a++;a>>1);o(t,e[n+u])>0?a=u+1:l=u}return l}function Tr(t,e,n,i,r,o){var a=0,s=0,l=1;if(o(t,e[n+r])<0){for(s=r+1;ls&&(l=s);var c=a;a=r-l,l=r-c}else{for(s=i-r;l=0;)a=l,(l=1+(l<<1))<=0&&(l=s);l>s&&(l=s),a+=r,l+=r}for(a++;a>>1);o(t,e[n+u])<0?l=u:a=u+1}return l}function Ir(t,e){var n,i,r=7,o=0,a=[];function s(s){var l=n[s],c=i[s],u=n[s+1],h=i[s+1];i[s]=c+h,s===o-3&&(n[s+1]=n[s+2],i[s+1]=i[s+2]),o--;var d=Tr(t[u],t,l,c,0,e);l+=d,0!==(c-=d)&&0!==(h=Mr(t[l+c-1],t,u,h,h-1,e))&&(c<=h?function(n,i,o,s){var l=0;for(l=0;l=7||p>=7);if(f)break;g<0&&(g=0),g+=2}if((r=g)<1&&(r=1),1===i){for(l=0;l=0;l--)t[p+l]=t[d+l];return void(t[h]=a[u])}var f=r;for(;;){var g=0,v=0,m=!1;do{if(e(a[u],t[c])<0){if(t[h--]=t[c--],g++,v=0,0===--i){m=!0;break}}else if(t[h--]=a[u--],v++,g=0,1===--s){m=!0;break}}while((g|v)=0;l--)t[p+l]=t[d+l];if(0===i){m=!0;break}}if(t[h--]=a[u--],1===--s){m=!0;break}if(0!==(v=s-Mr(t[c],a,0,s,s-1,e))){for(s-=v,p=(h-=v)+1,d=(u-=v)+1,l=0;l=7||v>=7);if(m)break;f<0&&(f=0),f+=2}(r=f)<1&&(r=1);if(1===s){for(p=(h-=i)+1,d=(c-=i)+1,l=i-1;l>=0;l--)t[p+l]=t[d+l];t[h]=a[u]}else{if(0===s)throw new Error;for(d=h-(s-1),l=0;l1;){var t=o-2;if(t>=1&&i[t-1]<=i[t]+i[t+1]||t>=2&&i[t-2]<=i[t]+i[t-1])i[t-1]i[t+1])break;s(t)}},forceMergeRuns:function(){for(;o>1;){var t=o-2;t>0&&i[t-1]=32;)e|=1&t,t>>=1;return t+e}(r);do{if((o=Cr(t,n,i,e))s&&(l=s),kr(t,n,n+l,n+o,e),o=l}a.pushRun(n,o),a.mergeRuns(),r-=o,n+=o}while(0!==r);a.forceMergeRuns()}}}var Ar=!1;function Pr(){Ar||(Ar=!0,console.warn("z / z2 / zlevel of displayable is invalid, which may cause unexpected errors"))}function Lr(t,e){return t.zlevel===e.zlevel?t.z===e.z?t.z2-e.z2:t.z-e.z:t.zlevel-e.zlevel}var Er,zr=function(){function t(){this._roots=[],this._displayList=[],this._displayListLen=0,this.displayableSortFunc=Lr}return t.prototype.traverse=function(t,e){for(var n=0;n=0&&this._roots.splice(i,1)}},t.prototype.delAllRoots=function(){this._roots=[],this._displayList=[],this._displayListLen=0},t.prototype.getRoots=function(){return this._roots},t.prototype.dispose=function(){this._displayList=null,this._roots=null},t}();Er=Ye.hasGlobalWindow&&(window.requestAnimationFrame&&window.requestAnimationFrame.bind(window)||window.msRequestAnimationFrame&&window.msRequestAnimationFrame.bind(window)||window.mozRequestAnimationFrame||window.webkitRequestAnimationFrame)||function(t){return setTimeout(t,16)};var Nr={linear:function(t){return t},quadraticIn:function(t){return t*t},quadraticOut:function(t){return t*(2-t)},quadraticInOut:function(t){return(t*=2)<1?.5*t*t:-.5*(--t*(t-2)-1)},cubicIn:function(t){return t*t*t},cubicOut:function(t){return--t*t*t+1},cubicInOut:function(t){return(t*=2)<1?.5*t*t*t:.5*((t-=2)*t*t+2)},quarticIn:function(t){return t*t*t*t},quarticOut:function(t){return 1- --t*t*t*t},quarticInOut:function(t){return(t*=2)<1?.5*t*t*t*t:-.5*((t-=2)*t*t*t-2)},quinticIn:function(t){return t*t*t*t*t},quinticOut:function(t){return--t*t*t*t*t+1},quinticInOut:function(t){return(t*=2)<1?.5*t*t*t*t*t:.5*((t-=2)*t*t*t*t+2)},sinusoidalIn:function(t){return 1-Math.cos(t*Math.PI/2)},sinusoidalOut:function(t){return Math.sin(t*Math.PI/2)},sinusoidalInOut:function(t){return.5*(1-Math.cos(Math.PI*t))},exponentialIn:function(t){return 0===t?0:Math.pow(1024,t-1)},exponentialOut:function(t){return 1===t?1:1-Math.pow(2,-10*t)},exponentialInOut:function(t){return 0===t?0:1===t?1:(t*=2)<1?.5*Math.pow(1024,t-1):.5*(2-Math.pow(2,-10*(t-1)))},circularIn:function(t){return 1-Math.sqrt(1-t*t)},circularOut:function(t){return Math.sqrt(1- --t*t)},circularInOut:function(t){return(t*=2)<1?-.5*(Math.sqrt(1-t*t)-1):.5*(Math.sqrt(1-(t-=2)*t)+1)},elasticIn:function(t){var e,n=.1;return 0===t?0:1===t?1:(!n||n<1?(n=1,e=.1):e=.4*Math.asin(1/n)/(2*Math.PI),-n*Math.pow(2,10*(t-=1))*Math.sin((t-e)*(2*Math.PI)/.4))},elasticOut:function(t){var e,n=.1;return 0===t?0:1===t?1:(!n||n<1?(n=1,e=.1):e=.4*Math.asin(1/n)/(2*Math.PI),n*Math.pow(2,-10*t)*Math.sin((t-e)*(2*Math.PI)/.4)+1)},elasticInOut:function(t){var e,n=.1,i=.4;return 0===t?0:1===t?1:(!n||n<1?(n=1,e=.1):e=i*Math.asin(1/n)/(2*Math.PI),(t*=2)<1?n*Math.pow(2,10*(t-=1))*Math.sin((t-e)*(2*Math.PI)/i)*-.5:n*Math.pow(2,-10*(t-=1))*Math.sin((t-e)*(2*Math.PI)/i)*.5+1)},backIn:function(t){var e=1.70158;return t*t*((e+1)*t-e)},backOut:function(t){var e=1.70158;return--t*t*((e+1)*t+e)+1},backInOut:function(t){var e=2.5949095;return(t*=2)<1?t*t*((e+1)*t-e)*.5:.5*((t-=2)*t*((e+1)*t+e)+2)},bounceIn:function(t){return 1-Nr.bounceOut(1-t)},bounceOut:function(t){return t<1/2.75?7.5625*t*t:t<2/2.75?7.5625*(t-=1.5/2.75)*t+.75:t<2.5/2.75?7.5625*(t-=2.25/2.75)*t+.9375:7.5625*(t-=2.625/2.75)*t+.984375},bounceInOut:function(t){return t<.5?.5*Nr.bounceIn(2*t):.5*Nr.bounceOut(2*t-1)+.5}},Or=Math.pow,Rr=Math.sqrt,$r=1e-8,Hr=1e-4,Fr=Rr(3),Br=1/3,Vr=ai(),Wr=ai(),Ur=ai();function Gr(t){return t>-1e-8&&t<$r}function qr(t){return t>$r||t<-1e-8}function jr(t,e,n,i,r){var o=1-r;return o*o*(o*t+3*r*e)+r*r*(r*i+3*o*n)}function Xr(t,e,n,i,r){var o=1-r;return 3*(((e-t)*o+2*(n-e)*r)*o+(i-n)*r*r)}function Yr(t,e,n,i,r,o){var a=i+3*(e-n)-t,s=3*(n-2*e+t),l=3*(e-t),c=t-r,u=s*s-3*a*l,h=s*l-9*a*c,d=l*l-3*s*c,p=0;if(Gr(u)&&Gr(h)){if(Gr(s))o[0]=0;else(C=-l/s)>=0&&C<=1&&(o[p++]=C)}else{var f=h*h-4*u*d;if(Gr(f)){var g=h/u,v=-g/2;(C=-s/a+g)>=0&&C<=1&&(o[p++]=C),v>=0&&v<=1&&(o[p++]=v)}else if(f>0){var m=Rr(f),y=u*s+1.5*a*(-h+m),_=u*s+1.5*a*(-h-m);(C=(-s-((y=y<0?-Or(-y,Br):Or(y,Br))+(_=_<0?-Or(-_,Br):Or(_,Br))))/(3*a))>=0&&C<=1&&(o[p++]=C)}else{var b=(2*u*s-3*a*h)/(2*Rr(u*u*u)),x=Math.acos(b)/3,w=Rr(u),S=Math.cos(x),C=(-s-2*w*S)/(3*a),k=(v=(-s+w*(S+Fr*Math.sin(x)))/(3*a),(-s+w*(S-Fr*Math.sin(x)))/(3*a));C>=0&&C<=1&&(o[p++]=C),v>=0&&v<=1&&(o[p++]=v),k>=0&&k<=1&&(o[p++]=k)}}return p}function Zr(t,e,n,i,r){var o=6*n-12*e+6*t,a=9*e+3*i-3*t-9*n,s=3*e-3*t,l=0;if(Gr(a)){if(qr(o))(u=-s/o)>=0&&u<=1&&(r[l++]=u)}else{var c=o*o-4*a*s;if(Gr(c))r[0]=-o/(2*a);else if(c>0){var u,h=Rr(c),d=(-o-h)/(2*a);(u=(-o+h)/(2*a))>=0&&u<=1&&(r[l++]=u),d>=0&&d<=1&&(r[l++]=d)}}return l}function Kr(t,e,n,i,r,o){var a=(e-t)*r+t,s=(n-e)*r+e,l=(i-n)*r+n,c=(s-a)*r+a,u=(l-s)*r+s,h=(u-c)*r+c;o[0]=t,o[1]=a,o[2]=c,o[3]=h,o[4]=h,o[5]=u,o[6]=l,o[7]=i}function Qr(t,e,n,i,r,o,a,s,l){for(var c=t,u=e,h=0,d=1/l,p=1;p<=l;p++){var f=p*d,g=jr(t,n,r,a,f),v=jr(e,i,o,s,f),m=g-c,y=v-u;h+=Math.sqrt(m*m+y*y),c=g,u=v}return h}function Jr(t,e,n,i){var r=1-i;return r*(r*t+2*i*e)+i*i*n}function to(t,e,n,i){return 2*((1-i)*(e-t)+i*(n-e))}function eo(t,e,n){var i=t+n-2*e;return 0===i?.5:(t-e)/i}function no(t,e,n,i,r){var o=(e-t)*i+t,a=(n-e)*i+e,s=(a-o)*i+o;r[0]=t,r[1]=o,r[2]=s,r[3]=s,r[4]=a,r[5]=n}function io(t,e,n,i,r,o,a){for(var s=t,l=e,c=0,u=1/a,h=1;h<=a;h++){var d=h*u,p=Jr(t,n,r,d),f=Jr(e,i,o,d),g=p-s,v=f-l;c+=Math.sqrt(g*g+v*v),s=p,l=f}return c}var ro=/cubic-bezier\(([0-9,\.e ]+)\)/;function oo(t){var e=t&&ro.exec(t);if(e){var n=e[1].split(","),i=+Xn(n[0]),r=+Xn(n[1]),o=+Xn(n[2]),a=+Xn(n[3]);if(isNaN(i+r+o+a))return;var s=[];return function(t){return t<=0?0:t>=1?1:Yr(0,i,o,1,t,s)&&jr(0,r,a,1,s[0])}}}var ao=function(){function t(t){this._inited=!1,this._startTime=0,this._pausedTime=0,this._paused=!1,this._life=t.life||1e3,this._delay=t.delay||0,this.loop=t.loop||!1,this.onframe=t.onframe||ri,this.ondestroy=t.ondestroy||ri,this.onrestart=t.onrestart||ri,t.easing&&this.setEasing(t.easing)}return t.prototype.step=function(t,e){if(this._inited||(this._startTime=t+this._delay,this._inited=!0),!this._paused){var n=this._life,i=t-this._startTime-this._pausedTime,r=i/n;r<0&&(r=0),r=Math.min(r,1);var o=this.easingFunc,a=o?o(r):r;if(this.onframe(a),1===r){if(!this.loop)return!0;var s=i%n;this._startTime=t-s,this._pausedTime=0,this.onrestart()}return!1}this._pausedTime+=e},t.prototype.pause=function(){this._paused=!0},t.prototype.resume=function(){this._paused=!1},t.prototype.setEasing=function(t){this.easing=t,this.easingFunc=Ln(t)?t:Nr[t]||oo(t)},t}(),so=function(t){this.value=t},lo=function(){function t(){this._len=0}return t.prototype.insert=function(t){var e=new so(t);return this.insertEntry(e),e},t.prototype.insertEntry=function(t){this.head?(this.tail.next=t,t.prev=this.tail,t.next=null,this.tail=t):this.head=this.tail=t,this._len++},t.prototype.remove=function(t){var e=t.prev,n=t.next;e?e.next=n:this.head=n,n?n.prev=e:this.tail=e,t.next=t.prev=null,this._len--},t.prototype.len=function(){return this._len},t.prototype.clear=function(){this.head=this.tail=null,this._len=0},t}(),co=function(){function t(t){this._list=new lo,this._maxSize=10,this._map={},this._maxSize=t}return t.prototype.put=function(t,e){var n=this._list,i=this._map,r=null;if(null==i[t]){var o=n.len(),a=this._lastRemovedEntry;if(o>=this._maxSize&&o>0){var s=n.head;n.remove(s),delete i[s.key],r=s.value,this._lastRemovedEntry=s}a?a.value=e:a=new so(e),a.key=t,n.insertEntry(a),i[t]=a}return r},t.prototype.get=function(t){var e=this._map[t],n=this._list;if(null!=e)return e!==n.tail&&(n.remove(e),n.insertEntry(e)),e.value},t.prototype.clear=function(){this._list.clear(),this._map={}},t.prototype.len=function(){return this._list.len()},t}(),uo={transparent:[0,0,0,0],aliceblue:[240,248,255,1],antiquewhite:[250,235,215,1],aqua:[0,255,255,1],aquamarine:[127,255,212,1],azure:[240,255,255,1],beige:[245,245,220,1],bisque:[255,228,196,1],black:[0,0,0,1],blanchedalmond:[255,235,205,1],blue:[0,0,255,1],blueviolet:[138,43,226,1],brown:[165,42,42,1],burlywood:[222,184,135,1],cadetblue:[95,158,160,1],chartreuse:[127,255,0,1],chocolate:[210,105,30,1],coral:[255,127,80,1],cornflowerblue:[100,149,237,1],cornsilk:[255,248,220,1],crimson:[220,20,60,1],cyan:[0,255,255,1],darkblue:[0,0,139,1],darkcyan:[0,139,139,1],darkgoldenrod:[184,134,11,1],darkgray:[169,169,169,1],darkgreen:[0,100,0,1],darkgrey:[169,169,169,1],darkkhaki:[189,183,107,1],darkmagenta:[139,0,139,1],darkolivegreen:[85,107,47,1],darkorange:[255,140,0,1],darkorchid:[153,50,204,1],darkred:[139,0,0,1],darksalmon:[233,150,122,1],darkseagreen:[143,188,143,1],darkslateblue:[72,61,139,1],darkslategray:[47,79,79,1],darkslategrey:[47,79,79,1],darkturquoise:[0,206,209,1],darkviolet:[148,0,211,1],deeppink:[255,20,147,1],deepskyblue:[0,191,255,1],dimgray:[105,105,105,1],dimgrey:[105,105,105,1],dodgerblue:[30,144,255,1],firebrick:[178,34,34,1],floralwhite:[255,250,240,1],forestgreen:[34,139,34,1],fuchsia:[255,0,255,1],gainsboro:[220,220,220,1],ghostwhite:[248,248,255,1],gold:[255,215,0,1],goldenrod:[218,165,32,1],gray:[128,128,128,1],green:[0,128,0,1],greenyellow:[173,255,47,1],grey:[128,128,128,1],honeydew:[240,255,240,1],hotpink:[255,105,180,1],indianred:[205,92,92,1],indigo:[75,0,130,1],ivory:[255,255,240,1],khaki:[240,230,140,1],lavender:[230,230,250,1],lavenderblush:[255,240,245,1],lawngreen:[124,252,0,1],lemonchiffon:[255,250,205,1],lightblue:[173,216,230,1],lightcoral:[240,128,128,1],lightcyan:[224,255,255,1],lightgoldenrodyellow:[250,250,210,1],lightgray:[211,211,211,1],lightgreen:[144,238,144,1],lightgrey:[211,211,211,1],lightpink:[255,182,193,1],lightsalmon:[255,160,122,1],lightseagreen:[32,178,170,1],lightskyblue:[135,206,250,1],lightslategray:[119,136,153,1],lightslategrey:[119,136,153,1],lightsteelblue:[176,196,222,1],lightyellow:[255,255,224,1],lime:[0,255,0,1],limegreen:[50,205,50,1],linen:[250,240,230,1],magenta:[255,0,255,1],maroon:[128,0,0,1],mediumaquamarine:[102,205,170,1],mediumblue:[0,0,205,1],mediumorchid:[186,85,211,1],mediumpurple:[147,112,219,1],mediumseagreen:[60,179,113,1],mediumslateblue:[123,104,238,1],mediumspringgreen:[0,250,154,1],mediumturquoise:[72,209,204,1],mediumvioletred:[199,21,133,1],midnightblue:[25,25,112,1],mintcream:[245,255,250,1],mistyrose:[255,228,225,1],moccasin:[255,228,181,1],navajowhite:[255,222,173,1],navy:[0,0,128,1],oldlace:[253,245,230,1],olive:[128,128,0,1],olivedrab:[107,142,35,1],orange:[255,165,0,1],orangered:[255,69,0,1],orchid:[218,112,214,1],palegoldenrod:[238,232,170,1],palegreen:[152,251,152,1],paleturquoise:[175,238,238,1],palevioletred:[219,112,147,1],papayawhip:[255,239,213,1],peachpuff:[255,218,185,1],peru:[205,133,63,1],pink:[255,192,203,1],plum:[221,160,221,1],powderblue:[176,224,230,1],purple:[128,0,128,1],red:[255,0,0,1],rosybrown:[188,143,143,1],royalblue:[65,105,225,1],saddlebrown:[139,69,19,1],salmon:[250,128,114,1],sandybrown:[244,164,96,1],seagreen:[46,139,87,1],seashell:[255,245,238,1],sienna:[160,82,45,1],silver:[192,192,192,1],skyblue:[135,206,235,1],slateblue:[106,90,205,1],slategray:[112,128,144,1],slategrey:[112,128,144,1],snow:[255,250,250,1],springgreen:[0,255,127,1],steelblue:[70,130,180,1],tan:[210,180,140,1],teal:[0,128,128,1],thistle:[216,191,216,1],tomato:[255,99,71,1],turquoise:[64,224,208,1],violet:[238,130,238,1],wheat:[245,222,179,1],white:[255,255,255,1],whitesmoke:[245,245,245,1],yellow:[255,255,0,1],yellowgreen:[154,205,50,1]};function ho(t){return(t=Math.round(t))<0?0:t>255?255:t}function po(t){return t<0?0:t>1?1:t}function fo(t){var e=t;return e.length&&"%"===e.charAt(e.length-1)?ho(parseFloat(e)/100*255):ho(parseInt(e,10))}function go(t){var e=t;return e.length&&"%"===e.charAt(e.length-1)?po(parseFloat(e)/100):po(parseFloat(e))}function vo(t,e,n){return n<0?n+=1:n>1&&(n-=1),6*n<1?t+(e-t)*n*6:2*n<1?e:3*n<2?t+(e-t)*(2/3-n)*6:t}function mo(t,e,n){return t+(e-t)*n}function yo(t,e,n,i,r){return t[0]=e,t[1]=n,t[2]=i,t[3]=r,t}function _o(t,e){return t[0]=e[0],t[1]=e[1],t[2]=e[2],t[3]=e[3],t}var bo=new co(20),xo=null;function wo(t,e){xo&&_o(xo,e),xo=bo.put(t,xo||e.slice())}function So(t,e){if(t){e=e||[];var n=bo.get(t);if(n)return _o(e,n);var i=(t+="").replace(/ /g,"").toLowerCase();if(i in uo)return _o(e,uo[i]),wo(t,e),e;var r,o=i.length;if("#"===i.charAt(0))return 4===o||5===o?(r=parseInt(i.slice(1,4),16))>=0&&r<=4095?(yo(e,(3840&r)>>4|(3840&r)>>8,240&r|(240&r)>>4,15&r|(15&r)<<4,5===o?parseInt(i.slice(4),16)/15:1),wo(t,e),e):void yo(e,0,0,0,1):7===o||9===o?(r=parseInt(i.slice(1,7),16))>=0&&r<=16777215?(yo(e,(16711680&r)>>16,(65280&r)>>8,255&r,9===o?parseInt(i.slice(7),16)/255:1),wo(t,e),e):void yo(e,0,0,0,1):void 0;var a=i.indexOf("("),s=i.indexOf(")");if(-1!==a&&s+1===o){var l=i.substr(0,a),c=i.substr(a+1,s-(a+1)).split(","),u=1;switch(l){case"rgba":if(4!==c.length)return 3===c.length?yo(e,+c[0],+c[1],+c[2],1):yo(e,0,0,0,1);u=go(c.pop());case"rgb":return c.length>=3?(yo(e,fo(c[0]),fo(c[1]),fo(c[2]),3===c.length?u:go(c[3])),wo(t,e),e):void yo(e,0,0,0,1);case"hsla":return 4!==c.length?void yo(e,0,0,0,1):(c[3]=go(c[3]),Co(c,e),wo(t,e),e);case"hsl":return 3!==c.length?void yo(e,0,0,0,1):(Co(c,e),wo(t,e),e);default:return}}yo(e,0,0,0,1)}}function Co(t,e){var n=(parseFloat(t[0])%360+360)%360/360,i=go(t[1]),r=go(t[2]),o=r<=.5?r*(i+1):r+i-r*i,a=2*r-o;return yo(e=e||[],ho(255*vo(a,o,n+1/3)),ho(255*vo(a,o,n)),ho(255*vo(a,o,n-1/3)),1),4===t.length&&(e[3]=t[3]),e}function ko(t,e){var n=So(t);if(n){for(var i=0;i<3;i++)n[i]=n[i]*(1-e)|0,n[i]>255?n[i]=255:n[i]<0&&(n[i]=0);return To(n,4===n.length?"rgba":"rgb")}}function Mo(t,e,n,i){var r=So(t);if(t)return r=function(t){if(t){var e,n,i=t[0]/255,r=t[1]/255,o=t[2]/255,a=Math.min(i,r,o),s=Math.max(i,r,o),l=s-a,c=(s+a)/2;if(0===l)e=0,n=0;else{n=c<.5?l/(s+a):l/(2-s-a);var u=((s-i)/6+l/2)/l,h=((s-r)/6+l/2)/l,d=((s-o)/6+l/2)/l;i===s?e=d-h:r===s?e=1/3+u-d:o===s&&(e=2/3+h-u),e<0&&(e+=1),e>1&&(e-=1)}var p=[360*e,n,c];return null!=t[3]&&p.push(t[3]),p}}(r),null!=n&&(r[1]=go(Ln(n)?n(r[1]):n)),null!=i&&(r[2]=go(Ln(i)?i(r[2]):i)),To(Co(r),"rgba")}function To(t,e){if(t&&t.length){var n=t[0]+","+t[1]+","+t[2];return"rgba"!==e&&"hsva"!==e&&"hsla"!==e||(n+=","+t[3]),e+"("+n+")"}}function Io(t,e){var n=So(t);return n?(.299*n[0]+.587*n[1]+.114*n[2])*n[3]/255+(1-n[3])*e:0}var Do=new co(100);function Ao(t){if(En(t)){var e=Do.get(t);return e||(e=ko(t,-.1),Do.put(t,e)),e}if(Fn(t)){var n=_n({},t);return n.colorStops=kn(t.colorStops,function(t){return{offset:t.offset,color:ko(t.color,-.1)}}),n}return t}var Po=Math.round;function Lo(t){var e;if(t&&"transparent"!==t){if("string"==typeof t&&t.indexOf("rgba")>-1){var n=So(t);n&&(t="rgb("+n[0]+","+n[1]+","+n[2]+")",e=n[3])}}else t="none";return{color:t,opacity:null==e?1:e}}var Eo=1e-4;function zo(t){return t-1e-4}function No(t){return Po(1e3*t)/1e3}function Oo(t){return Po(1e4*t)/1e4}var Ro={left:"start",right:"end",center:"middle",middle:"middle"};function $o(t){return t&&!!t.image}function Ho(t){return $o(t)||function(t){return t&&!!t.svgElement}(t)}function Fo(t){return"linear"===t.type}function Bo(t){return"radial"===t.type}function Vo(t){return t&&("linear"===t.type||"radial"===t.type)}function Wo(t){return"url(#"+t+")"}function Uo(t){var e=t.getGlobalScale(),n=Math.max(e[0],e[1]);return Math.max(Math.ceil(Math.log(n)/Math.log(10)),1)}function Go(t){var e=t.x||0,n=t.y||0,i=(t.rotation||0)*oi,r=Wn(t.scaleX,1),o=Wn(t.scaleY,1),a=t.skewX||0,s=t.skewY||0,l=[];return(e||n)&&l.push("translate("+e+"px,"+n+"px)"),i&&l.push("rotate("+i+")"),1===r&&1===o||l.push("scale("+r+","+o+")"),(a||s)&&l.push("skew("+Po(a*oi)+"deg, "+Po(s*oi)+"deg)"),l.join(" ")}var qo=Ye.hasGlobalWindow&&Ln(window.btoa)?function(t){return window.btoa(unescape(encodeURIComponent(t)))}:"undefined"!=typeof Buffer?function(t){return Buffer.from(t).toString("base64")}:function(t){return null},jo=Array.prototype.slice;function Xo(t,e,n){return(e-t)*n+t}function Yo(t,e,n,i){for(var r=e.length,o=0;oi?e:t,o=Math.min(n,i),a=r[o-1]||{color:[0,0,0,0],offset:0},s=o;sa)i.length=a;else for(var s=o;s=1},t.prototype.getAdditiveTrack=function(){return this._additiveTrack},t.prototype.addKeyframe=function(t,e,n){this._needsSort=!0;var i=this.keyframes,r=i.length,o=!1,a=6,s=e;if(Sn(e)){var l=function(t){return Sn(t&&t[0])?2:1}(e);a=l,(1===l&&!Nn(e[0])||2===l&&!Nn(e[0][0]))&&(o=!0)}else if(Nn(e)&&!Bn(e))a=0;else if(En(e))if(isNaN(+e)){var c=So(e);c&&(s=c,a=3)}else a=0;else if(Fn(e)){var u=_n({},s);u.colorStops=kn(e.colorStops,function(t){return{offset:t.offset,color:So(t.color)}}),Fo(e)?a=4:Bo(e)&&(a=5),s=u}0===r?this.valType=a:a===this.valType&&6!==a||(o=!0),this.discrete=this.discrete||o;var h={time:t,value:s,rawValue:e,percent:0};return n&&(h.easing=n,h.easingFunc=Ln(n)?n:Nr[n]||oo(n)),i.push(h),h},t.prototype.prepare=function(t,e){var n=this.keyframes;this._needsSort&&n.sort(function(t,e){return t.time-e.time});for(var i=this.valType,r=n.length,o=n[r-1],a=this.discrete,s=ia(i),l=na(i),c=0;c=0&&!(l[n].percent<=e);n--);n=p(n,c-2)}else{for(n=d;ne);n++);n=p(n-1,c-2)}r=l[n+1],i=l[n]}if(i&&r){this._lastFr=n,this._lastFrP=e;var f=r.percent-i.percent,g=0===f?1:p((e-i.percent)/f,1);r.easingFunc&&(g=r.easingFunc(g));var v=o?this._additiveValue:h?ra:t[u];if(!ia(s)&&!h||v||(v=this._additiveValue=[]),this.discrete)t[u]=g<1?i.rawValue:r.rawValue;else if(ia(s))1===s?Yo(v,i[a],r[a],g):function(t,e,n,i){for(var r=e.length,o=r&&e[0].length,a=0;a0&&s.addKeyframe(0,ta(l),i),this._trackKeys.push(a)}s.addKeyframe(t,ta(e[a]),i)}return this._maxTime=Math.max(this._maxTime,t),this},t.prototype.pause=function(){this._clip.pause(),this._paused=!0},t.prototype.resume=function(){this._clip.resume(),this._paused=!1},t.prototype.isPaused=function(){return!!this._paused},t.prototype.duration=function(t){return this._maxTime=t,this._force=!0,this},t.prototype._doneCallback=function(){this._setTracksFinished(),this._clip=null;var t=this._doneCbs;if(t)for(var e=t.length,n=0;n0)){this._started=1;for(var e=this,n=[],i=this._maxTime||0,r=0;r1){var a=o.pop();r.addKeyframe(a.time,t[i]),r.prepare(this._maxTime,r.getAdditiveTrack())}}}},t}();function sa(){return(new Date).getTime()}var la,ca,ua=function(t){function e(e){var n=t.call(this)||this;return n._running=!1,n._time=0,n._pausedTime=0,n._pauseStart=0,n._paused=!1,e=e||{},n.stage=e.stage||{},n}return y(e,t),e.prototype.addClip=function(t){t.animation&&this.removeClip(t),this._head?(this._tail.next=t,t.prev=this._tail,t.next=null,this._tail=t):this._head=this._tail=t,t.animation=this},e.prototype.addAnimator=function(t){t.animation=this;var e=t.getClip();e&&this.addClip(e)},e.prototype.removeClip=function(t){if(t.animation){var e=t.prev,n=t.next;e?e.next=n:this._head=n,n?n.prev=e:this._tail=e,t.next=t.prev=t.animation=null}},e.prototype.removeAnimator=function(t){var e=t.getClip();e&&this.removeClip(e),t.animation=null},e.prototype.update=function(t){for(var e=sa()-this._pausedTime,n=e-this._time,i=this._head;i;){var r=i.next;i.step(e,n)?(i.ondestroy(),this.removeClip(i),i=r):i=r}this._time=e,t||(this.trigger("frame",n),this.stage.update&&this.stage.update())},e.prototype._startLoop=function(){var t=this;this._running=!0,Er(function e(){t._running&&(Er(e),!t._paused&&t.update())})},e.prototype.start=function(){this._running||(this._time=sa(),this._pausedTime=0,this._startLoop())},e.prototype.stop=function(){this._running=!1},e.prototype.pause=function(){this._paused||(this._pauseStart=sa(),this._paused=!0)},e.prototype.resume=function(){this._paused&&(this._pausedTime+=sa()-this._pauseStart,this._paused=!1)},e.prototype.clear=function(){for(var t=this._head;t;){var e=t.next;t.prev=t.next=t.animation=null,t=e}this._head=this._tail=null},e.prototype.isFinished=function(){return null==this._head},e.prototype.animate=function(t,e){e=e||{},this.start();var n=new aa(t,e.loop);return this.addAnimator(n),n},e}(bi),ha=Ye.domSupported,da=(ca={pointerdown:1,pointerup:1,pointermove:1,pointerout:1},{mouse:la=["click","dblclick","mousewheel","wheel","mouseout","mouseup","mousedown","mousemove","contextmenu"],touch:["touchstart","touchend","touchmove"],pointer:kn(la,function(t){var e=t.replace("mouse","pointer");return ca.hasOwnProperty(e)?e:t})}),pa=["mousemove","mouseup"],fa=["pointermove","pointerup"],ga=!1;function va(t){var e=t.pointerType;return"pen"===e||"touch"===e}function ma(t){t&&(t.zrByTouch=!0)}function ya(t,e){for(var n=e,i=!1;n&&9!==n.nodeType&&!(i=n.domBelongToZr||n!==e&&n===t.painterRoot);)n=n.parentNode;return i}var _a=function(t,e){this.stopPropagation=ri,this.stopImmediatePropagation=ri,this.preventDefault=ri,this.type=e.type,this.target=this.currentTarget=t.dom,this.pointerType=e.pointerType,this.clientX=e.clientX,this.clientY=e.clientY},ba={mousedown:function(t){t=Ri(this.dom,t),this.__mayPointerCapture=[t.zrX,t.zrY],this.trigger("mousedown",t)},mousemove:function(t){t=Ri(this.dom,t);var e=this.__mayPointerCapture;!e||t.zrX===e[0]&&t.zrY===e[1]||this.__togglePointerCapture(!0),this.trigger("mousemove",t)},mouseup:function(t){t=Ri(this.dom,t),this.__togglePointerCapture(!1),this.trigger("mouseup",t)},mouseout:function(t){ya(this,(t=Ri(this.dom,t)).toElement||t.relatedTarget)||(this.__pointerCapturing&&(t.zrEventControl="no_globalout"),this.trigger("mouseout",t))},wheel:function(t){ga=!0,t=Ri(this.dom,t),this.trigger("mousewheel",t)},mousewheel:function(t){ga||(t=Ri(this.dom,t),this.trigger("mousewheel",t))},touchstart:function(t){ma(t=Ri(this.dom,t)),this.__lastTouchMoment=new Date,this.handler.processGesture(t,"start"),ba.mousemove.call(this,t),ba.mousedown.call(this,t)},touchmove:function(t){ma(t=Ri(this.dom,t)),this.handler.processGesture(t,"change"),ba.mousemove.call(this,t)},touchend:function(t){ma(t=Ri(this.dom,t)),this.handler.processGesture(t,"end"),ba.mouseup.call(this,t),+new Date-+this.__lastTouchMoment<300&&ba.click.call(this,t)},pointerdown:function(t){ba.mousedown.call(this,t)},pointermove:function(t){va(t)||ba.mousemove.call(this,t)},pointerup:function(t){ba.mouseup.call(this,t)},pointerout:function(t){va(t)||ba.mouseout.call(this,t)}};Cn(["click","dblclick","contextmenu"],function(t){ba[t]=function(e){e=Ri(this.dom,e),this.trigger(t,e)}});var xa={pointermove:function(t){va(t)||xa.mousemove.call(this,t)},pointerup:function(t){xa.mouseup.call(this,t)},mousemove:function(t){this.trigger("mousemove",t)},mouseup:function(t){var e=this.__pointerCapturing;this.__togglePointerCapture(!1),this.trigger("mouseup",t),e&&(t.zrEventControl="only_globalout",this.trigger("mouseout",t))}};function wa(t,e){var n=e.domHandlers;Ye.pointerEventsSupported?Cn(da.pointer,function(i){Ca(e,i,function(e){n[i].call(t,e)})}):(Ye.touchEventsSupported&&Cn(da.touch,function(i){Ca(e,i,function(r){n[i].call(t,r),function(t){t.touching=!0,null!=t.touchTimer&&(clearTimeout(t.touchTimer),t.touchTimer=null),t.touchTimer=setTimeout(function(){t.touching=!1,t.touchTimer=null},700)}(e)})}),Cn(da.mouse,function(i){Ca(e,i,function(r){r=Oi(r),e.touching||n[i].call(t,r)})}))}function Sa(t,e){function n(n){Ca(e,n,function(i){i=Oi(i),ya(t,i.target)||(i=function(t,e){return Ri(t.dom,new _a(t,e),!0)}(t,i),e.domHandlers[n].call(t,i))},{capture:!0})}Ye.pointerEventsSupported?Cn(fa,n):Ye.touchEventsSupported||Cn(pa,n)}function Ca(t,e,n,i){t.mounted[e]=n,t.listenerOpts[e]=i,function(t,e,n,i){t.addEventListener(e,n,i)}(t.domTarget,e,n,i)}function ka(t){var e=t.mounted;for(var n in e)e.hasOwnProperty(n)&&$i(t.domTarget,n,e[n],t.listenerOpts[n]);t.mounted={}}var Ma=function(t,e){this.mounted={},this.listenerOpts={},this.touching=!1,this.domTarget=t,this.domHandlers=e},Ta=function(t){function e(e,n){var i=t.call(this)||this;return i.__pointerCapturing=!1,i.dom=e,i.painterRoot=n,i._localHandlerScope=new Ma(e,ba),ha&&(i._globalHandlerScope=new Ma(document,xa)),wa(i,i._localHandlerScope),i}return y(e,t),e.prototype.dispose=function(){ka(this._localHandlerScope),ha&&ka(this._globalHandlerScope)},e.prototype.setCursor=function(t){this.dom.style&&(this.dom.style.cursor=t||"default")},e.prototype.__togglePointerCapture=function(t){if(this.__mayPointerCapture=null,ha&&+this.__pointerCapturing^+t){this.__pointerCapturing=t;var e=this._globalHandlerScope;t?Sa(this,e):ka(e)}},e}(bi),Ia=1;Ye.hasGlobalWindow&&(Ia=Math.max(window.devicePixelRatio||window.screen&&window.screen.deviceXDPI/window.screen.logicalXDPI||1,1));var Da=Ia,Aa="#333",Pa="#ccc",La=Wi,Ea=5e-5;function za(t){return t>Ea||t<-5e-5}var Na,Oa=[],Ra=[],$a=[1,0,0,1,0,0],Ha=Math.abs,Fa=function(){function t(){}var e;return t.prototype.getLocalTransform=function(e){return t.getLocalTransform(this,e)},t.prototype.setPosition=function(t){this.x=t[0],this.y=t[1]},t.prototype.setScale=function(t){this.scaleX=t[0],this.scaleY=t[1]},t.prototype.setSkew=function(t){this.skewX=t[0],this.skewY=t[1]},t.prototype.setOrigin=function(t){this.originX=t[0],this.originY=t[1]},t.prototype.needLocalTransform=function(){return za(this.rotation)||za(this.x)||za(this.y)||za(this.scaleX-1)||za(this.scaleY-1)||za(this.skewX)||za(this.skewY)},t.prototype.updateTransform=function(){var t=this.parent&&this.parent.transform,e=this.needLocalTransform(),n=this.transform;e||t?(n=n||[1,0,0,1,0,0],e?this.getLocalTransform(n):La(n),t&&(e?Gi(n,t,n):Ui(n,t)),this.transform=n,this._resolveGlobalScaleRatio(n)):n&&(La(n),this.invTransform=null)},t.prototype._resolveGlobalScaleRatio=function(t){var e=this.globalScaleRatio;if(null!=e&&1!==e){this.getGlobalScale(Oa);var n=Oa[0]<0?-1:1,i=Oa[1]<0?-1:1,r=((Oa[0]-n)*e+n)/Oa[0]||0,o=((Oa[1]-i)*e+i)/Oa[1]||0;t[0]*=r,t[1]*=r,t[2]*=o,t[3]*=o}this.invTransform=this.invTransform||[1,0,0,1,0,0],Xi(this.invTransform,t)},t.prototype.getComputedTransform=function(){for(var t=this,e=[];t;)e.push(t),t=t.parent;for(;t=e.pop();)t.updateTransform();return this.transform},t.prototype.setLocalTransform=function(t){if(t){var e=t[0]*t[0]+t[1]*t[1],n=t[2]*t[2]+t[3]*t[3],i=Math.atan2(t[1],t[0]),r=Math.PI/2+i-Math.atan2(t[3],t[2]);n=Math.sqrt(n)*Math.cos(r),e=Math.sqrt(e),this.skewX=r,this.skewY=0,this.rotation=-i,this.x=+t[4],this.y=+t[5],this.scaleX=e,this.scaleY=n,this.originX=0,this.originY=0}},t.prototype.decomposeTransform=function(){if(this.transform){var t=this.parent,e=this.transform;t&&t.transform&&(t.invTransform=t.invTransform||[1,0,0,1,0,0],Gi(Ra,t.invTransform,e),e=Ra);var n=this.originX,i=this.originY;(n||i)&&($a[4]=n,$a[5]=i,Gi(Ra,e,$a),Ra[4]-=n,Ra[5]-=i,e=Ra),this.setLocalTransform(e)}},t.prototype.getGlobalScale=function(t){var e=this.transform;return t=t||[],e?(t[0]=Math.sqrt(e[0]*e[0]+e[1]*e[1]),t[1]=Math.sqrt(e[2]*e[2]+e[3]*e[3]),e[0]<0&&(t[0]=-t[0]),e[3]<0&&(t[1]=-t[1]),t):(t[0]=1,t[1]=1,t)},t.prototype.transformCoordToLocal=function(t,e){var n=[t,e],i=this.invTransform;return i&&gi(n,n,i),n},t.prototype.transformCoordToGlobal=function(t,e){var n=[t,e],i=this.transform;return i&&gi(n,n,i),n},t.prototype.getLineScale=function(){var t=this.transform;return t&&Ha(t[0]-1)>1e-10&&Ha(t[3]-1)>1e-10?Math.sqrt(Ha(t[0]*t[3]-t[2]*t[1])):1},t.prototype.copyTransform=function(t){Va(this,t)},t.getLocalTransform=function(t,e){e=e||[];var n=t.originX||0,i=t.originY||0,r=t.scaleX,o=t.scaleY,a=t.anchorX,s=t.anchorY,l=t.rotation||0,c=t.x,u=t.y,h=t.skewX?Math.tan(t.skewX):0,d=t.skewY?Math.tan(-t.skewY):0;if(n||i||a||s){var p=n+a,f=i+s;e[4]=-p*r-h*f*o,e[5]=-f*o-d*p*r}else e[4]=e[5]=0;return e[0]=r,e[3]=o,e[1]=d*r,e[2]=h*o,l&&ji(e,e,l),e[4]+=n+c,e[5]+=i+u,e},t.initDefaultProps=((e=t.prototype).scaleX=e.scaleY=e.globalScaleRatio=1,void(e.x=e.y=e.originX=e.originY=e.skewX=e.skewY=e.rotation=e.anchorX=e.anchorY=0)),t}(),Ba=["x","y","originX","originY","anchorX","anchorY","rotation","scaleX","scaleY","skewX","skewY"];function Va(t,e){for(var n=0;n=Ga)){t=t||Ke;for(var e=[],n=+new Date,i=0;i<=127;i++)e[i]=en.measureText(String.fromCharCode(i),t).width;var r=+new Date-n;return r>16?Ua=Ga:r>2&&Ua++,e}}(t.font),t.asciiWidthMapTried=!0),0<=e&&e<=127?null!=t.asciiWidthMap?t.asciiWidthMap[e]:t.asciiCharWidth:t.stWideCharWidth}function ja(t,e){var n=t.strWidthCache,i=n.get(e);return null==i&&(i=en.measureText(e,t.font).width,n.put(e,i)),i}function Xa(t,e,n,i){var r=ja(Wa(e),t),o=Qa(e),a=Za(0,r,n),s=Ka(0,o,i);return new cr(a,s,r,o)}function Ya(t,e,n,i){var r=((t||"")+"").split("\n");if(1===r.length)return Xa(r[0],e,n,i);for(var o=new cr(0,0,0,0),a=0;a=0?parseFloat(t)/100*e:parseFloat(t):t}function ts(t,e,n){var i=e.position||"inside",r=null!=e.distance?e.distance:5,o=n.height,a=n.width,s=o/2,l=n.x,c=n.y,u="left",h="top";if(i instanceof Array)l+=Ja(i[0],n.width),c+=Ja(i[1],n.height),u=null,h=null;else switch(i){case"left":l-=r,c+=s,u="right",h="middle";break;case"right":l+=r+a,c+=s,h="middle";break;case"top":l+=a/2,c-=r,u="center",h="bottom";break;case"bottom":l+=a/2,c+=o+r,u="center";break;case"inside":l+=a/2,c+=s,u="center",h="middle";break;case"insideLeft":l+=r,c+=s,h="middle";break;case"insideRight":l+=a-r,c+=s,u="right",h="middle";break;case"insideTop":l+=a/2,c+=r,u="center";break;case"insideBottom":l+=a/2,c+=o-r,u="center",h="bottom";break;case"insideTopLeft":l+=r,c+=r;break;case"insideTopRight":l+=a-r,c+=r,u="right";break;case"insideBottomLeft":l+=r,c+=o-r,h="bottom";break;case"insideBottomRight":l+=a-r,c+=o-r,u="right",h="bottom"}return(t=t||{}).x=l,t.y=c,t.align=u,t.verticalAlign=h,t}var es="__zr_normal__",ns=Ba.concat(["ignore"]),is=Mn(Ba,function(t,e){return t[e]=!0,t},{ignore:!1}),rs={},os=new cr(0,0,0,0),as=[],ss=function(){function t(t){this.id=gn(),this.animators=[],this.currentStates=[],this.states={},this._init(t)}return t.prototype._init=function(t){this.attr(t)},t.prototype.drift=function(t,e,n){switch(this.draggable){case"horizontal":e=0;break;case"vertical":t=0}var i=this.transform;i||(i=this.transform=[1,0,0,1,0,0]),i[4]+=t,i[5]+=e,this.decomposeTransform(),this.markRedraw()},t.prototype.beforeUpdate=function(){},t.prototype.afterUpdate=function(){},t.prototype.update=function(){this.updateTransform(),this.__dirty&&this.updateInnerText()},t.prototype.updateInnerText=function(t){var e=this._textContent;if(e&&(!e.ignore||t)){this.textConfig||(this.textConfig={});var n=this.textConfig,i=n.local,r=e.innerTransformable,o=void 0,a=void 0,s=!1;r.parent=i?this:null;var l=!1;r.copyTransform(e);var c=null!=n.position,u=n.autoOverflowArea,h=void 0;if((u||c)&&(h=os,n.layoutRect?h.copy(n.layoutRect):h.copy(this.getBoundingRect()),i||h.applyTransform(this.transform)),c){this.calculateTextPosition?this.calculateTextPosition(rs,n,h):ts(rs,n,h),r.x=rs.x,r.y=rs.y,o=rs.align,a=rs.verticalAlign;var d=n.origin;if(d&&null!=n.rotation){var p=void 0,f=void 0;"center"===d?(p=.5*h.width,f=.5*h.height):(p=Ja(d[0],h.width),f=Ja(d[1],h.height)),l=!0,r.originX=-r.x+p+(i?0:h.x),r.originY=-r.y+f+(i?0:h.y)}}null!=n.rotation&&(r.rotation=n.rotation);var g=n.offset;g&&(r.x+=g[0],r.y+=g[1],l||(r.originX=-g[0],r.originY=-g[1]));var v=this._innerTextDefaultStyle||(this._innerTextDefaultStyle={});if(u){var m=v.overflowRect=v.overflowRect||new cr(0,0,0,0);r.getLocalTransform(as),Xi(as,as),cr.copy(m,h),m.applyTransform(as)}else v.overflowRect=null;var y=void 0,_=void 0,b=void 0;(null==n.inside?"string"==typeof n.position&&n.position.indexOf("inside")>=0:n.inside)&&this.canBeInsideText()?(y=n.insideFill,_=n.insideStroke,null!=y&&"auto"!==y||(y=this.getInsideTextFill()),null!=_&&"auto"!==_||(_=this.getInsideTextStroke(y),b=!0)):(y=n.outsideFill,_=n.outsideStroke,null!=y&&"auto"!==y||(y=this.getOutsideFill()),null!=_&&"auto"!==_||(_=this.getOutsideStroke(y),b=!0)),(y=y||"#000")===v.fill&&_===v.stroke&&b===v.autoStroke&&o===v.align&&a===v.verticalAlign||(s=!0,v.fill=y,v.stroke=_,v.autoStroke=b,v.align=o,v.verticalAlign=a,e.setDefaultTextStyle(v)),e.__dirty|=1,s&&e.dirtyStyle(!0)}},t.prototype.canBeInsideText=function(){return!0},t.prototype.getInsideTextFill=function(){return"#fff"},t.prototype.getInsideTextStroke=function(t){return"#000"},t.prototype.getOutsideFill=function(){return this.__zr&&this.__zr.isDarkMode()?Pa:Aa},t.prototype.getOutsideStroke=function(t){var e=this.__zr&&this.__zr.getBackgroundColor(),n="string"==typeof e&&So(e);n||(n=[255,255,255,1]);for(var i=n[3],r=this.__zr.isDarkMode(),o=0;o<3;o++)n[o]=n[o]*i+(r?0:255)*(1-i);return n[3]=1,To(n,"rgba")},t.prototype.traverse=function(t,e){},t.prototype.attrKV=function(t,e){"textConfig"===t?this.setTextConfig(e):"textContent"===t?this.setTextContent(e):"clipPath"===t?this.setClipPath(e):"extra"===t?(this.extra=this.extra||{},_n(this.extra,e)):this[t]=e},t.prototype.hide=function(){this.ignore=!0,this.markRedraw()},t.prototype.show=function(){this.ignore=!1,this.markRedraw()},t.prototype.attr=function(t,e){if("string"==typeof t)this.attrKV(t,e);else if(On(t))for(var n=In(t),i=0;i0},t.prototype.getState=function(t){return this.states[t]},t.prototype.ensureState=function(t){var e=this.states;return e[t]||(e[t]={}),e[t]},t.prototype.clearStates=function(t){this.useState(es,!1,t)},t.prototype.useState=function(t,e,n,i){var r=t===es;if(this.hasState()||!r){var o=this.currentStates,a=this.stateTransition;if(!(xn(o,t)>=0)||!e&&1!==o.length){var s;if(this.stateProxy&&!r&&(s=this.stateProxy(t)),s||(s=this.states&&this.states[t]),s||r){r||this.saveCurrentToNormalState(s);var l=!!(s&&s.hoverLayer||i);l&&this._toggleHoverLayerFlag(!0),this._applyStateObj(t,s,this._normalState,e,!n&&!this.__inHover&&a&&a.duration>0,a);var c=this._textContent,u=this._textGuide;return c&&c.useState(t,e,n,l),u&&u.useState(t,e,n,l),r?(this.currentStates=[],this._normalState={}):e?this.currentStates.push(t):this.currentStates=[t],this._updateAnimationTargets(),this.markRedraw(),!l&&this.__inHover&&(this._toggleHoverLayerFlag(!1),this.__dirty&=-2),s}vn("State "+t+" not exists.")}}},t.prototype.useStates=function(t,e,n){if(t.length){var i=[],r=this.currentStates,o=t.length,a=o===r.length;if(a)for(var s=0;s0,p);var f=this._textContent,g=this._textGuide;f&&f.useStates(t,e,h),g&&g.useStates(t,e,h),this._updateAnimationTargets(),this.currentStates=t.slice(),this.markRedraw(),!h&&this.__inHover&&(this._toggleHoverLayerFlag(!1),this.__dirty&=-2)}else this.clearStates()},t.prototype.isSilent=function(){for(var t=this;t;){if(t.silent)return!0;var e=t.__hostTarget;t=e?t.ignoreHostSilent?null:e:t.parent}return!1},t.prototype._updateAnimationTargets=function(){for(var t=0;t=0){var n=this.currentStates.slice();n.splice(e,1),this.useStates(n)}},t.prototype.replaceState=function(t,e,n){var i=this.currentStates.slice(),r=xn(i,t),o=xn(i,e)>=0;r>=0?o?i.splice(r,1):i[r]=e:n&&!o&&i.push(e),this.useStates(i)},t.prototype.toggleState=function(t,e){e?this.useState(t,!0):this.removeState(t)},t.prototype._mergeStates=function(t){for(var e,n={},i=0;i=0&&e.splice(n,1)}),this.animators.push(t),n&&n.animation.addAnimator(t),n&&n.wakeUp()},t.prototype.updateDuringAnimation=function(t){this.markRedraw()},t.prototype.stopAnimation=function(t,e){for(var n=this.animators,i=n.length,r=[],o=0;o0&&n.during&&o[0].during(function(t,e){n.during(e)});for(var d=0;d0||r.force&&!a.length){var w,S=void 0,C=void 0,k=void 0;if(s){C={},d&&(S={});for(b=0;b<_;b++){C[m=g[b]]=n[m],d?S[m]=i[m]:n[m]=i[m]}}else if(d){k={};for(b=0;b<_;b++){k[m=g[b]]=ta(n[m]),us(n,i,m)}}(w=new aa(n,!1,!1,h?Tn(f,function(t){return t.targetName===e}):null)).targetName=e,r.scope&&(w.scope=r.scope),d&&S&&w.whenWithKeys(0,S,g),k&&w.whenWithKeys(0,k,g),w.whenWithKeys(null==c?500:c,s?C:i,g).delay(u||0),t.addAnimator(w,e),a.push(w)}}wn(ss,bi),wn(ss,Fa);var ds=function(t){function e(e){var n=t.call(this)||this;return n.isGroup=!0,n._children=[],n.attr(e),n}return y(e,t),e.prototype.childrenRef=function(){return this._children},e.prototype.children=function(){return this._children.slice()},e.prototype.childAt=function(t){return this._children[t]},e.prototype.childOfName=function(t){for(var e=this._children,n=0;n=0&&(n.splice(i,0,t),this._doAdd(t))}return this},e.prototype.replace=function(t,e){var n=xn(this._children,t);return n>=0&&this.replaceAt(e,n),this},e.prototype.replaceAt=function(t,e){var n=this._children,i=n[e];if(t&&t!==this&&t.parent!==this&&t!==i){n[e]=t,i.parent=null;var r=this.__zr;r&&i.removeSelfFromZr(r),this._doAdd(t)}return this},e.prototype._doAdd=function(t){t.parent&&t.parent.remove(t),t.parent=this;var e=this.__zr;e&&e!==t.__zr&&t.addSelfToZr(e),e&&e.refresh()},e.prototype.remove=function(t){var e=this.__zr,n=this._children,i=xn(n,t);return i<0||(n.splice(i,1),t.parent=null,e&&t.removeSelfFromZr(e),e&&e.refresh()),this},e.prototype.removeAll=function(){for(var t=this._children,e=this.__zr,n=0;n0&&(this._stillFrameAccum++,this._stillFrameAccum>this._sleepAfterStill&&this.animation.stop())},t.prototype.setSleepAfterStill=function(t){this._sleepAfterStill=t},t.prototype.wakeUp=function(){this._disposed||(this.animation.start(),this._stillFrameAccum=0)},t.prototype.refreshHover=function(){this._needsRefreshHover=!0},t.prototype.refreshHoverImmediately=function(){this._disposed||(this._needsRefreshHover=!1,this.painter.refreshHover&&"canvas"===this.painter.getType()&&this.painter.refreshHover())},t.prototype.resize=function(t){this._disposed||(t=t||{},this.painter.resize(t.width,t.height),this.handler.resize())},t.prototype.clearAnimation=function(){this._disposed||this.animation.clear()},t.prototype.getWidth=function(){if(!this._disposed)return this.painter.getWidth()},t.prototype.getHeight=function(){if(!this._disposed)return this.painter.getHeight()},t.prototype.setCursorStyle=function(t){this._disposed||this.handler.setCursorStyle(t)},t.prototype.findHover=function(t,e){if(!this._disposed)return this.handler.findHover(t,e)},t.prototype.on=function(t,e,n){return this._disposed||this.handler.on(t,e,n),this},t.prototype.off=function(t,e){this._disposed||this.handler.off(t,e)},t.prototype.trigger=function(t,e){this._disposed||this.handler.trigger(t,e)},t.prototype.clear=function(){if(!this._disposed){for(var t=this.storage.getRoots(),e=0;e0){if(t<=r)return a;if(t>=o)return s}else{if(t>=r)return a;if(t<=o)return s}else{if(t===r)return a;if(t===o)return s}return(t-r)/l*c+a}var Ss=function(t,e,n){switch(t){case"center":case"middle":t="50%";break;case"left":case"top":t="0%";break;case"right":case"bottom":t="100%"}return Cs(t,e,n)};function Cs(t,e,n){return En(t)?(i=t,i.replace(/^\s+|\s+$/g,"")).match(/%$/)?parseFloat(t)/100*e+(n||0):parseFloat(t):null==t?NaN:+t;var i}function ks(t,e,n){return null==e&&(e=10),e=Math.min(Math.max(0,e),20),t=(+t).toFixed(e),n?t:+t}function Ms(t){if(t=+t,isNaN(t))return 0;if(t>1e-14)for(var e=1,n=0;n<15;n++,e*=10)if(Math.round(t*e)/e===t)return n;return function(t){var e=t.toString().toLowerCase(),n=e.indexOf("e"),i=n>0?+e.slice(n+1):0,r=n>0?n:e.length,o=e.indexOf("."),a=o<0?0:r-1-o;return Math.max(0,a-i)}(t)}function Ts(t,e){var n=Math.max(Ms(t),Ms(e)),i=t+e;return n>20?i:ks(i,n)}function Is(t){var e=2*Math.PI;return(t%e+e)%e}function Ds(t){return t>-1e-4&&t=10&&e++,e}function Es(t,e){var n=Ls(t),i=Math.pow(10,n),r=t/i;return t=(r<1.5?1:r<2.5?2:r<4?3:r<7?5:10)*i,n>=-20?+t.toFixed(n<0?-n:0):t}function zs(t){var e=parseFloat(t);return e==t&&(0!==e||!En(t)||t.indexOf("x")<=0)?e:NaN}function Ns(){return Math.round(9*Math.random())}function Os(t,e){return 0===e?t:Os(e,t%e)}function Rs(t,e){return null==t?e:null==e?t:t*e/Os(t,e)}var $s="undefined"!=typeof console&&console.warn&&console.log;function Hs(t,e){!function(t,e){$s&&console[t]("[ECharts] "+e)}("error",t)}function Fs(t){throw new Error(t)}function Bs(t,e,n){return(e-t)*n+t}var Vs="series\0";function Ws(t){return t instanceof Array?t:null==t?[]:[t]}function Us(t,e,n){if(t){t[e]=t[e]||{},t.emphasis=t.emphasis||{},t.emphasis[e]=t.emphasis[e]||{};for(var i=0,r=n.length;i=0||r&&xn(r,s)<0)){var l=n.getShallow(s,e);null!=l&&(o[t[a][0]]=l)}}return o}}var yl=ml([["fill","color"],["shadowBlur"],["shadowOffsetX"],["shadowOffsetY"],["opacity"],["shadowColor"]]),_l=function(){function t(){}return t.prototype.getAreaStyle=function(t,e){return yl(this,t,e)},t}(),bl=new co(50);function xl(t){if("string"==typeof t){var e=bl.get(t);return e&&e.image}return t}function wl(t,e,n,i,r){if(t){if("string"==typeof t){if(e&&e.__zrImageSrc===t||!n)return e;var o=bl.get(t),a={hostEl:n,cb:i,cbPayload:r};return o?!Cl(e=o.image)&&o.pending.push(a):((e=en.loadImage(t,Sl,Sl)).__zrImageSrc=t,bl.put(t,e.__cachedImgObj={image:e,pending:[a]})),e}return t}return e}function Sl(){var t=this.__cachedImgObj;this.onload=this.onerror=this.__cachedImgObj=null;for(var e=0;e=s;c++)l-=s;var u=ja(a,n);return u>l&&(n="",u=0),l=t-u,r.ellipsis=n,r.ellipsisWidth=u,r.contentWidth=l,r.containerWidth=t,r}function Il(t,e,n){var i=n.containerWidth,r=n.contentWidth,o=n.fontMeasureInfo;if(!i)return t.textLine="",void(t.isTruncated=!1);var a=ja(o,e);if(a<=i)return t.textLine=e,void(t.isTruncated=!1);for(var s=0;;s++){if(a<=r||s>=n.maxIterations){e+=n.ellipsis;break}var l=0===s?Dl(e,r,o):a>0?Math.floor(e.length*r/a):0;a=ja(o,e=e.substr(0,l))}""===e&&(e=n.placeholder),t.textLine=e,t.isTruncated=!0}function Dl(t,e,n){for(var i=0,r=0,o=t.length;r0&&f+i.accumWidth>i.width&&(o=e.split("\n"),h=!0),i.accumWidth=f}else{var g=Ol(e,u,i.width,i.breakAll,i.accumWidth);i.accumWidth=g.accumWidth+p,a=g.linesWidths,o=g.lines}}o||(o=e.split("\n"));for(var v=Wa(u),m=0;m=32&&e<=591||e>=880&&e<=4351||e>=4608&&e<=5119||e>=7680&&e<=8303}(t)||!!zl[t]}function Ol(t,e,n,i,r){for(var o=[],a=[],s="",l="",c=0,u=0,h=Wa(e),d=0;dn:r+u+f>n)?u?(s||l)&&(g?(s||(s=l,l="",u=c=0),o.push(s),a.push(u-c),l+=p,s="",u=c+=f):(l&&(s+=l,l="",c=0),o.push(s),a.push(u),s=p,u=f)):g?(o.push(l),a.push(c),l=p,c=f):(o.push(p),a.push(f)):(u+=f,g?(l+=p,c+=f):(l&&(s+=l,l="",c=0),s+=p))}else l&&(s+=l,u+=c),o.push(s),a.push(u),s="",l="",c=0,u=0}return l&&(s+=l),s&&(o.push(s),a.push(u)),1===o.length&&(u+=r),{accumWidth:u,lines:o,linesWidths:a}}function Rl(t,e,n,i,r,o){if(t.baseX=n,t.baseY=i,t.outerWidth=t.outerHeight=null,e){var a=2*e.width,s=2*e.height;cr.set($l,Za(n,a,r),Ka(i,s,o),a,s),cr.intersect(e,$l,null,Hl);var l=Hl.outIntersectRect;t.outerWidth=l.width,t.outerHeight=l.height,t.baseX=Za(l.x,l.width,r,!0),t.baseY=Ka(l.y,l.height,o,!0)}}var $l=new cr(0,0,0,0),Hl={outIntersectRect:{},clamp:!0};function Fl(t){return null!=t?t+="":t=""}function Bl(t,e,n,i){var r=new cr(Za(t.x||0,e,t.textAlign),Ka(t.y||0,n,t.textBaseline),e,n),o=null!=i?i:Vl(t)?t.lineWidth:0;return o>0&&(r.x-=o/2,r.y-=o/2,r.width+=o,r.height+=o),r}function Vl(t){var e=t.stroke;return null!=e&&"none"!==e&&t.lineWidth>0}var Wl="__zr_style_"+Math.round(10*Math.random()),Ul={shadowBlur:0,shadowOffsetX:0,shadowOffsetY:0,shadowColor:"#000",opacity:1,blend:"source-over"},Gl={style:{shadowBlur:!0,shadowOffsetX:!0,shadowOffsetY:!0,shadowColor:!0,opacity:!0}};Ul[Wl]=!0;var ql=["z","z2","invisible"],jl=["invisible"],Xl=function(t){function e(e){return t.call(this,e)||this}var n;return y(e,t),e.prototype._init=function(e){for(var n=In(e),i=0;i1e-4)return s[0]=t-n,s[1]=e-i,l[0]=t+n,void(l[1]=e+i);if(nc[0]=tc(r)*n+t,nc[1]=Jl(r)*i+e,ic[0]=tc(o)*n+t,ic[1]=Jl(o)*i+e,c(s,nc,ic),u(l,nc,ic),(r%=ec)<0&&(r+=ec),(o%=ec)<0&&(o+=ec),r>o&&!a?o+=ec:rr&&(rc[0]=tc(p)*n+t,rc[1]=Jl(p)*i+e,c(s,rc,s),u(l,rc,l))}var hc={M:1,L:2,C:3,Q:4,A:5,Z:6,R:7},dc=[],pc=[],fc=[],gc=[],vc=[],mc=[],yc=Math.min,_c=Math.max,bc=Math.cos,xc=Math.sin,wc=Math.abs,Sc=Math.PI,Cc=2*Sc,kc="undefined"!=typeof Float32Array,Mc=[];function Tc(t){return Math.round(t/Sc*1e8)/1e8%2*Sc}var Ic=function(){function t(t){this.dpr=1,this._xi=0,this._yi=0,this._x0=0,this._y0=0,this._len=0,t&&(this._saveData=!1),this._saveData&&(this.data=[])}var e;return t.prototype.increaseVersion=function(){this._version++},t.prototype.getVersion=function(){return this._version},t.prototype.setScale=function(t,e,n){(n=n||0)>0&&(this._ux=wc(n/Da/t)||0,this._uy=wc(n/Da/e)||0)},t.prototype.setDPR=function(t){this.dpr=t},t.prototype.setContext=function(t){this._ctx=t},t.prototype.getContext=function(){return this._ctx},t.prototype.beginPath=function(){return this._ctx&&this._ctx.beginPath(),this.reset(),this},t.prototype.reset=function(){this._saveData&&(this._len=0),this._pathSegLen&&(this._pathSegLen=null,this._pathLen=0),this._version++},t.prototype.moveTo=function(t,e){return this._drawPendingPt(),this.addData(hc.M,t,e),this._ctx&&this._ctx.moveTo(t,e),this._x0=t,this._y0=e,this._xi=t,this._yi=e,this},t.prototype.lineTo=function(t,e){var n=wc(t-this._xi),i=wc(e-this._yi),r=n>this._ux||i>this._uy;if(this.addData(hc.L,t,e),this._ctx&&r&&this._ctx.lineTo(t,e),r)this._xi=t,this._yi=e,this._pendingPtDist=0;else{var o=n*n+i*i;o>this._pendingPtDist&&(this._pendingPtX=t,this._pendingPtY=e,this._pendingPtDist=o)}return this},t.prototype.bezierCurveTo=function(t,e,n,i,r,o){return this._drawPendingPt(),this.addData(hc.C,t,e,n,i,r,o),this._ctx&&this._ctx.bezierCurveTo(t,e,n,i,r,o),this._xi=r,this._yi=o,this},t.prototype.quadraticCurveTo=function(t,e,n,i){return this._drawPendingPt(),this.addData(hc.Q,t,e,n,i),this._ctx&&this._ctx.quadraticCurveTo(t,e,n,i),this._xi=n,this._yi=i,this},t.prototype.arc=function(t,e,n,i,r,o){this._drawPendingPt(),Mc[0]=i,Mc[1]=r,function(t,e){var n=Tc(t[0]);n<0&&(n+=Cc);var i=n-t[0],r=t[1];r+=i,!e&&r-n>=Cc?r=n+Cc:e&&n-r>=Cc?r=n-Cc:!e&&n>r?r=n+(Cc-Tc(n-r)):e&&n0&&o))for(var a=0;ac.length&&(this._expandData(),c=this.data);for(var u=0;u0&&(this._ctx&&this._ctx.lineTo(this._pendingPtX,this._pendingPtY),this._pendingPtDist=0)},t.prototype._expandData=function(){if(!(this.data instanceof Array)){for(var t=[],e=0;e11&&(this.data=new Float32Array(t)))}},t.prototype.getBoundingRect=function(){fc[0]=fc[1]=vc[0]=vc[1]=Number.MAX_VALUE,gc[0]=gc[1]=mc[0]=mc[1]=-Number.MAX_VALUE;var t,e=this.data,n=0,i=0,r=0,o=0;for(t=0;tn||wc(v)>i||h===e-1)&&(f=Math.sqrt(D*D+v*v),r=g,o=_);break;case hc.C:var m=t[h++],y=t[h++],_=(g=t[h++],t[h++]),b=t[h++],x=t[h++];f=Qr(r,o,m,y,g,_,b,x,10),r=b,o=x;break;case hc.Q:f=io(r,o,m=t[h++],y=t[h++],g=t[h++],_=t[h++],10),r=g,o=_;break;case hc.A:var w=t[h++],S=t[h++],C=t[h++],k=t[h++],M=t[h++],T=t[h++],I=T+M;h+=1,p&&(a=bc(M)*C+w,s=xc(M)*k+S),f=_c(C,k)*yc(Cc,Math.abs(T)),r=bc(I)*C+w,o=xc(I)*k+S;break;case hc.R:a=r=t[h++],s=o=t[h++],f=2*t[h++]+2*t[h++];break;case hc.Z:var D=a-r;v=s-o;f=Math.sqrt(D*D+v*v),r=a,o=s}f>=0&&(l[u++]=f,c+=f)}return this._pathLen=c,c},t.prototype.rebuildPath=function(t,e){var n,i,r,o,a,s,l,c,u,h,d=this.data,p=this._ux,f=this._uy,g=this._len,v=e<1,m=0,y=0,_=0;if(!v||(this._pathSegLen||this._calculateLength(),l=this._pathSegLen,c=e*this._pathLen))t:for(var b=0;b0&&(t.lineTo(u,h),_=0),x){case hc.M:n=r=d[b++],i=o=d[b++],t.moveTo(r,o);break;case hc.L:a=d[b++],s=d[b++];var S=wc(a-r),C=wc(s-o);if(S>p||C>f){if(v){if(m+(X=l[y++])>c){var k=(c-m)/X;t.lineTo(r*(1-k)+a*k,o*(1-k)+s*k);break t}m+=X}t.lineTo(a,s),r=a,o=s,_=0}else{var M=S*S+C*C;M>_&&(u=a,h=s,_=M)}break;case hc.C:var T=d[b++],I=d[b++],D=d[b++],A=d[b++],P=d[b++],L=d[b++];if(v){if(m+(X=l[y++])>c){Kr(r,T,D,P,k=(c-m)/X,dc),Kr(o,I,A,L,k,pc),t.bezierCurveTo(dc[1],pc[1],dc[2],pc[2],dc[3],pc[3]);break t}m+=X}t.bezierCurveTo(T,I,D,A,P,L),r=P,o=L;break;case hc.Q:T=d[b++],I=d[b++],D=d[b++],A=d[b++];if(v){if(m+(X=l[y++])>c){no(r,T,D,k=(c-m)/X,dc),no(o,I,A,k,pc),t.quadraticCurveTo(dc[1],pc[1],dc[2],pc[2]);break t}m+=X}t.quadraticCurveTo(T,I,D,A),r=D,o=A;break;case hc.A:var E=d[b++],z=d[b++],N=d[b++],O=d[b++],R=d[b++],$=d[b++],H=d[b++],F=!d[b++],B=N>O?N:O,V=wc(N-O)>.001,W=R+$,U=!1;if(v)m+(X=l[y++])>c&&(W=R+$*(c-m)/X,U=!0),m+=X;if(V&&t.ellipse?t.ellipse(E,z,N,O,H,R,W,F):t.arc(E,z,B,R,W,F),U)break t;w&&(n=bc(R)*N+E,i=xc(R)*O+z),r=bc(W)*N+E,o=xc(W)*O+z;break;case hc.R:n=r=d[b],i=o=d[b+1],a=d[b++],s=d[b++];var G=d[b++],q=d[b++];if(v){if(m+(X=l[y++])>c){var j=c-m;t.moveTo(a,s),t.lineTo(a+yc(j,G),s),(j-=G)>0&&t.lineTo(a+G,s+yc(j,q)),(j-=q)>0&&t.lineTo(a+_c(G-j,0),s+q),(j-=G)>0&&t.lineTo(a,s+_c(q-j,0));break t}m+=X}t.rect(a,s,G,q);break;case hc.Z:if(v){var X;if(m+(X=l[y++])>c){k=(c-m)/X;t.lineTo(r*(1-k)+n*k,o*(1-k)+i*k);break t}m+=X}t.closePath(),r=n,o=i}}},t.prototype.clone=function(){var e=new t,n=this.data;return e.data=n.slice?n.slice():Array.prototype.slice.call(n),e._len=this._len,e},t.prototype.canSave=function(){return!!this._saveData},t.CMD=hc,t.initDefaultProps=((e=t.prototype)._saveData=!0,e._ux=0,e._uy=0,e._pendingPtDist=0,void(e._version=0)),t}();function Dc(t,e,n,i,r,o,a){if(0===r)return!1;var s=r,l=0;if(a>e+s&&a>i+s||at+s&&o>n+s||oe+h&&u>i+h&&u>o+h&&u>s+h||ut+h&&c>n+h&&c>r+h&&c>a+h||c=0&&pe+c&&l>i+c&&l>o+c||lt+c&&s>n+c&&s>r+c||s=0&&gn||u+cr&&(r+=zc);var d=Math.atan2(l,s);return d<0&&(d+=zc),d>=i&&d<=r||d+zc>=i&&d+zc<=r}function Oc(t,e,n,i,r,o){if(o>e&&o>i||or?s:0}var Rc=Ic.CMD,$c=2*Math.PI;var Hc=[-1,-1,-1],Fc=[-1,-1];function Bc(){var t=Fc[0];Fc[0]=Fc[1],Fc[1]=t}function Vc(t,e,n,i,r,o,a,s,l,c){if(c>e&&c>i&&c>o&&c>s||c1&&Bc(),p=jr(e,i,o,s,Fc[0]),d>1&&(f=jr(e,i,o,s,Fc[1]))),2===d?ve&&s>i&&s>o||s=0&&u<=1&&(r[l++]=u);else{var c=a*a-4*o*s;if(Gr(c))(u=-a/(2*o))>=0&&u<=1&&(r[l++]=u);else if(c>0){var u,h=Rr(c),d=(-a-h)/(2*o);(u=(-a+h)/(2*o))>=0&&u<=1&&(r[l++]=u),d>=0&&d<=1&&(r[l++]=d)}}return l}(e,i,o,s,Hc);if(0===l)return 0;var c=eo(e,i,o);if(c>=0&&c<=1){for(var u=0,h=Jr(e,i,o,c),d=0;dn||s<-n)return 0;var l=Math.sqrt(n*n-s*s);Hc[0]=-l,Hc[1]=l;var c=Math.abs(i-r);if(c<1e-4)return 0;if(c>=$c-1e-4){i=0,r=$c;var u=o?1:-1;return a>=Hc[0]+t&&a<=Hc[1]+t?u:0}if(i>r){var h=i;i=r,r=h}i<0&&(i+=$c,r+=$c);for(var d=0,p=0;p<2;p++){var f=Hc[p];if(f+t>a){var g=Math.atan2(s,f);u=o?1:-1;g<0&&(g=$c+g),(g>=i&&g<=r||g+$c>=i&&g+$c<=r)&&(g>Math.PI/2&&g<1.5*Math.PI&&(u=-u),d+=u)}}return d}function Gc(t,e,n,i,r){for(var o,a,s=t.data,l=t.len(),c=0,u=0,h=0,d=0,p=0,f=0;f1&&(n||(c+=Oc(u,h,d,p,i,r))),v&&(d=u=s[f],p=h=s[f+1]),g){case Rc.M:u=d=s[f++],h=p=s[f++];break;case Rc.L:if(n){if(Dc(u,h,s[f],s[f+1],e,i,r))return!0}else c+=Oc(u,h,s[f],s[f+1],i,r)||0;u=s[f++],h=s[f++];break;case Rc.C:if(n){if(Ac(u,h,s[f++],s[f++],s[f++],s[f++],s[f],s[f+1],e,i,r))return!0}else c+=Vc(u,h,s[f++],s[f++],s[f++],s[f++],s[f],s[f+1],i,r)||0;u=s[f++],h=s[f++];break;case Rc.Q:if(n){if(Pc(u,h,s[f++],s[f++],s[f],s[f+1],e,i,r))return!0}else c+=Wc(u,h,s[f++],s[f++],s[f],s[f+1],i,r)||0;u=s[f++],h=s[f++];break;case Rc.A:var m=s[f++],y=s[f++],_=s[f++],b=s[f++],x=s[f++],w=s[f++];f+=1;var S=!!(1-s[f++]);o=Math.cos(x)*_+m,a=Math.sin(x)*b+y,v?(d=o,p=a):c+=Oc(u,h,o,a,i,r);var C=(i-m)*b/_+m;if(n){if(Nc(m,y,b,x,x+w,S,e,C,r))return!0}else c+=Uc(m,y,b,x,x+w,S,C,r);u=Math.cos(x+w)*_+m,h=Math.sin(x+w)*b+y;break;case Rc.R:if(d=u=s[f++],p=h=s[f++],o=d+s[f++],a=p+s[f++],n){if(Dc(d,p,o,p,e,i,r)||Dc(o,p,o,a,e,i,r)||Dc(o,a,d,a,e,i,r)||Dc(d,a,d,p,e,i,r))return!0}else c+=Oc(o,p,o,a,i,r),c+=Oc(d,a,d,p,i,r);break;case Rc.Z:if(n){if(Dc(u,h,d,p,e,i,r))return!0}else c+=Oc(u,h,d,p,i,r);u=d,h=p}}return n||function(t,e){return Math.abs(t-e)<1e-4}(h,p)||(c+=Oc(u,h,d,p,i,r)||0),0!==c}var qc=bn({fill:"#000",stroke:null,strokePercent:1,fillOpacity:1,strokeOpacity:1,lineDashOffset:0,lineWidth:1,lineCap:"butt",miterLimit:10,strokeNoScale:!1,strokeFirst:!1},Ul),jc={style:bn({fill:!0,stroke:!0,strokePercent:!0,fillOpacity:!0,strokeOpacity:!0,lineDashOffset:!0,lineWidth:!0,miterLimit:!0},Gl.style)},Xc=Ba.concat(["invisible","culling","z","z2","zlevel","parent"]),Yc=function(t){function e(e){return t.call(this,e)||this}var n;return y(e,t),e.prototype.update=function(){var n=this;t.prototype.update.call(this);var i=this.style;if(i.decal){var r=this._decalEl=this._decalEl||new e;r.buildPath===e.prototype.buildPath&&(r.buildPath=function(t){n.buildPath(t,n.shape)}),r.silent=!0;var o=r.style;for(var a in i)o[a]!==i[a]&&(o[a]=i[a]);o.fill=i.fill?i.decal:null,o.decal=null,o.shadowColor=null,i.strokeFirst&&(o.stroke=null);for(var s=0;s.5?Aa:e>.2?"#eee":Pa}if(t)return Pa}return Aa},e.prototype.getInsideTextStroke=function(t){var e=this.style.fill;if(En(e)){var n=this.__zr;if(!(!n||!n.isDarkMode())===Io(t,0)<.4)return e}},e.prototype.buildPath=function(t,e,n){},e.prototype.pathUpdated=function(){this.__dirty&=-5},e.prototype.getUpdatedPathProxy=function(t){return!this.path&&this.createPathProxy(),this.path.beginPath(),this.buildPath(this.path,this.shape,t),this.path},e.prototype.createPathProxy=function(){this.path=new Ic(!1)},e.prototype.hasStroke=function(){var t=this.style,e=t.stroke;return!(null==e||"none"===e||!(t.lineWidth>0))},e.prototype.hasFill=function(){var t=this.style.fill;return null!=t&&"none"!==t},e.prototype.getBoundingRect=function(){var t=this._rect,e=this.style,n=!t;if(n){var i=!1;this.path||(i=!0,this.createPathProxy());var r=this.path;(i||4&this.__dirty)&&(r.beginPath(),this.buildPath(r,this.shape,!1),this.pathUpdated()),t=r.getBoundingRect()}if(this._rect=t,this.hasStroke()&&this.path&&this.path.len()>0){var o=this._rectStroke||(this._rectStroke=t.clone());if(this.__dirty||n){o.copy(t);var a=e.strokeNoScale?this.getLineScale():1,s=e.lineWidth;if(!this.hasFill()){var l=this.strokeContainThreshold;s=Math.max(s,null==l?4:l)}a>1e-10&&(o.width+=s/a,o.height+=s/a,o.x-=s/a/2,o.y-=s/a/2)}return o}return t},e.prototype.contain=function(t,e){var n=this.transformCoordToLocal(t,e),i=this.getBoundingRect(),r=this.style;if(t=n[0],e=n[1],i.contain(t,e)){var o=this.path;if(this.hasStroke()){var a=r.lineWidth,s=r.strokeNoScale?this.getLineScale():1;if(s>1e-10&&(this.hasFill()||(a=Math.max(a,this.strokeContainThreshold)),function(t,e,n,i){return Gc(t,e,!0,n,i)}(o,a/s,t,e)))return!0}if(this.hasFill())return function(t,e,n){return Gc(t,0,!1,e,n)}(o,t,e)}return!1},e.prototype.dirtyShape=function(){this.__dirty|=4,this._rect&&(this._rect=null),this._decalEl&&this._decalEl.dirtyShape(),this.markRedraw()},e.prototype.dirty=function(){this.dirtyStyle(),this.dirtyShape()},e.prototype.animateShape=function(t){return this.animate("shape",t)},e.prototype.updateDuringAnimation=function(t){"style"===t?this.dirtyStyle():"shape"===t?this.dirtyShape():this.markRedraw()},e.prototype.attrKV=function(e,n){"shape"===e?this.setShape(n):t.prototype.attrKV.call(this,e,n)},e.prototype.setShape=function(t,e){var n=this.shape;return n||(n=this.shape={}),"string"==typeof t?n[t]=e:_n(n,t),this.dirtyShape(),this},e.prototype.shapeChanged=function(){return!!(4&this.__dirty)},e.prototype.createStyle=function(t){return ni(qc,t)},e.prototype._innerSaveToNormal=function(e){t.prototype._innerSaveToNormal.call(this,e);var n=this._normalState;e.shape&&!n.shape&&(n.shape=_n({},this.shape))},e.prototype._applyStateObj=function(e,n,i,r,o,a){t.prototype._applyStateObj.call(this,e,n,i,r,o,a);var s,l=!(n&&r);if(n&&n.shape?o?r?s=n.shape:(s=_n({},i.shape),_n(s,n.shape)):(s=_n({},r?this.shape:i.shape),_n(s,n.shape)):l&&(s=i.shape),s)if(o){this.shape=_n({},this.shape);for(var c={},u=In(s),h=0;hc&&(n*=c/(a=n+i),i*=c/a),r+o>c&&(r*=c/(a=r+o),o*=c/a),i+r>u&&(i*=u/(a=i+r),r*=u/a),n+o>u&&(n*=u/(a=n+o),o*=u/a),t.moveTo(s+n,l),t.lineTo(s+c-i,l),0!==i&&t.arc(s+c-i,l+i,i,-Math.PI/2,0),t.lineTo(s+c,l+u-r),0!==r&&t.arc(s+c-r,l+u-r,r,0,Math.PI/2),t.lineTo(s+o,l+u),0!==o&&t.arc(s+o,l+u-o,o,Math.PI/2,Math.PI),t.lineTo(s,l+n),0!==n&&t.arc(s+n,l+n,n,Math.PI,1.5*Math.PI)}(t,e):t.rect(n,i,r,o)},e.prototype.isZeroArea=function(){return!this.shape.width||!this.shape.height},e}(Yc);su.prototype.type="rect";var lu={fill:"#000"},cu={},uu={style:bn({fill:!0,stroke:!0,fillOpacity:!0,strokeOpacity:!0,lineWidth:!0,fontSize:!0,lineHeight:!0,width:!0,height:!0,textShadowColor:!0,textShadowBlur:!0,textShadowOffsetX:!0,textShadowOffsetY:!0,backgroundColor:!0,padding:!0,borderColor:!0,borderWidth:!0,borderRadius:!0},Gl.style)},hu=function(t){function e(e){var n=t.call(this)||this;return n.type="text",n._children=[],n._defaultStyle=lu,n.attr(e),n}return y(e,t),e.prototype.childrenRef=function(){return this._children},e.prototype.update=function(){t.prototype.update.call(this),this.styleChanged()&&this._updateSubTexts();for(var e=0;em&&p){var _=Math.floor(m/d);f=f||v.length>_,y=(v=v.slice(0,_)).length*d}if(r&&u&&null!=g)for(var b=Tl(g,c,e.ellipsis,{minChar:e.truncateMinChar,placeholder:e.placeholder}),x={},w=0;w0,k=0;kg&&El(o,a.substring(g,v),e,f),El(o,d[2],e,f,d[1]),g=kl.lastIndex}gh){var z=o.lines.length;I>0?(k.tokens=k.tokens.slice(0,I),S(k,T,M),o.lines=o.lines.slice(0,C+1)):o.lines=o.lines.slice(0,C),o.isTruncated=o.isTruncated||o.lines.length=0&&"right"===(T=_[M]).align;)this._placeToken(T,t,x,f,k,"right",v),w-=T.width,k-=T.width,M--;for(C+=(s-(C-p)-(g-k)-w)/2;S<=M;)T=_[S],this._placeToken(T,t,x,f,C+T.width/2,"center",v),C+=T.width,S++;f+=x}},e.prototype._placeToken=function(t,e,n,i,r,o,a){var s=e.rich[t.styleName]||{};s.text=t.text;var l=t.verticalAlign,c=i+n/2;"top"===l?c=i+t.height/2:"bottom"===l&&(c=i+n-t.height/2),!t.isLineHolder&&Su(s)&&this._renderBackground(s,e,"right"===o?r-t.width:"center"===o?r-t.width/2:r,c-t.height/2,t.width,t.height);var u=!!s.backgroundColor,h=t.textPadding;h&&(r=xu(r,o,h),c-=t.height/2-h[0]-t.innerHeight/2);var d=this._getOrCreateChild(Kc),p=d.createStyle();d.useStyle(p);var f=this._defaultStyle,g=!1,v=0,m=!1,y=bu("fill"in s?s.fill:"fill"in e?e.fill:(g=!0,f.fill)),_=_u("stroke"in s?s.stroke:"stroke"in e?e.stroke:u||a||f.autoStroke&&!g?null:(v=2,m=!0,f.stroke)),b=s.textShadowBlur>0||e.textShadowBlur>0;p.text=t.text,p.x=r,p.y=c,b&&(p.shadowBlur=s.textShadowBlur||e.textShadowBlur||0,p.shadowColor=s.textShadowColor||e.textShadowColor||"transparent",p.shadowOffsetX=s.textShadowOffsetX||e.textShadowOffsetX||0,p.shadowOffsetY=s.textShadowOffsetY||e.textShadowOffsetY||0),p.textAlign=o,p.textBaseline="middle",p.font=t.font||Ke,p.opacity=Un(s.opacity,e.opacity,1),vu(p,s),_&&(p.lineWidth=Un(s.lineWidth,e.lineWidth,v),p.lineDash=Wn(s.lineDash,e.lineDash),p.lineDashOffset=e.lineDashOffset||0,p.stroke=_),y&&(p.fill=y),d.setBoundingRect(Bl(p,t.contentWidth,t.contentHeight,m?0:null))},e.prototype._renderBackground=function(t,e,n,i,r,o){var a,s,l,c=t.backgroundColor,u=t.borderWidth,h=t.borderColor,d=c&&c.image,p=c&&!d,f=t.borderRadius,g=this;if(p||t.lineHeight||u&&h){(a=this._getOrCreateChild(su)).useStyle(a.createStyle()),a.style.fill=null;var v=a.shape;v.x=n,v.y=i,v.width=r,v.height=o,v.r=f,a.dirtyShape()}if(p)(l=a.style).fill=c||null,l.fillOpacity=Wn(t.fillOpacity,1);else if(d){(s=this._getOrCreateChild(tu)).onload=function(){g.dirtyStyle()};var m=s.style;m.image=c.image,m.x=n,m.y=i,m.width=r,m.height=o}u&&h&&((l=a.style).lineWidth=u,l.stroke=h,l.strokeOpacity=Wn(t.strokeOpacity,1),l.lineDash=t.borderDash,l.lineDashOffset=t.borderDashOffset||0,a.strokeContainThreshold=0,a.hasFill()&&a.hasStroke()&&(l.strokeFirst=!0,l.lineWidth*=2));var y=(a||s).style;y.shadowBlur=t.shadowBlur||0,y.shadowColor=t.shadowColor||"transparent",y.shadowOffsetX=t.shadowOffsetX||0,y.shadowOffsetY=t.shadowOffsetY||0,y.opacity=Un(t.opacity,e.opacity,1)},e.makeFont=function(t){var e="";return mu(t)&&(e=[t.fontStyle,t.fontWeight,gu(t.fontSize),t.fontFamily||"sans-serif"].join(" ")),e&&Xn(e)||t.textFont||t.font},e}(Xl),du={left:!0,right:1,center:1},pu={top:1,bottom:1,middle:1},fu=["fontStyle","fontWeight","fontSize","fontFamily"];function gu(t){return"string"!=typeof t||-1===t.indexOf("px")&&-1===t.indexOf("rem")&&-1===t.indexOf("em")?isNaN(+t)?"12px":t+"px":t}function vu(t,e){for(var n=0;n=0,o=!1;if(t instanceof Yc){var a=Tu(t),s=r&&a.selectFill||a.normalFill,l=r&&a.selectStroke||a.normalStroke;if(Ru(s)||Ru(l)){var c=(i=i||{}).style||{};"inherit"===c.fill?(o=!0,i=_n({},i),(c=_n({},c)).fill=s):!Ru(c.fill)&&Ru(s)?(o=!0,i=_n({},i),(c=_n({},c)).fill=Ao(s)):!Ru(c.stroke)&&Ru(l)&&(o||(i=_n({},i),c=_n({},c)),c.stroke=Ao(l)),i.style=c}}if(i&&null==i.z2){o||(i=_n({},i));var u=t.z2EmphasisLift;i.z2=t.z2+(null!=u?u:10)}return i}(this,0,e,n);if("blur"===t)return function(t,e,n){var i=xn(t.currentStates,e)>=0,r=t.style.opacity,o=i?null:function(t,e,n,i){for(var r=t.style,o={},a=0;a0){var o={dataIndex:r,seriesIndex:t.seriesIndex};null!=i&&(o.dataType=i),e.push(o)}})}),e}function uh(t,e,n){gh(t,!0),qu(t,Yu),function(t,e,n){var i=Cu(t);null!=e?(i.focus=e,i.blurScope=n):i.focus&&(i.focus=null)}(t,e,n)}function hh(t,e,n,i){i?function(t){gh(t,!1)}(t):uh(t,e,n)}var dh=["emphasis","blur","select"],ph={itemStyle:"getItemStyle",lineStyle:"getLineStyle",areaStyle:"getAreaStyle"};function fh(t,e,n,i){n=n||"itemStyle";for(var r=0;r1&&(a*=Sh(f),s*=Sh(f));var g=(r===o?-1:1)*Sh((a*a*(s*s)-a*a*(p*p)-s*s*(d*d))/(a*a*(p*p)+s*s*(d*d)))||0,v=g*a*p/s,m=g*-s*d/a,y=(t+n)/2+kh(h)*v-Ch(h)*m,_=(e+i)/2+Ch(h)*v+kh(h)*m,b=Dh([1,0],[(d-v)/a,(p-m)/s]),x=[(d-v)/a,(p-m)/s],w=[(-1*d-v)/a,(-1*p-m)/s],S=Dh(x,w);if(Ih(x,w)<=-1&&(S=Mh),Ih(x,w)>=1&&(S=0),S<0){var C=Math.round(S/Mh*1e6)/1e6;S=2*Mh+C%2*Mh}u.addData(c,y,_,a,s,b,S,h,o)}var Ph=/([mlvhzcqtsa])([^mlvhzcqtsa]*)/gi,Lh=/-?([0-9]*\.)?[0-9]+([eE]-?[0-9]+)?/g;var Eh=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return y(e,t),e.prototype.applyTransform=function(t){},e}(Yc);function zh(t){return null!=t.setData}function Nh(t,e){var n=function(t){var e=new Ic;if(!t)return e;var n,i=0,r=0,o=i,a=r,s=Ic.CMD,l=t.match(Ph);if(!l)return e;for(var c=0;cA*A+P*P&&(C=M,k=T),{cx:C,cy:k,x0:-u,y0:-h,x1:C*(r/x-1),y1:k*(r/x-1)}}function Qh(t,e){var n,i=Xh(e.r,0),r=Xh(e.r0||0,0),o=i>0;if(o||r>0){if(o||(i=r,r=0),r>i){var a=i;i=r,r=a}var s=e.startAngle,l=e.endAngle;if(!isNaN(s)&&!isNaN(l)){var c=e.cx,u=e.cy,h=!!e.clockwise,d=qh(l-s),p=d>Bh&&d%Bh;if(p>Zh&&(d=p),i>Zh)if(d>Bh-Zh)t.moveTo(c+i*Wh(s),u+i*Vh(s)),t.arc(c,u,i,s,l,!h),r>Zh&&(t.moveTo(c+r*Wh(l),u+r*Vh(l)),t.arc(c,u,r,l,s,h));else{var f=void 0,g=void 0,v=void 0,m=void 0,y=void 0,_=void 0,b=void 0,x=void 0,w=void 0,S=void 0,C=void 0,k=void 0,M=void 0,T=void 0,I=void 0,D=void 0,A=i*Wh(s),P=i*Vh(s),L=r*Wh(l),E=r*Vh(l),z=d>Zh;if(z){var N=e.cornerRadius;N&&(n=function(t){var e;if(Pn(t)){var n=t.length;if(!n)return t;e=1===n?[t[0],t[0],0,0]:2===n?[t[0],t[0],t[1],t[1]]:3===n?t.concat(t[2]):t}else e=[t,t,t,t];return e}(N),f=n[0],g=n[1],v=n[2],m=n[3]);var O=qh(i-r)/2;if(y=Yh(O,v),_=Yh(O,m),b=Yh(O,f),x=Yh(O,g),C=w=Xh(y,_),k=S=Xh(b,x),(w>Zh||S>Zh)&&(M=i*Wh(l),T=i*Vh(l),I=r*Wh(s),D=r*Vh(s),dZh){var U=Yh(v,C),G=Yh(m,C),q=Kh(I,D,A,P,i,U,h),j=Kh(M,T,L,E,i,G,h);t.moveTo(c+q.cx+q.x0,u+q.cy+q.y0),C0&&t.arc(c+q.cx,u+q.cy,U,Gh(q.y0,q.x0),Gh(q.y1,q.x1),!h),t.arc(c,u,i,Gh(q.cy+q.y1,q.cx+q.x1),Gh(j.cy+j.y1,j.cx+j.x1),!h),G>0&&t.arc(c+j.cx,u+j.cy,G,Gh(j.y1,j.x1),Gh(j.y0,j.x0),!h))}else t.moveTo(c+A,u+P),t.arc(c,u,i,s,l,!h);else t.moveTo(c+A,u+P);if(r>Zh&&z)if(k>Zh){U=Yh(f,k),q=Kh(L,E,M,T,r,-(G=Yh(g,k)),h),j=Kh(A,P,I,D,r,-U,h);t.lineTo(c+q.cx+q.x0,u+q.cy+q.y0),k0&&t.arc(c+q.cx,u+q.cy,G,Gh(q.y0,q.x0),Gh(q.y1,q.x1),!h),t.arc(c,u,r,Gh(q.cy+q.y1,q.cx+q.x1),Gh(j.cy+j.y1,j.cx+j.x1),h),U>0&&t.arc(c+j.cx,u+j.cy,U,Gh(j.y1,j.x1),Gh(j.y0,j.x0),!h))}else t.lineTo(c+L,u+E),t.arc(c,u,r,l,s,h);else t.lineTo(c+L,u+E)}else t.moveTo(c,u);t.closePath()}}}var Jh=function(){this.cx=0,this.cy=0,this.r0=0,this.r=0,this.startAngle=0,this.endAngle=2*Math.PI,this.clockwise=!0,this.cornerRadius=0},td=function(t){function e(e){return t.call(this,e)||this}return y(e,t),e.prototype.getDefaultShape=function(){return new Jh},e.prototype.buildPath=function(t,e){Qh(t,e)},e.prototype.isZeroArea=function(){return this.shape.startAngle===this.shape.endAngle||this.shape.r===this.shape.r0},e}(Yc);td.prototype.type="sector";var ed=function(){this.cx=0,this.cy=0,this.r=0,this.r0=0},nd=function(t){function e(e){return t.call(this,e)||this}return y(e,t),e.prototype.getDefaultShape=function(){return new ed},e.prototype.buildPath=function(t,e){var n=e.cx,i=e.cy,r=2*Math.PI;t.moveTo(n+e.r,i),t.arc(n,i,e.r,0,r,!1),t.moveTo(n+e.r0,i),t.arc(n,i,e.r0,0,r,!0)},e}(Yc);function id(t,e,n){var i=e.smooth,r=e.points;if(r&&r.length>=2){if(i){var o=function(t,e,n,i){var r,o,a,s,l=[],c=[],u=[],h=[];if(i){a=[1/0,1/0],s=[-1/0,-1/0];for(var d=0,p=t.length;dkd[1]){if(r=!1,Md.negativeSize||n)return r;var s=Sd(kd[0]-Cd[1]),l=Sd(Cd[0]-kd[1]);xd(s,l)>Id.len()&&(s=l||!Md.bidirectional)&&(Yi.scale(Td,a,-l*i),Md.useDir&&Md.calcDirMTV()))}}return r},t.prototype._getProjMinMaxOnAxis=function(t,e,n){for(var i=this._axes[t],r=this._origin,o=e[0].dot(i)+r[t],a=o,s=o,l=1;l0){var h={duration:u.duration,delay:u.delay||0,easing:u.easing,done:o,force:!!o||!!a,setToFinal:!c,scope:t,during:a};l?e.animateFrom(n,h):e.animateTo(n,h)}else e.stopAnimation(),!l&&e.attr(n),a&&a(1),o&&o()}function zd(t,e,n,i,r,o){Ed("update",t,e,n,i,r,o)}function Nd(t,e,n,i,r,o){Ed("enter",t,e,n,i,r,o)}function Od(t){if(!t.__zr)return!0;for(var e=0;e=-1e-6)return!1;var f=t-r,g=e-o,v=np(f,g,c,u)/p;if(v<0||v>1)return!1;var m=np(f,g,h,d)/p;return!(m<0||m>1)}function np(t,e,n,i){return t*i-n*e}function ip(t,e,n,i,r){return null==e||(Nn(e)?rp[0]=rp[1]=rp[2]=rp[3]=e:(rp[0]=e[0],rp[1]=e[1],rp[2]=e[2],rp[3]=e[3]),i&&(rp[0]=bs(0,rp[0]),rp[1]=bs(0,rp[1]),rp[2]=bs(0,rp[2]),rp[3]=bs(0,rp[3])),n&&(rp[0]=-rp[0],rp[1]=-rp[1],rp[2]=-rp[2],rp[3]=-rp[3]),op(t,rp,"x","width",3,1,r&&r[0]||0),op(t,rp,"y","height",0,2,r&&r[1]||0)),t}var rp=[0,0,0,0];function op(t,e,n,i,r,o,a){var s=e[o]+e[r],l=t[i];t[i]+=s,a=bs(0,_s(a,l)),t[i]=0?-e[r]:e[o]>=0?l+e[o]:xs(s)>1e-8?(l-a)*e[r]/s:0):t[n]-=e[r]}function ap(t){var e=t.itemTooltipOption,n=t.componentModel,i=t.itemName,r=En(e)?{formatter:e}:e,o=n.mainType,a=n.componentIndex,s={componentType:o,name:i,$vars:["name"]};s[o+"Index"]=a;var l=t.formatterParamsExtra;l&&Cn(In(l),function(t){ii(s,t)||(s[t]=l[t],s.$vars.push(t))});var c=Cu(t.el);c.componentMainType=o,c.componentIndex=a,c.tooltipConfig={name:i,option:bn({content:i,encodeHTMLContent:!0,formatterParams:s},r)}}function sp(t,e){var n;t.isGroup&&(n=e(t)),n||t.traverse(e)}function lp(t,e){if(t)if(Pn(t))for(var n=0;ne&&(e=i),ie&&(n=e=0),{min:n,max:e}},clipPointsByRect:function(t,e){return kn(t,function(t){var n=t[0];n=bs(n,e.x),n=_s(n,e.x+e.width);var i=t[1];return i=bs(i,e.y),[n,i=_s(i,e.y+e.height)]})},clipRectByRect:function(t,e){var n=bs(t.x,e.x),i=_s(t.x+t.width,e.x+e.width),r=bs(t.y,e.y),o=_s(t.y+t.height,e.y+e.height);if(i>=n&&o>=r)return{x:n,y:r,width:i-n,height:o-r}},createIcon:tp,ensureCopyRect:hp,ensureCopyTransform:dp,expandOrShrinkRect:ip,extendPath:function(t,e){return Vd(t,e)},extendShape:function(t){return Yc.extend(t)},getShapeClass:function(t){if(Hd.hasOwnProperty(t))return Hd[t]},getTransform:function(t,e){for(var n=Wi([]);t&&t!==e;)Gi(n,t.getLocalTransform(),n),t=t.parent;return n},groupTransition:Jd,initProps:Nd,isBoundingRectAxisAligned:cp,isElementRemoved:Od,lineLineIntersect:ep,linePolygonIntersect:function(t,e,n,i,r){for(var o=0,a=r[r.length-1];oxs(o[1])?o[0]>0?"right":"left":o[1]>0?"bottom":"top"},traverseElements:lp,traverseUpdateZ:fp,updateProps:zd}),mp={};function yp(t,e,n){var i,r=t.labelFetcher,o=t.labelDataIndex,a=t.labelDimIndex,s=e.normal;r&&(i=r.getFormattedLabel(o,"normal",null,a,s&&s.get("formatter"),null!=n?{interpolatedValue:n}:null)),null==i&&(i=Ln(t.defaultText)?t.defaultText(o,t,n):t.defaultText);for(var l={normal:i},c=0;c-1?Up:qp;function Zp(t,e){t=t.toUpperCase(),Xp[t]=new Bp(e),jp[t]=e}Zp(Gp,{time:{month:["January","February","March","April","May","June","July","August","September","October","November","December"],monthAbbr:["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"],dayOfWeek:["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"],dayOfWeekAbbr:["Sun","Mon","Tue","Wed","Thu","Fri","Sat"]},legend:{selector:{all:"All",inverse:"Inv"}},toolbox:{brush:{title:{rect:"Box Select",polygon:"Lasso Select",lineX:"Horizontally Select",lineY:"Vertically Select",keep:"Keep Selections",clear:"Clear Selections"}},dataView:{title:"Data View",lang:["Data View","Close","Refresh"]},dataZoom:{title:{zoom:"Zoom",back:"Zoom Reset"}},magicType:{title:{line:"Switch to Line Chart",bar:"Switch to Bar Chart",stack:"Stack",tiled:"Tile"}},restore:{title:"Restore"},saveAsImage:{title:"Save as Image",lang:["Right Click to Save Image"]}},series:{typeNames:{pie:"Pie chart",bar:"Bar chart",line:"Line chart",scatter:"Scatter plot",effectScatter:"Ripple scatter plot",radar:"Radar chart",tree:"Tree",treemap:"Treemap",boxplot:"Boxplot",candlestick:"Candlestick",k:"K line chart",heatmap:"Heat map",map:"Map",parallel:"Parallel coordinate map",lines:"Line graph",graph:"Relationship graph",sankey:"Sankey diagram",funnel:"Funnel chart",gauge:"Gauge",pictorialBar:"Pictorial bar",themeRiver:"Theme River Map",sunburst:"Sunburst",custom:"Custom chart",chart:"Chart"}},aria:{general:{withTitle:'This is a chart about "{title}"',withoutTitle:"This is a chart"},series:{single:{prefix:"",withName:" with type {seriesType} named {seriesName}.",withoutName:" with type {seriesType}."},multiple:{prefix:". It consists of {seriesCount} series count.",withName:" The {seriesId} series is a {seriesType} representing {seriesName}.",withoutName:" The {seriesId} series is a {seriesType}.",separator:{middle:"",end:""}}},data:{allData:"The data is as follows: ",partialData:"The first {displayCnt} items are: ",withName:"the data for {name} is {value}",withoutName:"{value}",separator:{middle:", ",end:". "}}}}),Zp(Up,{time:{month:["一月","二月","三月","四月","五月","六月","七月","八月","九月","十月","十一月","十二月"],monthAbbr:["1月","2月","3月","4月","5月","6月","7月","8月","9月","10月","11月","12月"],dayOfWeek:["星期日","星期一","星期二","星期三","星期四","星期五","星期六"],dayOfWeekAbbr:["日","一","二","三","四","五","六"]},legend:{selector:{all:"全选",inverse:"反选"}},toolbox:{brush:{title:{rect:"矩形选择",polygon:"圈选",lineX:"横向选择",lineY:"纵向选择",keep:"保持选择",clear:"清除选择"}},dataView:{title:"数据视图",lang:["数据视图","关闭","刷新"]},dataZoom:{title:{zoom:"区域缩放",back:"区域缩放还原"}},magicType:{title:{line:"切换为折线图",bar:"切换为柱状图",stack:"切换为堆叠",tiled:"切换为平铺"}},restore:{title:"还原"},saveAsImage:{title:"保存为图片",lang:["右键另存为图片"]}},series:{typeNames:{pie:"饼图",bar:"柱状图",line:"折线图",scatter:"散点图",effectScatter:"涟漪散点图",radar:"雷达图",tree:"树图",treemap:"矩形树图",boxplot:"箱型图",candlestick:"K线图",k:"K线图",heatmap:"热力图",map:"地图",parallel:"平行坐标图",lines:"线图",graph:"关系图",sankey:"桑基图",funnel:"漏斗图",gauge:"仪表盘图",pictorialBar:"象形柱图",themeRiver:"主题河流图",sunburst:"旭日图",custom:"自定义图表",chart:"图表"}},aria:{general:{withTitle:"这是一个关于“{title}”的图表。",withoutTitle:"这是一个图表,"},series:{single:{prefix:"",withName:"图表类型是{seriesType},表示{seriesName}。",withoutName:"图表类型是{seriesType}。"},multiple:{prefix:"它由{seriesCount}个图表系列组成。",withName:"第{seriesId}个系列是一个表示{seriesName}的{seriesType},",withoutName:"第{seriesId}个系列是一个{seriesType},",separator:{middle:";",end:"。"}}},data:{allData:"其数据是——",partialData:"其中,前{displayCnt}项是——",withName:"{name}的数据是{value}",withoutName:"{value}",separator:{middle:",",end:""}}}});function Kp(){return null}var Qp=1e3,Jp=6e4,tf=36e5,ef=864e5,nf=31536e6,rf={year:/({yyyy}|{yy})/,month:/({MMMM}|{MMM}|{MM}|{M})/,day:/({dd}|{d})/,hour:/({HH}|{H}|{hh}|{h})/,minute:/({mm}|{m})/,second:/({ss}|{s})/,millisecond:/({SSS}|{S})/},of={year:"{yyyy}",month:"{MMM}",day:"{d}",hour:"{HH}:{mm}",minute:"{HH}:{mm}",second:"{HH}:{mm}:{ss}",millisecond:"{HH}:{mm}:{ss} {SSS}"},af="{yyyy}-{MM}-{dd}",sf={year:"{yyyy}",month:"{yyyy}-{MM}",day:af,hour:af+" "+of.hour,minute:af+" "+of.minute,second:af+" "+of.second,millisecond:"{yyyy}-{MM}-{dd} {HH}:{mm}:{ss} {SSS}"},lf=["year","month","day","hour","minute","second","millisecond"],cf=["year","half-year","quarter","month","week","half-week","day","half-day","quarter-day","hour","minute","second","millisecond"];function uf(t){return En(t)||Ln(t)?t:function(t){t=t||{};var e={},n=!0;return Cn(lf,function(e){n&&(n=null==t[e])}),Cn(lf,function(i,r){var o=t[i];e[i]={};for(var a=null,s=r;s>=0;s--){var l=lf[s],c=On(o)&&!Pn(o)?o[l]:o,u=void 0;Pn(c)?a=(u=c.slice())[0]||"":En(c)?u=[a=c]:(null==a?a=of[i]:rf[l].test(a)||(a=e[l][l][0]+" "+a),u=[a],n&&(u[1]="{primary|"+a+"}")),e[i][l]=u}}),e}(t)}function hf(t,e){return"0000".substr(0,e-(t+="").length)+t}function df(t){switch(t){case"half-year":case"quarter":return"month";case"week":case"half-week":return"day";case"half-day":case"quarter-day":return"hour";default:return t}}function pf(t){return t===df(t)}function ff(t,e,n,i){var r=Ps(t),o=r[mf(n)](),a=r[yf(n)]()+1,s=Math.floor((a-1)/3)+1,l=r[_f(n)](),c=r["get"+(n?"UTC":"")+"Day"](),u=r[bf(n)](),h=(u-1)%12+1,d=r[xf(n)](),p=r[wf(n)](),f=r[Sf(n)](),g=u>=12?"pm":"am",v=g.toUpperCase(),m=i instanceof Bp?i:function(t){return Xp[t]}(i||Yp)||Xp[qp],y=m.getModel("time"),_=y.get("month"),b=y.get("monthAbbr"),x=y.get("dayOfWeek"),w=y.get("dayOfWeekAbbr");return(e||"").replace(/{a}/g,g+"").replace(/{A}/g,v+"").replace(/{yyyy}/g,o+"").replace(/{yy}/g,hf(o%100+"",2)).replace(/{Q}/g,s+"").replace(/{MMMM}/g,_[a-1]).replace(/{MMM}/g,b[a-1]).replace(/{MM}/g,hf(a,2)).replace(/{M}/g,a+"").replace(/{dd}/g,hf(l,2)).replace(/{d}/g,l+"").replace(/{eeee}/g,x[c]).replace(/{ee}/g,w[c]).replace(/{e}/g,c+"").replace(/{HH}/g,hf(u,2)).replace(/{H}/g,u+"").replace(/{hh}/g,hf(h+"",2)).replace(/{h}/g,h+"").replace(/{mm}/g,hf(d,2)).replace(/{m}/g,d+"").replace(/{ss}/g,hf(p,2)).replace(/{s}/g,p+"").replace(/{SSS}/g,hf(f,3)).replace(/{S}/g,f+"")}function gf(t,e){var n=Ps(t),i=n[yf(e)]()+1,r=n[_f(e)](),o=n[bf(e)](),a=n[xf(e)](),s=n[wf(e)](),l=0===n[Sf(e)](),c=l&&0===s,u=c&&0===a,h=u&&0===o,d=h&&1===r;return d&&1===i?"year":d?"month":h?"day":u?"hour":c?"minute":l?"second":"millisecond"}function vf(t,e,n){switch(e){case"year":t[kf(n)](0);case"month":t[Mf(n)](1);case"day":t[Tf(n)](0);case"hour":t[If(n)](0);case"minute":t[Df(n)](0);case"second":t[Af(n)](0)}return t}function mf(t){return t?"getUTCFullYear":"getFullYear"}function yf(t){return t?"getUTCMonth":"getMonth"}function _f(t){return t?"getUTCDate":"getDate"}function bf(t){return t?"getUTCHours":"getHours"}function xf(t){return t?"getUTCMinutes":"getMinutes"}function wf(t){return t?"getUTCSeconds":"getSeconds"}function Sf(t){return t?"getUTCMilliseconds":"getMilliseconds"}function Cf(t){return t?"setUTCFullYear":"setFullYear"}function kf(t){return t?"setUTCMonth":"setMonth"}function Mf(t){return t?"setUTCDate":"setDate"}function Tf(t){return t?"setUTCHours":"setHours"}function If(t){return t?"setUTCMinutes":"setMinutes"}function Df(t){return t?"setUTCSeconds":"setSeconds"}function Af(t){return t?"setUTCMilliseconds":"setMilliseconds"}function Pf(t){if(isNaN(zs(t)))return En(t)?t:"-";var e=(t+"").split(".");return e[0].replace(/(\d{1,3})(?=(?:\d{3})+(?!\d))/g,"$1,")+(e.length>1?"."+e[1]:"")}function Lf(t,e){return t=(t||"").toLowerCase().replace(/-(.)/g,function(t,e){return e.toUpperCase()}),e&&t&&(t=t.charAt(0).toUpperCase()+t.slice(1)),t}var Ef=qn;function zf(t,e,n){function i(t){return t&&Xn(t)?t:"-"}function r(t){return!(null==t||isNaN(t)||!isFinite(t))}var o="time"===e,a=t instanceof Date;if(o||a){var s=o?Ps(t):t;if(!isNaN(+s))return ff(s,"{yyyy}-{MM}-{dd} {HH}:{mm}:{ss}",n);if(a)return"-"}if("ordinal"===e)return zn(t)?i(t):Nn(t)&&r(t)?t+"":"-";var l=zs(t);return r(l)?Pf(l):zn(t)?i(t):"boolean"==typeof t?t+"":"-"}var Nf=["a","b","c","d","e","f","g"],Of=function(t,e){return"{"+t+(null==e?"":e)+"}"};function Rf(t,e,n){Pn(e)||(e=[e]);var i=e.length;if(!i)return"";for(var r=e[0].$vars||[],o=0;oi||l.newline?(o=0,u=g,a+=s+n,s=d.height):s=Math.max(s,d.height)}else{var v=d.height+(f?-f.y+d.y:0);(h=a+v)>r||l.newline?(o+=s+n,a=0,h=v,s=d.width):s=Math.max(s,d.width)}l.newline||(l.x=o,l.y=a,l.markRedraw(),"horizontal"===t?o=u+n:a=h+n)})}function Jf(t,e,n){n=Ef(n||0);var i=e.width,r=e.height,o=Ss(t.left,i),a=Ss(t.top,r),s=Ss(t.right,i),l=Ss(t.bottom,r),c=Ss(t.width,i),u=Ss(t.height,r),h=n[2]+n[0],d=n[1]+n[3],p=t.aspect;switch(isNaN(c)&&(c=i-s-d-o),isNaN(u)&&(u=r-l-h-a),null!=p&&(isNaN(c)&&isNaN(u)&&(p>i/r?c=.8*i:u=.8*r),isNaN(c)&&(c=p*u),isNaN(u)&&(u=c/p)),isNaN(o)&&(o=i-s-c-d),isNaN(a)&&(a=r-l-u-h),t.left||t.right){case"center":o=i/2-c/2-n[3];break;case"right":o=i-c-d}switch(t.top||t.bottom){case"middle":case"center":a=r/2-u/2-n[0];break;case"bottom":a=r-u-h}o=o||0,a=a||0,isNaN(c)&&(c=i-d-o-(s||0)),isNaN(u)&&(u=r-h-a-(l||0));var f=new cr((e.x||0)+o+n[3],(e.y||0)+a+n[0],c,u);return f.margin=n,f}An(Qf,"vertical"),An(Qf,"horizontal");var tg=1;function eg(t,e,n){var i,r,o,a,s=t.boxCoordinateSystem;if(s){var l=function(t){var e=t.getShallow("coord",!0),n=Vf;if(null==e){var i=Uf.get(t.type);i&&i.getCoord2&&(n=Wf,e=i.getCoord2(t))}return{coord:e,from:n}}(t),c=l.coord,u=l.from;if(s.dataToLayout){o=tg,a=u;var h=s.dataToLayout(c);i=h.contentRect||h.rect}}return null==o&&(o=tg),o===tg&&(i||(i={x:0,y:0,width:e.getWidth(),height:e.getHeight()}),r=[i.x+i.width/2,i.y+i.height/2]),{type:o,refContainer:i,refPoint:r,boxCoordFrom:a}}function ng(t){var e=t.layoutMode||t.constructor.layoutMode;return On(e)?e:e?{type:e}:null}function ig(t,e,n){var i=n&&n.ignoreSize;!Pn(i)&&(i=[i,i]);var r=a(Kf[0],0),o=a(Kf[1],1);function a(n,r){var o={},a=0,l={},c=0;if(Yf(n,function(e){l[e]=t[e]}),Yf(n,function(t){ii(e,t)&&(o[t]=l[t]=e[t]),s(o,t)&&a++,s(l,t)&&c++}),i[r])return s(e,n[1])?l[n[2]]=null:s(e,n[2])&&(l[n[1]]=null),l;if(2!==c&&a){if(a>=2)return o;for(var u=0;u=0;a--)o=yn(o,n[a],!0);e.defaultOption=o}return e.defaultOption},e.prototype.getReferringComponents=function(t,e){var n=t+"Index",i=t+"Id";return al(this.ecModel,t,{index:this.get(n,!0),id:this.get(i,!0)},e)},e.prototype.getBoxLayoutParams=function(){return e=!1,{left:(t=this).getShallow("left",e),top:t.getShallow("top",e),right:t.getShallow("right",e),bottom:t.getShallow("bottom",e),width:t.getShallow("width",e),height:t.getShallow("height",e)};var t,e},e.prototype.getZLevelKey=function(){return""},e.prototype.setZLevel=function(t){this.option.zlevel=t},e.protoInitialize=((n=e.prototype).type="component",n.id="",n.name="",n.mainType="",n.subType="",void(n.componentIndex=0)),e}(Bp);dl(ag,Bp),vl(ag),function(t){var e={};t.registerSubTypeDefaulter=function(t,n){var i=ul(t);e[i.main]=n},t.determineSubType=function(n,i){var r=i.type;if(!r){var o=ul(n).main;t.hasSubTypes(n)&&e[o]&&(r=e[o](i))}return r}}(ag),function(t,e){function n(t,e){return t[e]||(t[e]={predecessor:[],successor:[]}),t[e]}t.topologicalTravel=function(t,i,r,o){if(t.length){var a=function(t){var i={},r=[];return Cn(t,function(o){var a=n(i,o),s=function(t,e){var n=[];return Cn(t,function(t){xn(e,t)>=0&&n.push(t)}),n}(a.originalDeps=e(o),t);a.entryCount=s.length,0===a.entryCount&&r.push(o),Cn(s,function(t){xn(a.predecessor,t)<0&&a.predecessor.push(t);var e=n(i,t);xn(e.successor,t)<0&&e.successor.push(o)})}),{graph:i,noEntryList:r}}(i),s=a.graph,l=a.noEntryList,c={};for(Cn(t,function(t){c[t]=!0});l.length;){var u=l.pop(),h=s[u],d=!!c[u];d&&(r.call(o,u,h.originalDeps.slice()),delete c[u]),Cn(h.successor,d?f:p)}Cn(c,function(){throw new Error("")})}function p(t){s[t].entryCount--,0===s[t].entryCount&&l.push(t)}function f(t){c[t]=!0,p(t)}}}(ag,function(t){var e=[];Cn(ag.getClassesByMainType(t),function(t){e=e.concat(t.dependencies||t.prototype.dependencies||[])}),e=kn(e,function(t){return ul(t).main}),"dataset"!==t&&xn(e,"dataset")<=0&&e.unshift("dataset");return e});var sg={color:{},darkColor:{},size:{}},lg=sg.color={theme:["#5070dd","#b6d634","#505372","#ff994d","#0ca8df","#ffd10a","#fb628b","#785db0","#3fbe95"],neutral00:"#fff",neutral05:"#f4f7fd",neutral10:"#e8ebf0",neutral15:"#dbdee4",neutral20:"#cfd2d7",neutral25:"#c3c5cb",neutral30:"#b7b9be",neutral35:"#aaacb2",neutral40:"#9ea0a5",neutral45:"#929399",neutral50:"#86878c",neutral55:"#797b7f",neutral60:"#6d6e73",neutral65:"#616266",neutral70:"#54555a",neutral75:"#48494d",neutral80:"#3c3c41",neutral85:"#303034",neutral90:"#232328",neutral95:"#17171b",neutral99:"#000",accent05:"#eff1f9",accent10:"#e0e4f2",accent15:"#d0d6ec",accent20:"#c0c9e6",accent25:"#b1bbdf",accent30:"#a1aed9",accent35:"#91a0d3",accent40:"#8292cc",accent45:"#7285c6",accent50:"#6578ba",accent55:"#5c6da9",accent60:"#536298",accent65:"#4a5787",accent70:"#404c76",accent75:"#374165",accent80:"#2e3654",accent85:"#252b43",accent90:"#1b2032",accent95:"#121521",transparent:"rgba(0,0,0,0)",highlight:"rgba(255,231,130,0.8)"};for(var cg in _n(lg,{primary:lg.neutral80,secondary:lg.neutral70,tertiary:lg.neutral60,quaternary:lg.neutral50,disabled:lg.neutral20,border:lg.neutral30,borderTint:lg.neutral20,borderShade:lg.neutral40,background:lg.neutral05,backgroundTint:"rgba(234,237,245,0.5)",backgroundTransparent:"rgba(255,255,255,0)",backgroundShade:lg.neutral10,shadow:"rgba(0,0,0,0.2)",shadowTint:"rgba(129,130,136,0.2)",axisLine:lg.neutral70,axisLineTint:lg.neutral40,axisTick:lg.neutral70,axisTickMinor:lg.neutral60,axisLabel:lg.neutral70,axisSplitLine:lg.neutral15,axisMinorSplitLine:lg.neutral05}),lg)if(lg.hasOwnProperty(cg)){var ug=lg[cg];"theme"===cg?sg.darkColor.theme=lg.theme.slice():"highlight"===cg?sg.darkColor.highlight="rgba(255,231,130,0.4)":0===cg.indexOf("accent")?sg.darkColor[cg]=Mo(ug,0,function(t){return.5*t},function(t){return Math.min(1,1.3-t)}):sg.darkColor[cg]=Mo(ug,0,function(t){return.9*t},function(t){return 1-Math.pow(t,1.5)})}sg.size={xxs:2,xs:5,s:10,m:15,l:20,xl:30,xxl:40,xxxl:50};var hg="";"undefined"!=typeof navigator&&(hg=navigator.platform||"");var dg="rgba(0, 0, 0, 0.2)",pg=sg.color.theme[0],fg=Mo(pg,0,null,.9),gg={darkMode:"auto",colorBy:"series",color:sg.color.theme,gradientColor:[fg,pg],aria:{decal:{decals:[{color:dg,dashArrayX:[1,0],dashArrayY:[2,5],symbolSize:1,rotation:Math.PI/6},{color:dg,symbol:"circle",dashArrayX:[[8,8],[0,8,8,0]],dashArrayY:[6,0],symbolSize:.8},{color:dg,dashArrayX:[1,0],dashArrayY:[4,3],rotation:-Math.PI/4},{color:dg,dashArrayX:[[6,6],[0,6,6,0]],dashArrayY:[6,0]},{color:dg,dashArrayX:[[1,0],[1,6]],dashArrayY:[1,0,6,0],rotation:Math.PI/4},{color:dg,symbol:"triangle",dashArrayX:[[9,9],[0,9,9,0]],dashArrayY:[7,2],symbolSize:.75}]}},textStyle:{fontFamily:hg.match(/^Win/)?"Microsoft YaHei":"sans-serif",fontSize:12,fontStyle:"normal",fontWeight:"normal"},blendMode:null,stateAnimation:{duration:300,easing:"cubicOut"},animation:"auto",animationDuration:1e3,animationDurationUpdate:500,animationEasing:"cubicInOut",animationEasingUpdate:"cubicInOut",animationThreshold:2e3,progressiveThreshold:3e3,progressive:400,hoverLayerThreshold:3e3,useUTC:!1},vg=ei(["tooltip","label","itemName","itemId","itemGroupId","itemChildGroupId","seriesName"]),mg="original",yg="arrayRows",_g="objectRows",bg="keyedColumns",xg="typedArray",wg="unknown",Sg="column",Cg="row",kg=1,Mg=2,Tg=3,Ig=el();function Dg(t,e,n){var i={},r=Ag(e);if(!r||!t)return i;var o,a,s=[],l=[],c=e.ecModel,u=Ig(c).datasetMap,h=r.uid+"_"+n.seriesLayoutBy;Cn(t=t.slice(),function(e,n){var r=On(e)?e:t[n]={name:e};"ordinal"===r.type&&null==o&&(o=n,a=f(r)),i[r.name]=[]});var d=u.get(h)||u.set(h,{categoryWayDim:a,valueWayDim:0});function p(t,e,n){for(var i=0;ie)return t[i];return t[n-1]}(i,a):n;if(u=u||n,!u||!u.length)return;var h=u[l];r&&(c[r]=h);return s.paletteIdx=(l+1)%u.length,h}(this,Eg,i,r,t,e,n)},t.prototype.clearColorPalette=function(){!function(t,e){e(t).paletteIdx=0,e(t).paletteNameMap={}}(this,Eg)},t}();var $g="\0_ec_inner",Hg=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return y(e,t),e.prototype.init=function(t,e,n,i,r,o){i=i||{},this.option=null,this._theme=new Bp(i),this._locale=new Bp(r),this._optionManager=o},e.prototype.setOption=function(t,e,n){var i=Vg(e);this._optionManager.setOption(t,n,i),this._resetOption(null,i)},e.prototype.resetOption=function(t,e){return this._resetOption(t,Vg(e))},e.prototype._resetOption=function(t,e){var n=!1,i=this._optionManager;if(!t||"recreate"===t){var r=i.mountOption("recreate"===t);this.option&&"recreate"!==t?(this.restoreData(),this._mergeOption(r,e)):Og(this,r),n=!0}if("timeline"!==t&&"media"!==t||this.restoreData(),!t||"recreate"===t||"timeline"===t){var o=i.getTimelineOption(this);o&&(n=!0,this._mergeOption(o,e))}if(!t||"recreate"===t||"media"===t){var a=i.getMediaOption(this);a.length&&Cn(a,function(t){n=!0,this._mergeOption(t,e)},this)}return n},e.prototype.mergeOption=function(t){this._mergeOption(t,null)},e.prototype._mergeOption=function(t,e){var n=this.option,i=this._componentsMap,r=this._componentsCount,o=[],a=ei(),s=e&&e.replaceMergeMainTypeMap;Ig(this).datasetMap=ei(),Cn(t,function(t,e){null!=t&&(ag.hasClass(e)?e&&(o.push(e),a.set(e,!0)):n[e]=null==n[e]?mn(t):yn(n[e],t,!0))}),s&&s.each(function(t,e){ag.hasClass(e)&&!a.get(e)&&(o.push(e),a.set(e,!0))}),ag.topologicalTravel(o,ag.getAllClassMainTypes(),function(e){var o=function(t,e,n){var i=Lg.get(e);if(!i)return n;var r=i(t);return r?n.concat(r):n}(this,e,Ws(t[e])),a=i.get(e),l=a?s&&s.get(e)?"replaceMerge":"normalMerge":"replaceAll",c=Xs(a,o,l);(function(t,e,n){Cn(t,function(t){var i=t.newOption;On(i)&&(t.keyInfo.mainType=e,t.keyInfo.subType=function(t,e,n,i){return e.type?e.type:n?n.subType:i.determineSubType(t,e)}(e,i,t.existing,n))})})(c,e,ag),n[e]=null,i.set(e,null),r.set(e,0);var u,h=[],d=[],p=0;Cn(c,function(t,n){var i=t.existing,r=t.newOption;if(r){var o="series"===e,a=ag.getClass(e,t.keyInfo.subType,!o);if(!a)return;if("tooltip"===e){if(u)return;u=!0}if(i&&i.constructor===a)i.name=t.keyInfo.name,i.mergeOption(r,this),i.optionUpdated(r,!1);else{var s=_n({componentIndex:n},t.keyInfo);_n(i=new a(r,this,this,s),s),t.brandNew&&(i.__requireNewView=!0),i.init(r,this,this),i.optionUpdated(null,!0)}}else i&&(i.mergeOption({},this),i.optionUpdated({},!1));i?(h.push(i.option),d.push(i),p++):(h.push(void 0),d.push(void 0))},this),n[e]=h,i.set(e,d),r.set(e,p),"series"===e&&zg(this)},this),this._seriesIndices||zg(this)},e.prototype.getOption=function(){var t=mn(this.option);return Cn(t,function(e,n){if(ag.hasClass(n)){for(var i=Ws(e),r=i.length,o=!1,a=r-1;a>=0;a--)i[a]&&!Js(i[a])?o=!0:(i[a]=null,!o&&r--);i.length=r,t[n]=i}}),delete t[$g],t},e.prototype.setTheme=function(t){this._theme=new Bp(t),this._resetOption("recreate",null)},e.prototype.getTheme=function(){return this._theme},e.prototype.getLocaleModel=function(){return this._locale},e.prototype.setUpdatePayload=function(t){this._payload=t},e.prototype.getUpdatePayload=function(){return this._payload},e.prototype.getComponent=function(t,e){var n=this._componentsMap.get(t);if(n){var i=n[e||0];if(i)return i;if(null==e)for(var r=0;r=e:"max"===n?t<=e:t===e})(i[a],t,o)||(r=!1)}}),r}var Xg=Cn,Yg=On,Zg=["areaStyle","lineStyle","nodeStyle","linkStyle","chordStyle","label","labelLine"];function Kg(t){var e=t&&t.itemStyle;if(e)for(var n=0,i=Zg.length;nc&&(c=p)}s[0]=l,s[1]=c}},i=function(){return this._data?this._data.length/this._dimSize:0};function r(t){for(var e=0;e=0&&(s=o.interpolatedValue[l])}return null!=s?s+"":""}):void 0},t.prototype.getRawValue=function(t,e){return Fv(this.getData(e),t)},t.prototype.formatTooltip=function(t,e,n){},t}();function Wv(t){var e,n;return On(t)?t.type&&(n=t):e=t,{text:e,frag:n}}function Uv(t){return new Gv(t)}var Gv=function(){function t(t){t=t||{},this._reset=t.reset,this._plan=t.plan,this._count=t.count,this._onDirty=t.onDirty,this._dirty=!0}return t.prototype.perform=function(t){var e,n=this._upstream,i=t&&t.skip;if(this._dirty&&n){var r=this.context;r.data=r.outputData=n.context.outputData}this.__pipeline&&(this.__pipeline.currentTask=this),this._plan&&!i&&(e=this._plan(this.context));var o,a=u(this._modBy),s=this._modDataCount||0,l=u(t&&t.modBy),c=t&&t.modDataCount||0;function u(t){return!(t>=1)&&(t=1),t}a===l&&s===c||(e="reset"),(this._dirty||"reset"===e)&&(this._dirty=!1,o=this._doReset(i)),this._modBy=l,this._modDataCount=c;var h=t&&t.step;if(this._dueEnd=n?n._outputDueEnd:this._count?this._count(this.context):1/0,this._progress){var d=this._dueIndex,p=Math.min(null!=h?this._dueIndex+h:1/0,this._dueEnd);if(!i&&(o||d1&&i>0?s:a}};return o;function a(){return e=t?null:oi?-this._resultLT:0},t}(),Yv=function(){function t(){}return t.prototype.getRawData=function(){throw new Error("not supported")},t.prototype.getRawDataItem=function(t){throw new Error("not supported")},t.prototype.cloneRawData=function(){},t.prototype.getDimensionInfo=function(t){},t.prototype.cloneAllDimensionInfo=function(){},t.prototype.count=function(){},t.prototype.retrieveValue=function(t,e){},t.prototype.retrieveValueFromItem=function(t,e){},t.prototype.convertValue=function(t,e){return jv(t,e)},t}();function Zv(t){if(!nm(t.sourceFormat)){Fs("")}return t.data}function Kv(t){var e=t.sourceFormat,n=t.data;if(!nm(e)){Fs("")}if(e===yg){for(var i=[],r=0,o=n.length;r65535?om:am}function hm(){return[1/0,-1/0]}function dm(t){var e=t.constructor;return e===Array?t.slice():new e(t)}function pm(t,e,n,i,r){var o=cm[n||"float"];if(r){var a=t[e],s=a&&a.length;if(s!==i){for(var l=new o(i),c=0;cg[1]&&(g[1]=f)}return this._rawCount=this._count=s,{start:a,end:s}},t.prototype._initDataFromProvider=function(t,e,n){for(var i=this._provider,r=this._chunks,o=this._dimensions,a=o.length,s=this._rawExtent,l=kn(o,function(t){return t.property}),c=0;cv[1]&&(v[1]=g)}}!i.persistent&&i.clean&&i.clean(),this._rawCount=this._count=e,this._extent=[]},t.prototype.count=function(){return this._count},t.prototype.get=function(t,e){if(!(e>=0&&e=0&&e=this._rawCount||t<0)return-1;if(!this._indices)return t;var e=this._indices,n=e[t];if(null!=n&&nt))return o;r=o-1}}return-1},t.prototype.getIndices=function(){var t,e=this._indices;if(e){var n=e.constructor,i=this._count;if(n===Array){t=new n(i);for(var r=0;r=c&&_<=u||isNaN(_))&&(a[s++]=p),p++}d=!0}else if(2===r){f=h[i[0]];var v=h[i[1]],m=t[i[1]][0],y=t[i[1]][1];for(g=0;g=c&&_<=u||isNaN(_))&&(b>=m&&b<=y||isNaN(b))&&(a[s++]=p),p++}d=!0}}if(!d)if(1===r)for(g=0;g=c&&_<=u||isNaN(_))&&(a[s++]=x)}else for(g=0;gt[C][1])&&(w=!1)}w&&(a[s++]=e.getRawIndex(g))}return sv[1]&&(v[1]=g)}}}},t.prototype.lttbDownSample=function(t,e){var n,i,r,o=this.clone([t],!0),a=o._chunks[t],s=this.count(),l=0,c=Math.floor(1/e),u=this.getRawIndex(0),h=new(um(this._rawCount))(Math.min(2*(Math.ceil(s/c)+2),s));h[l++]=u;for(var d=1;dn&&(n=i,r=k)}C>0&&Ca&&(f=a-c);for(var g=0;gp&&(p=v,d=c+g)}var m=this.getRawIndex(u),y=this.getRawIndex(d);uc-p&&(s=c-p,a.length=s);for(var f=0;fu[1]&&(u[1]=v),h[d++]=m}return r._count=d,r._indices=h,r._updateGetRawIdx(),r},t.prototype.each=function(t,e){if(this._count)for(var n=t.length,i=this._chunks,r=0,o=this.count();ra&&(a=l)}return i=[o,a],this._extent[t]=i,i},t.prototype.getRawDataItem=function(t){var e=this.getRawIndex(t);if(this._provider.persistent)return this._provider.getItem(e);for(var n=[],i=this._chunks,r=0;r=0?this._indices[t]:-1},t.prototype._updateGetRawIdx=function(){this.getRawIndex=this._indices?this._getRawIdx:this._getRawIdxIdentity},t.internalField=function(){function t(t,e,n,i){return jv(t[i],this._dimensions[i])}im={arrayRows:t,objectRows:function(t,e,n,i){return jv(t[e],this._dimensions[i])},keyedColumns:t,original:function(t,e,n,i){var r=t&&(null==t.value?t:t.value);return jv(r instanceof Array?r[i]:r,this._dimensions[i])},typedArray:function(t,e,n,i){return t[i]}}}(),t}(),gm=function(){function t(t){this._sourceList=[],this._storeList=[],this._upstreamSignList=[],this._versionSignBase=0,this._dirty=!0,this._sourceHost=t}return t.prototype.dirty=function(){this._setLocalSource([],[]),this._storeList=[],this._dirty=!0},t.prototype._setLocalSource=function(t,e){this._sourceList=t,this._upstreamSignList=e,this._versionSignBase++,this._versionSignBase>9e10&&(this._versionSignBase=0)},t.prototype._getVersionSign=function(){return this._sourceHost.uid+"_"+this._versionSignBase},t.prototype.prepareSource=function(){this._isDirty()&&(this._createSource(),this._dirty=!1)},t.prototype._createSource=function(){this._setLocalSource([],[]);var t,e,n=this._sourceHost,i=this._getUpstreamSourceManagers(),r=!!i.length;if(vm(n)){var o=n,a=void 0,s=void 0,l=void 0;if(r){var c=i[0];c.prepareSource(),a=(l=c.getSource()).data,s=l.sourceFormat,e=[c._getVersionSign()]}else s=$n(a=o.get("data",!0))?xg:mg,e=[];var u=this._getSourceMetaRawOption()||{},h=l&&l.metaRawOption||{},d=Wn(u.seriesLayoutBy,h.seriesLayoutBy)||null,p=Wn(u.sourceHeader,h.sourceHeader),f=Wn(u.dimensions,h.dimensions);t=d!==h.seriesLayoutBy||!!p!=!!h.sourceHeader||f?[wv(a,{seriesLayoutBy:d,sourceHeader:p,dimensions:f},s)]:[]}else{var g=n;if(r){var v=this._applyTransform(i);t=v.sourceList,e=v.upstreamSignList}else{t=[wv(g.get("source",!0),this._getSourceMetaRawOption(),null)],e=[]}}this._setLocalSource(t,e)},t.prototype._applyTransform=function(t){var e,n=this._sourceHost,i=n.get("transform",!0),r=n.get("fromTransformResult",!0);if(null!=r){1!==t.length&&mm("")}var o,a=[],s=[];return Cn(t,function(t){t.prepareSource();var e=t.getSource(r||0);null==r||e||mm(""),a.push(e),s.push(t._getVersionSign())}),i?e=function(t,e){var n=Ws(t),i=n.length;i||Fs("");for(var r=0,o=i;r1||n>0&&!t.noHeader;return Cn(t.blocks,function(t){var n=km(t);n>=e&&(e=n+ +(i&&(!n||Sm(t)&&!t.noHeader)))}),e}return 0}function Mm(t,e,n,i){var r,o=e.noHeader,a=(r=km(e),{html:bm[r],richText:xm[r]}),s=[],l=e.blocks||[];jn(!l||Pn(l)),l=l||[];var c=t.orderMode;if(e.sortBlocks&&c){l=l.slice();var u={valueAsc:"asc",valueDesc:"desc"};if(ii(u,c)){var h=new Xv(u[c],null);l.sort(function(t,e){return h.evaluate(t.sortParam,e.sortParam)})}else"seriesDesc"===c&&l.reverse()}Cn(l,function(n,r){var o=e.valueFormatter,l=Cm(n)(o?_n(_n({},t),{valueFormatter:o}):t,n,r>0?a.html:0,i);null!=l&&s.push(l)});var d="richText"===t.renderMode?s.join(a.richText):Dm(i,s.join(""),o?n:a.html);if(o)return d;var p=zf(e.header,"ordinal",t.useUTC),f=_m(i,t.renderMode).nameStyle,g=ym(i);return"richText"===t.renderMode?Am(t,p,f)+a.richText+d:Dm(i,'
'+Ai(p)+"
"+d,n)}function Tm(t,e,n,i){var r=t.renderMode,o=e.noName,a=e.noValue,s=!e.markerType,l=e.name,c=t.useUTC,u=e.valueFormatter||t.valueFormatter||function(t){return kn(t=Pn(t)?t:[t],function(t,e){return zf(t,Pn(p)?p[e]:p,c)})};if(!o||!a){var h=s?"":t.markupStyleCreator.makeTooltipMarker(e.markerType,e.markerColor||sg.color.secondary,r),d=o?"":zf(l,"ordinal",c),p=e.valueType,f=a?[]:u(e.value,e.dataIndex),g=!s||!o,v=!s&&o,m=_m(i,r),y=m.nameStyle,_=m.valueStyle;return"richText"===r?(s?"":h)+(o?"":Am(t,d,y))+(a?"":function(t,e,n,i,r){var o=[r],a=i?10:20;return n&&o.push({padding:[0,0,0,a],align:"right"}),t.markupStyleCreator.wrapRichTextStyle(Pn(e)?e.join(" "):e,o)}(t,f,g,v,_)):Dm(i,(s?"":h)+(o?"":function(t,e,n){return''+Ai(t)+""}(d,!s,y))+(a?"":function(t,e,n,i){var r=n?"10px":"20px",o=e?"float:right;margin-left:"+r:"";return t=Pn(t)?t:[t],''+kn(t,function(t){return Ai(t)}).join("  ")+""}(f,g,v,_)),n)}}function Im(t,e,n,i,r,o){if(t)return Cm(t)({useUTC:r,renderMode:n,orderMode:i,markupStyleCreator:e,valueFormatter:t.valueFormatter},t,0,o)}function Dm(t,e,n){return'
'+e+'
'}function Am(t,e,n){return t.markupStyleCreator.wrapRichTextStyle(e,n)}function Pm(t,e){var n=t.get("padding");return null!=n?n:"richText"===e?[8,10]:10}var Lm=function(){function t(){this.richTextStyles={},this._nextStyleNameId=Ns()}return t.prototype._generateStyleName=function(){return"__EC_aUTo_"+this._nextStyleNameId++},t.prototype.makeTooltipMarker=function(t,e,n){var i="richText"===n?this._generateStyleName():null,r=function(t,e){var n=En(t)?{color:t,extraCssText:e}:t||{},i=n.color,r=n.type;e=n.extraCssText;var o=n.renderMode||"html";return i?"html"===o?"subItem"===r?'':'':{renderMode:o,content:"{"+(n.markerId||"markerX")+"|} ",style:"subItem"===r?{width:4,height:4,borderRadius:2,backgroundColor:i}:{width:10,height:10,borderRadius:5,backgroundColor:i}}:""}({color:e,type:t,renderMode:n,markerId:i});return En(r)?r:(this.richTextStyles[i]=r.style,r.content)},t.prototype.wrapRichTextStyle=function(t,e){var n={};Pn(e)?Cn(e,function(t){return _n(n,t)}):_n(n,e);var i=this._generateStyleName();return this.richTextStyles[i]=n,"{"+i+"|"+t+"}"},t}();function Em(t){var e,n,i,r,o=t.series,a=t.dataIndex,s=t.multipleSeries,l=o.getData(),c=l.mapDimensionsAll("defaultedTooltip"),u=c.length,h=o.getRawValue(a),d=Pn(h),p=function(t,e){return $f(t.getData().getItemVisual(e,"style")[t.visualDrawType])}(o,a);if(u>1||d&&!u){var f=function(t,e,n,i,r){var o=e.getData(),a=Mn(t,function(t,e,n){var i=o.getDimensionInfo(n);return t||i&&!1!==i.tooltip&&null!=i.displayName},!1),s=[],l=[],c=[];function u(t,e){var n=o.getDimensionInfo(e);n&&!1!==n.otherDims.tooltip&&(a?c.push(wm("nameValue",{markerType:"subItem",markerColor:r,name:n.displayName,value:t,valueType:n.type})):(s.push(t),l.push(n.type)))}return i.length?Cn(i,function(t){u(Fv(o,n,t),t)}):Cn(t,u),{inlineValues:s,inlineValueTypes:l,blocks:c}}(h,o,a,c,p);e=f.inlineValues,n=f.inlineValueTypes,i=f.blocks,r=f.inlineValues[0]}else if(u){var g=l.getDimensionInfo(c[0]);r=e=Fv(l,a,c[0]),n=g.type}else r=e=d?h[0]:h;var v=Qs(o),m=v&&o.name||"",y=l.getName(a),_=s?m:y;return wm("section",{header:m,noHeader:s||!v,sortParam:r,blocks:[wm("nameValue",{markerType:"item",markerColor:p,name:_,noName:!Xn(_),value:e,valueType:n,dataIndex:a})].concat(i||[])})}var zm=el();function Nm(t,e){return t.getName(e)||t.getId(e)}var Om=function(t){function e(){var e=null!==t&&t.apply(this,arguments)||this;return e._selectedDataIndicesMap={},e}var n;return y(e,t),e.prototype.init=function(t,e,n){this.seriesIndex=this.componentIndex,this.dataTask=Uv({count:$m,reset:Hm}),this.dataTask.context={model:this},this.mergeDefaultAndTheme(t,n),(zm(this).sourceManager=new gm(this)).prepareSource();var i=this.getInitialData(t,n);Bm(i,this),this.dataTask.context.data=i,zm(this).dataBeforeProcessed=i,Rm(this),this._initSelectedMapFromData(i)},e.prototype.mergeDefaultAndTheme=function(t,e){var n=ng(this),i=n?rg(t):{},r=this.subType;ag.hasClass(r)&&(r+="Series"),yn(t,e.getTheme().get(this.subType)),yn(t,this.getDefaultOption()),Us(t,"label",["show"]),this.fillDataTextStyle(t.data),n&&ig(t,i,n)},e.prototype.mergeOption=function(t,e){t=yn(this.option,t,!0),this.fillDataTextStyle(t.data);var n=ng(this);n&&ig(this.option,t,n);var i=zm(this).sourceManager;i.dirty(),i.prepareSource();var r=this.getInitialData(t,e);Bm(r,this),this.dataTask.dirty(),this.dataTask.context.data=r,zm(this).dataBeforeProcessed=r,Rm(this),this._initSelectedMapFromData(r)},e.prototype.fillDataTextStyle=function(t){if(t&&!$n(t))for(var e=["show"],n=0;n=0&&u<0)&&(c=o,u=r,h=0),r===u&&(l[h++]=e))}),l.length=h,l},e.prototype.formatTooltip=function(t,e,n){return Em({series:this,dataIndex:t,multipleSeries:e})},e.prototype.isAnimationEnabled=function(){var t=this.ecModel;if(Ye.node&&(!t||!t.ssr))return!1;var e=this.getShallow("animation");return e&&this.getData().count()>this.getShallow("animationThreshold")&&(e=!1),!!e},e.prototype.restoreData=function(){this.dataTask.dirty()},e.prototype.getColorFromPalette=function(t,e,n){var i=this.ecModel,r=Rg.prototype.getColorFromPalette.call(this,t,e,n);return r||(r=i.getColorFromPalette(t,e,n)),r},e.prototype.coordDimToDataDim=function(t){return this.getRawData().mapDimensionsAll(t)},e.prototype.getProgressive=function(){return this.get("progressive")},e.prototype.getProgressiveThreshold=function(){return this.get("progressiveThreshold")},e.prototype.select=function(t,e){this._innerSelect(this.getData(e),t)},e.prototype.unselect=function(t,e){var n=this.option.selectedMap;if(n){var i=this.option.selectedMode,r=this.getData(e);if("series"===i||"all"===n)return this.option.selectedMap={},void(this._selectedDataIndicesMap={});for(var o=0;o=0&&n.push(r)}return n},e.prototype.isSelected=function(t,e){var n=this.option.selectedMap;if(!n)return!1;var i=this.getData(e);return("all"===n||n[Nm(i,t)])&&!i.getItemModel(t).get(["select","disabled"])},e.prototype.isUniversalTransitionEnabled=function(){if(this.__universalTransitionEnabled)return!0;var t=this.option.universalTransition;return!!t&&(!0===t||t&&t.enabled)},e.prototype._innerSelect=function(t,e){var n,i,r=this.option,o=r.selectedMode,a=e.length;if(o&&a)if("series"===o)r.selectedMap="all";else if("multiple"===o){On(r.selectedMap)||(r.selectedMap={});for(var s=r.selectedMap,l=0;l0&&this._innerSelect(t,e)}},e.registerClass=function(t){return ag.registerClass(t)},e.protoInitialize=((n=e.prototype).type="series.__base__",n.seriesIndex=0,n.ignoreStyleOnData=!1,n.hasSymbolVisual=!1,n.defaultSymbol="circle",n.visualStyleAccessPath="itemStyle",void(n.visualDrawType="fill")),e}(ag);function Rm(t){var e=t.name;Qs(t)||(t.name=function(t){var e=t.getRawData(),n=e.mapDimensionsAll("seriesName"),i=[];return Cn(n,function(t){var n=e.getDimensionInfo(t);n.displayName&&i.push(n.displayName)}),i.join(" ")}(t)||e)}function $m(t){return t.model.getRawData().count()}function Hm(t){var e=t.model;return e.setData(e.getRawData().cloneShallow()),Fm}function Fm(t,e){e.outputData&&t.end>e.outputData.count()&&e.model.getRawData().cloneShallow(e.outputData)}function Bm(t,e){Cn(function(t,e){for(var n=new t.constructor(t.length+e.length),i=0;i=0?h():u=setTimeout(h,-r),l=i};return d.clear=function(){u&&(clearTimeout(u),u=null)},d.debounceNextCall=function(t){s=t},d}function ry(t,e,n,i){var r=t[e];if(r){var o=r[ty]||r,a=r[ny];if(r[ey]!==n||a!==i){if(null==n||!i)return t[e]=o;(r=t[e]=iy(o,n,"debounce"===i))[ty]=o,r[ny]=i,r[ey]=n}return r}}function oy(t,e){var n=t[e];n&&n[ty]&&(n.clear&&n.clear(),t[e]=n[ty])}var ay=el(),sy={itemStyle:ml($p,!0),lineStyle:ml(Np,!0)},ly={lineStyle:"stroke",itemStyle:"fill"};function cy(t,e){var n=t.visualStyleMapper||sy[e];return n||(console.warn("Unknown style type '"+e+"'."),sy.itemStyle)}function uy(t,e){var n=t.visualDrawType||ly[e];return n||(console.warn("Unknown style type '"+e+"'."),"fill")}var hy={createOnAllSeries:!0,performRawSeries:!0,reset:function(t,e){var n=t.getData(),i=t.visualStyleAccessPath||"itemStyle",r=t.getModel(i),o=cy(t,i)(r),a=r.getShallow("decal");a&&(n.setVisual("decal",a),a.dirty=!0);var s=uy(t,i),l=o[s],c=Ln(l)?l:null,u="auto"===o.fill||"auto"===o.stroke;if(!o[s]||c||u){var h=t.getColorFromPalette(t.name,null,e.getSeriesCount());o[s]||(o[s]=h,n.setVisual("colorFromPalette",!0)),o.fill="auto"===o.fill||Ln(o.fill)?h:o.fill,o.stroke="auto"===o.stroke||Ln(o.stroke)?h:o.stroke}if(n.setVisual("style",o),n.setVisual("drawType",s),!e.isSeriesFiltered(t)&&c)return n.setVisual("colorFromPalette",!1),{dataEach:function(e,n){var i=t.getDataParams(n),r=_n({},o);r[s]=c(i),e.setItemVisual(n,"style",r)}}}},dy=new Bp,py={createOnAllSeries:!0,performRawSeries:!0,reset:function(t,e){if(!t.ignoreStyleOnData&&!e.isSeriesFiltered(t)){var n=t.getData(),i=t.visualStyleAccessPath||"itemStyle",r=cy(t,i),o=n.getVisual("drawType");return{dataEach:n.hasItemOption?function(t,e){var n=t.getRawDataItem(e);if(n&&n[i]){dy.option=n[i];var a=r(dy);_n(t.ensureUniqueItemVisual(e,"style"),a),dy.option.decal&&(t.setItemVisual(e,"decal",dy.option.decal),dy.option.decal.dirty=!0),o in a&&t.setItemVisual(e,"colorFromPalette",!1)}}:null}}}},fy={performRawSeries:!0,overallReset:function(t){var e=ei();t.eachSeries(function(t){var n=t.getColorBy();if(!t.isColorBySeries()){var i=t.type+"-"+n,r=e.get(i);r||(r={},e.set(i,r)),ay(t).scope=r}}),t.eachSeries(function(e){if(!e.isColorBySeries()&&!t.isSeriesFiltered(e)){var n=e.getRawData(),i={},r=e.getData(),o=ay(e).scope,a=e.visualStyleAccessPath||"itemStyle",s=uy(e,a);r.each(function(t){var e=r.getRawIndex(t);i[e]=t}),n.each(function(t){var a=i[t];if(r.getItemVisual(a,"colorFromPalette")){var l=r.ensureUniqueItemVisual(a,"style"),c=n.getName(t)||t+"",u=n.count();l[s]=e.getColorFromPalette(c,o,u)}})}})}},gy=Math.PI;var vy=function(){function t(t,e,n,i){this._stageTaskMap=ei(),this.ecInstance=t,this.api=e,n=this._dataProcessorHandlers=n.slice(),i=this._visualHandlers=i.slice(),this._allHandlers=n.concat(i)}return t.prototype.restoreData=function(t,e){t.restoreData(e),this._stageTaskMap.each(function(t){var e=t.overallTask;e&&e.dirty()})},t.prototype.getPerformArgs=function(t,e){if(t.__pipeline){var n=this._pipelineMap.get(t.__pipeline.id),i=n.context,r=!e&&n.progressiveEnabled&&(!i||i.progressiveRender)&&t.__idxInPipeline>n.blockIndex?n.step:null,o=i&&i.modDataCount;return{step:r,modBy:null!=o?Math.ceil(o/r):null,modDataCount:o}}},t.prototype.getPipeline=function(t){return this._pipelineMap.get(t)},t.prototype.updateStreamModes=function(t,e){var n=this._pipelineMap.get(t.uid),i=t.getData().count(),r=n.progressiveEnabled&&e.incrementalPrepareRender&&i>=n.threshold,o=t.get("large")&&i>=t.get("largeThreshold"),a="mod"===t.get("progressiveChunkMode")?i:null;t.pipelineContext=n.context={progressiveRender:r,modDataCount:a,large:o}},t.prototype.restorePipelines=function(t){var e=this,n=e._pipelineMap=ei();t.eachSeries(function(t){var i=t.getProgressive(),r=t.uid;n.set(r,{id:r,head:null,tail:null,threshold:t.getProgressiveThreshold(),progressiveEnabled:i&&!(t.preventIncremental&&t.preventIncremental()),blockIndex:-1,step:Math.round(i||700),count:0}),e._pipe(t,t.dataTask)})},t.prototype.prepareStageTasks=function(){var t=this._stageTaskMap,e=this.api.getModel(),n=this.api;Cn(this._allHandlers,function(i){var r=t.get(i.uid)||t.set(i.uid,{});jn(!(i.reset&&i.overallReset),""),i.reset&&this._createSeriesStageTask(i,r,e,n),i.overallReset&&this._createOverallStageTask(i,r,e,n)},this)},t.prototype.prepareView=function(t,e,n,i){var r=t.renderTask,o=r.context;o.model=e,o.ecModel=n,o.api=i,r.__block=!t.incrementalPrepareRender,this._pipe(e,r)},t.prototype.performDataProcessorTasks=function(t,e){this._performStageTasks(this._dataProcessorHandlers,t,e,{block:!0})},t.prototype.performVisualTasks=function(t,e,n){this._performStageTasks(this._visualHandlers,t,e,n)},t.prototype._performStageTasks=function(t,e,n,i){i=i||{};var r=!1,o=this;function a(t,e){return t.setDirty&&(!t.dirtyMap||t.dirtyMap.get(e.__pipeline.id))}Cn(t,function(t,s){if(!i.visualType||i.visualType===t.visualType){var l=o._stageTaskMap.get(t.uid),c=l.seriesTaskMap,u=l.overallTask;if(u){var h,d=u.agentStubMap;d.each(function(t){a(i,t)&&(t.dirty(),h=!0)}),h&&u.dirty(),o.updatePayload(u,n);var p=o.getPerformArgs(u,i.block);d.each(function(t){t.perform(p)}),u.perform(p)&&(r=!0)}else c&&c.each(function(s,l){a(i,s)&&s.dirty();var c=o.getPerformArgs(s,i.block);c.skip=!t.performRawSeries&&e.isSeriesFiltered(s.context.model),o.updatePayload(s,n),s.perform(c)&&(r=!0)})}}),this.unfinished=r||this.unfinished},t.prototype.performSeriesTasks=function(t){var e;t.eachSeries(function(t){e=t.dataTask.perform()||e}),this.unfinished=e||this.unfinished},t.prototype.plan=function(){this._pipelineMap.each(function(t){var e=t.tail;do{if(e.__block){t.blockIndex=e.__idxInPipeline;break}e=e.getUpstream()}while(e)})},t.prototype.updatePayload=function(t,e){"remain"!==e&&(t.context.payload=e)},t.prototype._createSeriesStageTask=function(t,e,n,i){var r=this,o=e.seriesTaskMap,a=e.seriesTaskMap=ei(),s=t.seriesType,l=t.getTargetSeries;function c(e){var s=e.uid,l=a.set(s,o&&o.get(s)||Uv({plan:xy,reset:wy,count:ky}));l.context={model:e,ecModel:n,api:i,useClearVisual:t.isVisual&&!t.isLayout,plan:t.plan,reset:t.reset,scheduler:r},r._pipe(e,l)}t.createOnAllSeries?n.eachRawSeries(c):s?n.eachRawSeriesByType(s,c):l&&l(n,i).each(c)},t.prototype._createOverallStageTask=function(t,e,n,i){var r=this,o=e.overallTask=e.overallTask||Uv({reset:my});o.context={ecModel:n,api:i,overallReset:t.overallReset,scheduler:r};var a=o.agentStubMap,s=o.agentStubMap=ei(),l=t.seriesType,c=t.getTargetSeries,u=!0,h=!1;function d(t){var e=t.uid,n=s.set(e,a&&a.get(e)||(h=!0,Uv({reset:yy,onDirty:by})));n.context={model:t,overallProgress:u},n.agent=o,n.__block=u,r._pipe(t,n)}jn(!t.createOnAllSeries,""),l?n.eachRawSeriesByType(l,d):c?c(n,i).each(d):(u=!1,Cn(n.getSeries(),d)),h&&o.dirty()},t.prototype._pipe=function(t,e){var n=t.uid,i=this._pipelineMap.get(n);!i.head&&(i.head=e),i.tail&&i.tail.pipe(e),i.tail=e,e.__idxInPipeline=i.count++,e.__pipeline=i},t.wrapStageHandler=function(t,e){return Ln(t)&&(t={overallReset:t,seriesType:My(t)}),t.uid=Wp("stageHandler"),e&&(t.visualType=e),t},t}();function my(t){t.overallReset(t.ecModel,t.api,t.payload)}function yy(t){return t.overallProgress&&_y}function _y(){this.agent.dirty(),this.getDownstream().dirty()}function by(){this.agent&&this.agent.dirty()}function xy(t){return t.plan?t.plan(t.model,t.ecModel,t.api,t.payload):null}function wy(t){t.useClearVisual&&t.data.clearAllVisual();var e=t.resetDefines=Ws(t.reset(t.model,t.ecModel,t.api,t.payload));return e.length>1?kn(e,function(t,e){return Cy(e)}):Sy}var Sy=Cy(0);function Cy(t){return function(e,n){var i=n.data,r=n.resetDefines[t];if(r&&r.dataEach)for(var o=e.start;o0&&u===r.length-c.length){var h=r.slice(0,u);"data"!==h&&(e.mainType=h,e[c.toLowerCase()]=t,s=!0)}}a.hasOwnProperty(r)&&(n[r]=t,s=!0),s||(i[r]=t)})}return{cptQuery:e,dataQuery:n,otherQuery:i}},t.prototype.filter=function(t,e){var n=this.eventInfo;if(!n)return!0;var i=n.targetEl,r=n.packedEvent,o=n.model,a=n.view;if(!o||!a)return!0;var s=e.cptQuery,l=e.dataQuery;return c(s,o,"mainType")&&c(s,o,"subType")&&c(s,o,"index","componentIndex")&&c(s,o,"name")&&c(s,o,"id")&&c(l,r,"name")&&c(l,r,"dataIndex")&&c(l,r,"dataType")&&(!a.filterForExposedEvent||a.filterForExposedEvent(t,e.otherQuery,i,r));function c(t,e,n,i){return null==t[n]||e[i||n]===t[n]}},t.prototype.afterTrigger=function(){this.eventInfo=null},t}(),$y=["symbol","symbolSize","symbolRotate","symbolOffset"],Hy=$y.concat(["symbolKeepAspect"]),Fy={createOnAllSeries:!0,performRawSeries:!0,reset:function(t,e){var n=t.getData();if(t.legendIcon&&n.setVisual("legendIcon",t.legendIcon),t.hasSymbolVisual){for(var i={},r={},o=!1,a=0;a<$y.length;a++){var s=$y[a],l=t.get(s);Ln(l)?(o=!0,r[s]=l):i[s]=l}if(i.symbol=i.symbol||t.defaultSymbol,n.setVisual(_n({legendIcon:t.legendIcon||i.symbol,symbolKeepAspect:t.get("symbolKeepAspect")},i)),!e.isSeriesFiltered(t)){var c=In(r);return{dataEach:o?function(e,n){for(var i=t.getRawValue(n),o=t.getDataParams(n),a=0;a=0&&i_(l)?l:.5,t.createRadialGradient(a,s,0,a,s,l)}(t,e,n):function(t,e,n){var i=null==e.x?0:e.x,r=null==e.x2?1:e.x2,o=null==e.y?0:e.y,a=null==e.y2?0:e.y2;return e.global||(i=i*n.width+n.x,r=r*n.width+n.x,o=o*n.height+n.y,a=a*n.height+n.y),i=i_(i)?i:0,r=i_(r)?r:1,o=i_(o)?o:0,a=i_(a)?a:0,t.createLinearGradient(i,o,r,a)}(t,e,n),r=e.colorStops,o=0;o0&&(e=i.lineDash,n=i.lineWidth,e&&"solid"!==e&&n>0?"dashed"===e?[4*n,2*n]:"dotted"===e?[n]:Nn(e)?[e]:Pn(e)?e:null:null),o=i.lineDashOffset;if(r){var a=i.strokeNoScale&&t.getLineScale?t.getLineScale():1;a&&1!==a&&(r=kn(r,function(t){return t/a}),o/=a)}return[r,o]}var l_=new Ic(!0);function c_(t){var e=t.stroke;return!(null==e||"none"===e||!(t.lineWidth>0))}function u_(t){return"string"==typeof t&&"none"!==t}function h_(t){var e=t.fill;return null!=e&&"none"!==e}function d_(t,e){if(null!=e.fillOpacity&&1!==e.fillOpacity){var n=t.globalAlpha;t.globalAlpha=e.fillOpacity*e.opacity,t.fill(),t.globalAlpha=n}else t.fill()}function p_(t,e){if(null!=e.strokeOpacity&&1!==e.strokeOpacity){var n=t.globalAlpha;t.globalAlpha=e.strokeOpacity*e.opacity,t.stroke(),t.globalAlpha=n}else t.stroke()}function f_(t,e,n){var i=wl(e.image,e.__image,n);if(Cl(i)){var r=t.createPattern(i,e.repeat||"repeat");if("function"==typeof DOMMatrix&&r&&r.setTransform){var o=new DOMMatrix;o.translateSelf(e.x||0,e.y||0),o.rotateSelf(0,0,(e.rotation||0)*oi),o.scaleSelf(e.scaleX||1,e.scaleY||1),r.setTransform(o)}return r}}var g_=["shadowBlur","shadowOffsetX","shadowOffsetY"],v_=[["lineCap","butt"],["lineJoin","miter"],["miterLimit",10]];function m_(t,e,n,i,r){var o=!1;if(!i&&e===(n=n||{}))return!1;if(i||e.opacity!==n.opacity){b_(t,r),o=!0;var a=Math.max(Math.min(e.opacity,1),0);t.globalAlpha=isNaN(a)?Ul.opacity:a}(i||e.blend!==n.blend)&&(o||(b_(t,r),o=!0),t.globalCompositeOperation=e.blend||Ul.blend);for(var s=0;s0&&t.unfinished);t.unfinished||this._zr.flush()}}},e.prototype.getDom=function(){return this._dom},e.prototype.getId=function(){return this.id},e.prototype.getZr=function(){return this._zr},e.prototype.isSSR=function(){return this._ssr},e.prototype.setOption=function(t,e,n){if(!this[N_])if(this._disposed)this.id;else{var i,r,o;if(On(e)&&(n=e.lazyUpdate,i=e.silent,r=e.replaceMerge,o=e.transition,e=e.notMerge),this[N_]=!0,lb(this),!this._model||e){var a=new qg(this._api),s=this._theme,l=this._model=new Hg;l.scheduler=this._scheduler,l.ssr=this._ssr,l.init(null,null,null,s,this._locale,a)}this._model.setOption(t,{replaceMerge:r},yb);var c={seriesTransition:o,optionChanged:!0};if(n)this[R_]={silent:i,updateParams:c},this[N_]=!1,this.getZr().wakeUp();else{try{U_(this),j_.update.call(this,null,c)}catch(t){throw this[R_]=null,this[N_]=!1,t}this._ssr||this._zr.flush(),this[R_]=null,this[N_]=!1,K_.call(this,i),Q_.call(this,i)}}},e.prototype.setTheme=function(t,e){if(!this[N_])if(this._disposed)this.id;else{var n=this._model;if(n){var i=e&&e.silent,r=null;this[R_]&&(null==i&&(i=this[R_].silent),r=this[R_].updateParams,this[R_]=null),this[N_]=!0,lb(this);try{this._updateTheme(t),n.setTheme(this._theme),U_(this),j_.update.call(this,{type:"setTheme"},r)}catch(t){throw this[N_]=!1,t}this[N_]=!1,K_.call(this,i),Q_.call(this,i)}}},e.prototype._updateTheme=function(t){En(t)&&(t=bb[t]),t&&((t=mn(t))&&pv(t,!0),this._theme=t)},e.prototype.getModel=function(){return this._model},e.prototype.getOption=function(){return this._model&&this._model.getOption()},e.prototype.getWidth=function(){return this._zr.getWidth()},e.prototype.getHeight=function(){return this._zr.getHeight()},e.prototype.getDevicePixelRatio=function(){return this._zr.painter.dpr||Ye.hasGlobalWindow&&window.devicePixelRatio||1},e.prototype.getRenderedCanvas=function(t){return this.renderToCanvas(t)},e.prototype.renderToCanvas=function(t){return t=t||{},this._zr.painter.getRenderedCanvas({backgroundColor:t.backgroundColor||this._model.get("backgroundColor"),pixelRatio:t.pixelRatio||this.getDevicePixelRatio()})},e.prototype.renderToSVGString=function(t){return t=t||{},this._zr.painter.renderToString({useViewBox:t.useViewBox})},e.prototype.getSvgDataURL=function(){var t=this._zr;return Cn(t.storage.getDisplayList(),function(t){t.stopAnimation(null,!0)}),t.painter.toDataURL()},e.prototype.getDataURL=function(t){if(!this._disposed){var e=(t=t||{}).excludeComponents,n=this._model,i=[],r=this;Cn(e,function(t){n.eachComponent({mainType:t},function(t){var e=r._componentsMap[t.__viewId];e.group.ignore||(i.push(e),e.group.ignore=!0)})});var o="svg"===this._zr.painter.getType()?this.getSvgDataURL():this.renderToCanvas(t).toDataURL("image/"+(t&&t.type||"png"));return Cn(i,function(t){t.group.ignore=!1}),o}this.id},e.prototype.getConnectedDataURL=function(t){if(!this._disposed){var e="svg"===t.type,n=this.group,i=Math.min,r=Math.max,o=1/0;if(Sb[n]){var a=o,s=o,l=-1/0,c=-1/0,u=[],h=t&&t.pixelRatio||this.getDevicePixelRatio();Cn(wb,function(o,h){if(o.group===n){var d=e?o.getZr().painter.getSvgDom().innerHTML:o.renderToCanvas(mn(t)),p=o.getDom().getBoundingClientRect();a=i(p.left,a),s=i(p.top,s),l=r(p.right,l),c=r(p.bottom,c),u.push({dom:d,left:p.left,top:p.top})}});var d=(l*=h)-(a*=h),p=(c*=h)-(s*=h),f=en.createCanvas(),g=ms(f,{renderer:e?"svg":"canvas"});if(g.resize({width:d,height:p}),e){var v="";return Cn(u,function(t){var e=t.left-a,n=t.top-s;v+=''+t.dom+""}),g.painter.getSvgRoot().innerHTML=v,t.connectedBackgroundColor&&g.painter.setBackgroundColor(t.connectedBackgroundColor),g.refreshImmediately(),g.painter.toDataURL()}return t.connectedBackgroundColor&&g.add(new su({shape:{x:0,y:0,width:d,height:p},style:{fill:t.connectedBackgroundColor}})),Cn(u,function(t){var e=new tu({style:{x:t.left*h-a,y:t.top*h-s,image:t.dom}});g.add(e)}),g.refreshImmediately(),f.toDataURL("image/"+(t&&t.type||"png"))}return this.getDataURL(t)}this.id},e.prototype.convertToPixel=function(t,e,n){return X_(this,"convertToPixel",t,e,n)},e.prototype.convertToLayout=function(t,e,n){return X_(this,"convertToLayout",t,e,n)},e.prototype.convertFromPixel=function(t,e,n){return X_(this,"convertFromPixel",t,e,n)},e.prototype.containPixel=function(t,e){var n;if(!this._disposed)return Cn(il(this._model,t),function(t,i){i.indexOf("Models")>=0&&Cn(t,function(t){var r=t.coordinateSystem;if(r&&r.containPoint)n=n||!!r.containPoint(e);else if("seriesModels"===i){var o=this._chartsMap[t.__viewId];o&&o.containPoint&&(n=n||o.containPoint(e,t))}},this)},this),!!n;this.id},e.prototype.getVisual=function(t,e){var n=il(this._model,t,{defaultMainType:"series"}),i=n.seriesModel.getData(),r=n.hasOwnProperty("dataIndexInside")?n.dataIndexInside:n.hasOwnProperty("dataIndex")?i.indexOfRawIndex(n.dataIndex):null;return null!=r?function(t,e,n){switch(n){case"color":return t.getItemVisual(e,"style")[t.getVisual("drawType")];case"opacity":return t.getItemVisual(e,"style").opacity;case"symbol":case"symbolSize":case"liftZ":return t.getItemVisual(e,n)}}(i,r,e):function(t,e){switch(e){case"color":return t.getVisual("style")[t.getVisual("drawType")];case"opacity":return t.getVisual("style").opacity;case"symbol":case"symbolSize":case"liftZ":return t.getVisual(e)}}(i,e)},e.prototype.getViewOfComponentModel=function(t){return this._componentsMap[t.__viewId]},e.prototype.getViewOfSeriesModel=function(t){return this._chartsMap[t.__viewId]},e.prototype._initEvents=function(){var t=this;Cn(pb,function(e){var n=function(n){var i,r=t.getModel(),o=n.target;if("globalout"===e?i={}:o&&Wy(o,function(t){var e=Cu(t);if(e&&null!=e.dataIndex){var n=e.dataModel||r.getSeriesByIndex(e.seriesIndex);return i=n&&n.getDataParams(e.dataIndex,e.dataType,o)||{},!0}if(e.eventData)return i=_n({},e.eventData),!0},!0),i){var a=i.componentType,s=i.componentIndex;"markLine"!==a&&"markPoint"!==a&&"markArea"!==a||(a="series",s=i.seriesIndex);var l=a&&null!=s&&r.getComponent(a,s),c=l&&t["series"===l.mainType?"_chartsMap":"_componentsMap"][l.__viewId];i.event=n,i.type=e,t._$eventProcessor.eventInfo={targetEl:o,packedEvent:i,model:l,view:c},t.trigger(e,i)}};n.zrEventfulCallAtLast=!0,t._zr.on(e,n,t)});var e=this._messageCenter;Cn(vb,function(n,i){e.on(i,function(e){t.trigger(i,e)})}),function(t,e,n){t.on("selectchanged",function(t){var i=n.getModel();t.isFromClick?(Vy("map","selectchanged",e,i,t),Vy("pie","selectchanged",e,i,t)):"select"===t.fromAction?(Vy("map","selected",e,i,t),Vy("pie","selected",e,i,t)):"unselect"===t.fromAction&&(Vy("map","unselected",e,i,t),Vy("pie","unselected",e,i,t))})}(e,this,this._api)},e.prototype.isDisposed=function(){return this._disposed},e.prototype.clear=function(){this._disposed?this.id:this.setOption({series:[]},!0)},e.prototype.dispose=function(){if(this._disposed)this.id;else{this._disposed=!0,this.getDom()&&sl(this.getDom(),kb,"");var t=this,e=t._api,n=t._model;Cn(t._componentsViews,function(t){t.dispose(n,e)}),Cn(t._chartsViews,function(t){t.dispose(n,e)}),t._zr.dispose(),t._dom=t._model=t._chartsMap=t._componentsMap=t._chartsViews=t._componentsViews=t._scheduler=t._api=t._zr=t._throttledZrFlush=t._theme=t._coordSysMgr=t._messageCenter=null,delete wb[t.id]}},e.prototype.resize=function(t){if(!this[N_])if(this._disposed)this.id;else{this._zr.resize(t);var e=this._model;if(this._loadingFX&&this._loadingFX.resize(),e){var n=e.resetOption("media"),i=t&&t.silent;this[R_]&&(null==i&&(i=this[R_].silent),n=!0,this[R_]=null),this[N_]=!0,lb(this);try{n&&U_(this),j_.update.call(this,{type:"resize",animation:_n({duration:0},t&&t.animation)})}catch(t){throw this[N_]=!1,t}this[N_]=!1,K_.call(this,i),Q_.call(this,i)}}},e.prototype.showLoading=function(t,e){if(this._disposed)this.id;else if(On(t)&&(e=t,t=""),t=t||"default",this.hideLoading(),xb[t]){var n=xb[t](this._api,e),i=this._zr;this._loadingFX=n,i.add(n)}},e.prototype.hideLoading=function(){this._disposed?this.id:(this._loadingFX&&this._zr.remove(this._loadingFX),this._loadingFX=null)},e.prototype.makeActionFromEvent=function(t){var e=_n({},t);return e.type=gb[t.type],e},e.prototype.dispatchAction=function(t,e){if(this._disposed)this.id;else if(On(e)||(e={silent:!!e}),fb[t.type]&&this._model)if(this[N_])this._pendingActions.push(t);else{var n=e.silent;Z_.call(this,t,n);var i=e.flush;i?this._zr.flush():!1!==i&&Ye.browser.weChat&&this._throttledZrFlush(),K_.call(this,n),Q_.call(this,n)}},e.prototype.updateLabelLayout=function(){A_.trigger("series:layoutlabels",this._model,this._api,{updatedSeries:[]})},e.prototype.appendData=function(t){if(this._disposed)this.id;else{var e=t.seriesIndex;this.getModel().getSeriesByIndex(e).appendData(t),this._scheduler.unfinished=!0,this.getZr().wakeUp()}},e.internalField=function(){function t(t){t.clearColorPalette(),t.eachSeries(function(t){t.clearColorPalette()})}function e(t){for(var e=[],n=t.currentStates,i=0;i0?{duration:o,delay:i.get("delay"),easing:i.get("easing")}:null;n.eachRendered(function(t){if(t.states&&t.states.emphasis){if(Od(t))return;if(t instanceof Yc&&function(t){var e=Tu(t);e.normalFill=t.style.fill,e.normalStroke=t.style.stroke;var n=t.states.select||{};e.selectFill=n.style&&n.style.fill||null,e.selectStroke=n.style&&n.style.stroke||null}(t),t.__dirty){var n=t.prevStates;n&&t.useStates(n)}if(r){t.stateTransition=a;var i=t.getTextContent(),o=t.getTextGuideLine();i&&(i.stateTransition=a),o&&(o.stateTransition=a)}t.__dirty&&e(t)}})}U_=function(t){var e=t._scheduler;e.restorePipelines(t._model),e.prepareStageTasks(),G_(t,!0),G_(t,!1),e.plan()},G_=function(t,e){for(var n=t._model,i=t._scheduler,r=e?t._componentsViews:t._chartsViews,o=e?t._componentsMap:t._chartsMap,a=t._zr,s=t._api,l=0;le.get("hoverLayerThreshold")&&!Ye.node&&!Ye.worker&&e.eachSeries(function(e){if(!e.preventUsingHoverLayer){var n=t._chartsMap[e.__viewId];n.__alive&&n.eachRendered(function(t){t.states.emphasis&&(t.states.emphasis.hoverLayer=!0)})}})}(t,e),A_.trigger("series:afterupdate",e,o,s)},ab=function(t){t[$_]=!0,t.getZr().wakeUp()},lb=function(t){t[O_]=(t[O_]+1)%1e3},sb=function(t){t[$_]&&(t.getZr().storage.traverse(function(t){Od(t)||e(t)}),t[$_]=!1)},rb=function(t){return new(function(e){function n(){return null!==e&&e.apply(this,arguments)||this}return y(n,e),n.prototype.getCoordinateSystems=function(){return t._coordSysMgr.getCoordinateSystems()},n.prototype.getComponentByElement=function(e){for(;e;){var n=e.__ecComponentInfo;if(null!=n)return t._model.getComponent(n.mainType,n.index);e=e.parent}},n.prototype.enterEmphasis=function(e,n){Qu(e,n),ab(t)},n.prototype.leaveEmphasis=function(e,n){Ju(e,n),ab(t)},n.prototype.enterBlur=function(e){!function(t){qu(t,Bu)}(e),ab(t)},n.prototype.leaveBlur=function(e){th(e),ab(t)},n.prototype.enterSelect=function(e){eh(e),ab(t)},n.prototype.leaveSelect=function(e){nh(e),ab(t)},n.prototype.getModel=function(){return t.getModel()},n.prototype.getViewOfComponentModel=function(e){return t.getViewOfComponentModel(e)},n.prototype.getViewOfSeriesModel=function(e){return t.getViewOfSeriesModel(e)},n.prototype.getMainProcessVersion=function(){return t[O_]},n}(Ug))(t)},ob=function(t){function e(t,e){for(var n=0;n=0)){Eb.push(n);var o=vy.wrapStageHandler(n,r);o.__prio=e,o.__raw=n,t.push(o)}}function Nb(t,e){xb[t]=e}var Ob=function(t){var e=(t=mn(t)).type;e||Fs("");var n=e.split(":");2!==n.length&&Fs("");var i=!1;"echarts"===n[0]&&(e=n[1],i=!0),t.__isBuiltIn=i,tm.set(e,t)};function Rb(t,e,n,i){return{eventContent:{selected:ch(n),isFromClick:e.isFromClick||!1}}}function $b(t){return null==t?0:t.length||1}function Hb(t){return t}Lb(L_,hy),Lb(E_,py),Lb(E_,fy),Lb(L_,Fy),Lb(E_,By),Lb(7e3,function(t,e){t.eachRawSeries(function(n){if(!t.isSeriesFiltered(n)){var i=n.getData();i.hasItemVisual()&&i.each(function(t){var n=i.getItemVisual(t,"decal");n&&(i.ensureUniqueItemVisual(t,"style").decal=M_(n,e))});var r=i.getVisual("decal");if(r)i.getVisual("style").decal=M_(r,e)}})}),Ib(pv),Db(900,function(t){var e=ei();t.eachSeries(function(t){var n=t.get("stack");if(n){var i=e.get(n)||e.set(n,[]),r=t.getData(),o={stackResultDimension:r.getCalculationInfo("stackResultDimension"),stackedOverDimension:r.getCalculationInfo("stackedOverDimension"),stackedDimension:r.getCalculationInfo("stackedDimension"),stackedByDimension:r.getCalculationInfo("stackedByDimension"),isStackedByIndex:r.getCalculationInfo("isStackedByIndex"),data:r,seriesModel:t};if(!o.stackedDimension||!o.isStackedByIndex&&!o.stackedByDimension)return;i.push(o)}}),e.each(function(t){0!==t.length&&("seriesDesc"===(t[0].seriesModel.get("stackOrder")||"seriesAsc")&&t.reverse(),Cn(t,function(e,n){e.data.setCalculationInfo("stackedOnSeries",n>0?t[n-1].seriesModel:null)}),function(t){Cn(t,function(e,n){var i=[],r=[NaN,NaN],o=[e.stackResultDimension,e.stackedOverDimension],a=e.data,s=e.isStackedByIndex,l=e.seriesModel.get("stackStrategy")||"samesign";a.modify(o,function(o,c,u){var h,d,p=a.get(e.stackedDimension,u);if(isNaN(p))return r;s?d=a.getRawIndex(u):h=a.get(e.stackedByDimension,u);for(var f=NaN,g=n-1;g>=0;g--){var v=t[g];if(s||(d=v.data.rawIndexOf(v.stackedByDimension,h)),d>=0){var m=v.data.getByRawIndex(v.stackResultDimension,d);if("all"===l||"positive"===l&&m>0||"negative"===l&&m<0||"samesign"===l&&p>=0&&m>0||"samesign"===l&&p<=0&&m<0){p=Ts(p,m),f=m;break}}}return i[0]=p,i[1]=f,i})})}(t))})}),Nb("default",function(t,e){bn(e=e||{},{text:"loading",textColor:sg.color.primary,fontSize:12,fontWeight:"normal",fontStyle:"normal",fontFamily:"sans-serif",maskColor:"rgba(255,255,255,0.8)",showSpinner:!0,color:sg.color.theme[0],spinnerRadius:10,lineWidth:5,zlevel:0});var n=new ds,i=new su({style:{fill:e.maskColor},zlevel:e.zlevel,z:1e4});n.add(i);var r,o=new hu({style:{text:e.text,fill:e.textColor,fontSize:e.fontSize,fontWeight:e.fontWeight,fontStyle:e.fontStyle,fontFamily:e.fontFamily},zlevel:e.zlevel,z:10001}),a=new su({style:{fill:"none"},textContent:o,textConfig:{position:"right",distance:10},zlevel:e.zlevel,z:10001});return n.add(a),e.showSpinner&&((r=new vd({shape:{startAngle:-gy/2,endAngle:-gy/2+.1,r:e.spinnerRadius},style:{stroke:e.color,lineCap:"round",lineWidth:e.lineWidth},zlevel:e.zlevel,z:10001})).animateShape(!0).when(1e3,{endAngle:3*gy/2}).start("circularInOut"),r.animateShape(!0).when(1e3,{startAngle:3*gy/2}).delay(300).start("circularInOut"),n.add(r)),n.resize=function(){var n=o.getBoundingRect().width,s=e.showSpinner?e.spinnerRadius:0,l=(t.getWidth()-2*s-(e.showSpinner&&n?10:0)-n)/2-(e.showSpinner&&n?0:5+n/2)+(e.showSpinner?0:n/2)+(n?0:s),c=t.getHeight()/2;e.showSpinner&&r.setShape({cx:l,cy:c}),a.setShape({x:l-s,y:c-s,width:2*s,height:2*s}),i.setShape({x:0,y:0,width:t.getWidth(),height:t.getHeight()})},n.resize(),n}),Pb({type:Pu,event:Pu,update:Pu},ri),Pb({type:Lu,event:Lu,update:Lu},ri),Pb({type:Eu,event:Ou,update:Eu,action:ri,refineEvent:Rb,publishNonRefinedEvent:!0}),Pb({type:zu,event:Ou,update:zu,action:ri,refineEvent:Rb,publishNonRefinedEvent:!0}),Pb({type:Nu,event:Ou,update:Nu,action:ri,refineEvent:Rb,publishNonRefinedEvent:!0}),Tb("default",{}),Tb("dark",Oy);var Fb=function(){function t(t,e,n,i,r,o){this._old=t,this._new=e,this._oldKeyGetter=n||Hb,this._newKeyGetter=i||Hb,this.context=r,this._diffModeMultiple="multiple"===o}return t.prototype.add=function(t){return this._add=t,this},t.prototype.update=function(t){return this._update=t,this},t.prototype.updateManyToOne=function(t){return this._updateManyToOne=t,this},t.prototype.updateOneToMany=function(t){return this._updateOneToMany=t,this},t.prototype.updateManyToMany=function(t){return this._updateManyToMany=t,this},t.prototype.remove=function(t){return this._remove=t,this},t.prototype.execute=function(){this[this._diffModeMultiple?"_executeMultiple":"_executeOneToOne"]()},t.prototype._executeOneToOne=function(){var t=this._old,e=this._new,n={},i=new Array(t.length),r=new Array(e.length);this._initIndexMap(t,null,i,"_oldKeyGetter"),this._initIndexMap(e,n,r,"_newKeyGetter");for(var o=0;o1){var c=s.shift();1===s.length&&(n[a]=s[0]),this._update&&this._update(c,o)}else 1===l?(n[a]=null,this._update&&this._update(s,o)):this._remove&&this._remove(o)}this._performRestAdd(r,n)},t.prototype._executeMultiple=function(){var t=this._old,e=this._new,n={},i={},r=[],o=[];this._initIndexMap(t,n,r,"_oldKeyGetter"),this._initIndexMap(e,i,o,"_newKeyGetter");for(var a=0;a1&&1===h)this._updateManyToOne&&this._updateManyToOne(c,l),i[s]=null;else if(1===u&&h>1)this._updateOneToMany&&this._updateOneToMany(c,l),i[s]=null;else if(1===u&&1===h)this._update&&this._update(c,l),i[s]=null;else if(u>1&&h>1)this._updateManyToMany&&this._updateManyToMany(c,l),i[s]=null;else if(u>1)for(var d=0;d1)for(var a=0;a30}var Kb,Qb,Jb,tx,ex,nx,ix,rx=On,ox=kn,ax="undefined"==typeof Int32Array?Array:Int32Array,sx=["hasItemOption","_nameList","_idList","_invertedIndicesMap","_dimSummary","userOutput","_rawData","_dimValueGetter","_nameDimIdx","_idDimIdx","_nameRepeatCount"],lx=["_approximateExtent"],cx=function(){function t(t,e){var n;this.type="list",this._dimOmitted=!1,this._nameList=[],this._idList=[],this._visual={},this._layout={},this._itemVisuals=[],this._itemLayouts=[],this._graphicEls=[],this._approximateExtent={},this._calculationInfo={},this.hasItemOption=!1,this.TRANSFERABLE_METHODS=["cloneShallow","downSample","minmaxDownSample","lttbDownSample","map"],this.CHANGABLE_METHODS=["filterSelf","selectRange"],this.DOWNSAMPLE_METHODS=["downSample","minmaxDownSample","lttbDownSample"];var i=!1;jb(t)?(n=t.dimensions,this._dimOmitted=t.isDimensionOmitted(),this._schema=t):(i=!0,n=t),n=n||["x","y"];for(var r={},o=[],a={},s=!1,l={},c=0;c=e)){var n=this._store.getProvider();this._updateOrdinalMeta();var i=this._nameList,r=this._idList;if(n.getSource().sourceFormat===mg&&!n.pure)for(var o=[],a=t;a0},t.prototype.ensureUniqueItemVisual=function(t,e){var n=this._itemVisuals,i=n[t];i||(i=n[t]={});var r=i[e];return null==r&&(Pn(r=this.getVisual(e))?r=r.slice():rx(r)&&(r=_n({},r)),i[e]=r),r},t.prototype.setItemVisual=function(t,e,n){var i=this._itemVisuals[t]||{};this._itemVisuals[t]=i,rx(e)?_n(i,e):i[e]=n},t.prototype.clearAllVisual=function(){this._visual={},this._itemVisuals=[]},t.prototype.setLayout=function(t,e){rx(t)?_n(this._layout,t):this._layout[t]=e},t.prototype.getLayout=function(t){return this._layout[t]},t.prototype.getItemLayout=function(t){return this._itemLayouts[t]},t.prototype.setItemLayout=function(t,e,n){this._itemLayouts[t]=n?_n(this._itemLayouts[t]||{},e):e},t.prototype.clearItemLayouts=function(){this._itemLayouts.length=0},t.prototype.setItemGraphicEl=function(t,e){!function(t,e,n,i){if(i){var r=Cu(i);r.dataIndex=n,r.dataType=e,r.seriesIndex=t,r.ssrType="chart","group"===i.type&&i.traverse(function(i){var r=Cu(i);r.seriesIndex=t,r.dataIndex=n,r.dataType=e,r.ssrType="chart"})}}(this.hostModel&&this.hostModel.seriesIndex,this.dataType,t,e),this._graphicEls[t]=e},t.prototype.getItemGraphicEl=function(t){return this._graphicEls[t]},t.prototype.eachItemGraphicEl=function(t,e){Cn(this._graphicEls,function(n,i){n&&t&&t.call(e,n,i)})},t.prototype.cloneShallow=function(e){return e||(e=new t(this._schema?this._schema:ox(this.dimensions,this._getDimInfo,this),this.hostModel)),ex(e,this),e._store=this._store,e},t.prototype.wrapMethod=function(t,e){var n=this[t];Ln(n)&&(this.__wrappedMethods=this.__wrappedMethods||[],this.__wrappedMethods.push(t),this[t]=function(){var t=n.apply(this,arguments);return e.apply(this,[t].concat(Gn(arguments)))})},t.internalField=(Kb=function(t){var e=t._invertedIndicesMap;Cn(e,function(n,i){var r=t._dimInfos[i],o=r.ordinalMeta,a=t._store;if(o){n=e[i]=new ax(o.categories.length);for(var s=0;s1&&(s+="__ec__"+c),i[e]=s}})),t}();function ux(t,e){xv(t)||(t=Sv(t));var n=(e=e||{}).coordDimensions||[],i=e.dimensionsDefine||t.dimensionsDefine||[],r=ei(),o=[],a=function(t,e,n,i){var r=Math.max(t.dimensionsDetectedCount||1,e.length,n.length,i||0);return Cn(e,function(t){var e;On(t)&&(e=t.dimsDef)&&(r=Math.max(r,e.length))}),r}(t,n,i,e.dimensionsCount),s=e.canOmitUnusedDimensions&&Zb(a),l=i===t.dimensionsDefine,c=l?Yb(t):Xb(i),u=e.encodeDefine;!u&&e.encodeDefaulter&&(u=e.encodeDefaulter(t,a));for(var h=ei(u),d=new sm(a),p=0;p0&&(i.name=r+(o-1)),o++,e.set(r,o)}}(o),new qb({source:t,dimensions:o,fullDimensionCount:a,dimensionOmitted:s})}function hx(t,e,n){if(n||e.hasKey(t)){for(var i=0;e.hasKey(t+i);)i++;t+=i}return e.set(t,!0),t}var dx=function(t){this.coordSysDims=[],this.axisMap=ei(),this.categoryAxisMap=ei(),this.coordSysName=t};var px={cartesian2d:function(t,e,n,i){var r=t.getReferringComponents("xAxis",ol).models[0],o=t.getReferringComponents("yAxis",ol).models[0];e.coordSysDims=["x","y"],n.set("x",r),n.set("y",o),fx(r)&&(i.set("x",r),e.firstCategoryDimIndex=0),fx(o)&&(i.set("y",o),null==e.firstCategoryDimIndex&&(e.firstCategoryDimIndex=1))},singleAxis:function(t,e,n,i){var r=t.getReferringComponents("singleAxis",ol).models[0];e.coordSysDims=["single"],n.set("single",r),fx(r)&&(i.set("single",r),e.firstCategoryDimIndex=0)},polar:function(t,e,n,i){var r=t.getReferringComponents("polar",ol).models[0],o=r.findAxisModel("radiusAxis"),a=r.findAxisModel("angleAxis");e.coordSysDims=["radius","angle"],n.set("radius",o),n.set("angle",a),fx(o)&&(i.set("radius",o),e.firstCategoryDimIndex=0),fx(a)&&(i.set("angle",a),null==e.firstCategoryDimIndex&&(e.firstCategoryDimIndex=1))},geo:function(t,e,n,i){e.coordSysDims=["lng","lat"]},parallel:function(t,e,n,i){var r=t.ecModel,o=r.getComponent("parallel",t.get("parallelIndex")),a=e.coordSysDims=o.dimensions.slice();Cn(o.parallelAxisIndex,function(t,o){var s=r.getComponent("parallelAxis",t),l=a[o];n.set(l,s),fx(s)&&(i.set(l,s),null==e.firstCategoryDimIndex&&(e.firstCategoryDimIndex=o))})},matrix:function(t,e,n,i){var r=t.getReferringComponents("matrix",ol).models[0];e.coordSysDims=["x","y"];var o=r.getDimensionModel("x"),a=r.getDimensionModel("y");n.set("x",o),n.set("y",a),i.set("x",o),i.set("y",a)}};function fx(t){return"category"===t.get("type")}function gx(t,e,n){var i,r,o,a=(n=n||{}).byIndex,s=n.stackedCoordDimension;!function(t){return!jb(t.schema)}(e)?(r=e.schema,i=r.dimensions,o=e.store):i=e;var l,c,u,h,d=!(!t||!t.get("stack"));if(Cn(i,function(t,e){En(t)&&(i[e]=t={name:t}),d&&!t.isExtraCoord&&(a||l||!t.ordinalMeta||(l=t),c||"ordinal"===t.type||"time"===t.type||s&&s!==t.coordDim||(c=t))}),!c||a||l||(a=!0),c){u="__\0ecstackresult_"+t.id,h="__\0ecstackedover_"+t.id,l&&(l.createInvertedIndices=!0);var p=c.coordDim,f=c.type,g=0;Cn(i,function(t){t.coordDim===p&&g++});var v={name:u,coordDim:p,coordDimIndex:g,type:f,isExtraCoord:!0,isCalculationCoord:!0,storeDimIndex:i.length},m={name:h,coordDim:h,coordDimIndex:g+1,type:f,isExtraCoord:!0,isCalculationCoord:!0,storeDimIndex:i.length+1};r?(o&&(v.storeDimIndex=o.ensureCalculationDimension(h,f),m.storeDimIndex=o.ensureCalculationDimension(u,f)),r.appendCalculationDimension(v),r.appendCalculationDimension(m)):(i.push(v),i.push(m))}return{stackedDimension:c&&c.name,stackedByDimension:l&&l.name,isStackedByIndex:a,stackedOverDimension:h,stackResultDimension:u}}function vx(t,e){return!!e&&e===t.getCalculationInfo("stackedDimension")}function mx(t,e,n){n=n||{};var i,r,o=e.getSourceManager();r=(i=o.getSource()).sourceFormat===mg;var a=function(t){var e=t.get("coordinateSystem"),n=new dx(e),i=px[e];if(i)return i(t,n,n.axisMap,n.categoryAxisMap),n}(e),s=function(t,e){var n,i=t.get("coordinateSystem"),r=Bf.get(i);return e&&e.coordSysDims&&(n=kn(e.coordSysDims,function(t){var n={name:t},i=e.axisMap.get(t);if(i){var r=i.get("type");n.type=function(t){return"category"===t?"ordinal":"time"===t?"time":"float"}(r)}return n})),n||(n=r&&(r.getDimensionsInfo?r.getDimensionsInfo():r.dimensions.slice())||["x","y"]),n}(e,a),l=n.useEncodeDefaulter,c=Ln(l)?l:l?An(Dg,s,e):null,u=ux(i,{coordDimensions:s,generateCoord:n.generateCoord,encodeDefine:e.getEncode(),encodeDefaulter:c,canOmitUnusedDimensions:!r}),h=function(t,e,n){var i,r;return n&&Cn(t,function(t,o){var a=t.coordDim,s=n.categoryAxisMap.get(a);s&&(null==i&&(i=o),t.ordinalMeta=s.getOrdinalMeta(),e&&(t.createInvertedIndices=!0)),null!=t.otherDims.itemName&&(r=!0)}),r||null==i||(t[i].otherDims.itemName=0),i}(u.dimensions,n.createInvertedIndices,a),d=r?null:o.getSharedDataStore(u),p=gx(e,{schema:u,store:d}),f=new cx(u,e);f.setCalculationInfo(p);var g=null!=h&&function(t){if(t.sourceFormat===mg){var e=function(t){var e=0;for(;er&&(a=o.interval=r);var s=o.intervalPrecision=xx(a);return function(t,e){!isFinite(t[0])&&(t[0]=e[0]),!isFinite(t[1])&&(t[1]=e[1]),Sx(t,0,e),Sx(t,1,e),t[0]>t[1]&&(t[0]=t[1])}(o.niceTickExtent=[ks(Math.ceil(t[0]/a)*a,s),ks(Math.floor(t[1]/a)*a,s)],t),o}function bx(t){var e=Math.pow(10,Ls(t)),n=t/e;return n?2===n?n=3:3===n?n=5:n*=2:n=1,ks(n*e)}function xx(t){return Ms(t)+2}function Sx(t,e,n){t[e]=Math.max(Math.min(t[e],n[1]),n[0])}function Cx(t,e){return t>=e[0]&&t<=e[1]}var kx=function(){function t(){this.normalize=Mx,this.scale=Tx}return t.prototype.updateMethods=function(t){t.hasBreaks()?(this.normalize=Dn(t.normalize,t),this.scale=Dn(t.scale,t)):(this.normalize=Mx,this.scale=Tx)},t}();function Mx(t,e){return e[1]===e[0]?.5:(t-e[0])/(e[1]-e[0])}function Tx(t,e){return t*(e[1]-e[0])+e[0]}function Ix(t,e,n){var i=Math.log(t);return[Math.log(n?e[0]:Math.max(0,e[0]))/i,Math.log(n?e[1]:Math.max(0,e[1]))/i]}var Dx=function(){function t(t){this._calculator=new kx,this._setting=t||{},this._extent=[1/0,-1/0]}return t.prototype.getSetting=function(t){return this._setting[t]},t.prototype._innerUnionExtent=function(t){var e=this._extent;this._innerSetExtent(t[0]e[1]?t[1]:e[1])},t.prototype.unionExtentFromData=function(t,e){this._innerUnionExtent(t.getApproximateExtent(e))},t.prototype.getExtent=function(){return this._extent.slice()},t.prototype.setExtent=function(t,e){this._innerSetExtent(t,e)},t.prototype._innerSetExtent=function(t,e){var n=this._extent;isNaN(t)||(n[0]=t),isNaN(e)||(n[1]=e),this._brkCtx&&this._brkCtx.update(n)},t.prototype.setBreaksFromOption=function(t){},t.prototype._innerSetBreak=function(t){this._brkCtx&&(this._brkCtx.setBreaks(t),this._calculator.updateMethods(this._brkCtx),this._brkCtx.update(this._extent))},t.prototype._innerGetBreaks=function(){return this._brkCtx?this._brkCtx.breaks:[]},t.prototype.hasBreaks=function(){return!!this._brkCtx&&this._brkCtx.hasBreaks()},t.prototype._getExtentSpanWithBreaks=function(){return this._brkCtx&&this._brkCtx.hasBreaks()?this._brkCtx.getExtentSpan():this._extent[1]-this._extent[0]},t.prototype.isInExtentRange=function(t){return this._extent[0]<=t&&this._extent[1]>=t},t.prototype.isBlank=function(){return this._isBlank},t.prototype.setBlank=function(t){this._isBlank=t},t}();vl(Dx);var Ax=0,Px=function(){function t(t){this.categories=t.categories||[],this._needCollect=t.needCollect,this._deduplication=t.deduplication,this.uid=++Ax,this._onCollect=t.onCollect}return t.createByAxisModel=function(e){var n=e.option,i=n.data,r=i&&kn(i,Lx);return new t({categories:r,needCollect:!r,deduplication:!1!==n.dedplication})},t.prototype.getOrdinal=function(t){return this._getOrCreateMap().get(t)},t.prototype.parseAndCollect=function(t){var e,n=this._needCollect;if(!En(t)&&!n)return t;if(n&&!this._deduplication)return e=this.categories.length,this.categories[e]=t,this._onCollect&&this._onCollect(t,e),e;var i=this._getOrCreateMap();return null==(e=i.get(t))&&(n?(e=this.categories.length,this.categories[e]=t,i.set(t,e),this._onCollect&&this._onCollect(t,e)):e=NaN),e},t.prototype._getOrCreateMap=function(){return this._map||(this._map=ei(this.categories))},t}();function Lx(t){return On(t)&&null!=t.value?t.value:t+""}var Ex=function(t){function e(e){var n=t.call(this,e)||this;n.type="ordinal";var i=n.getSetting("ordinalMeta");return i||(i=new Px({})),Pn(i)&&(i=new Px({categories:kn(i,function(t){return On(t)?t.value:t})})),n._ordinalMeta=i,n._extent=n.getSetting("extent")||[0,i.categories.length-1],n}return y(e,t),e.prototype.parse=function(t){return null==t?NaN:En(t)?this._ordinalMeta.getOrdinal(t):Math.round(t)},e.prototype.contain=function(t){return Cx(t,this._extent)&&t>=0&&t=0&&t=0&&t=t},e.prototype.getOrdinalMeta=function(){return this._ordinalMeta},e.prototype.calcNiceTicks=function(){},e.prototype.calcNiceExtent=function(){},e.type="ordinal",e}(Dx);Dx.registerClass(Ex);var zx=ks,Nx=function(t){function e(){var e=null!==t&&t.apply(this,arguments)||this;return e.type="interval",e._interval=0,e._intervalPrecision=2,e}return y(e,t),e.prototype.parse=function(t){return null==t||""===t?NaN:Number(t)},e.prototype.contain=function(t){return Cx(t,this._extent)},e.prototype.normalize=function(t){return this._calculator.normalize(t,this._extent)},e.prototype.scale=function(t){return this._calculator.scale(t,this._extent)},e.prototype.getInterval=function(){return this._interval},e.prototype.setInterval=function(t){this._interval=t,this._niceExtent=this._extent.slice(),this._intervalPrecision=xx(t)},e.prototype.getTicks=function(t){t=t||{};var e=this._interval,n=this._extent,i=this._niceExtent,r=this._intervalPrecision,o=[];if(!e)return o;t.breakTicks;n[0]=0&&(s=zx(s+l*e,r))}if(o.length>0&&s===o[o.length-1].value)break;if(o.length>1e4)return[]}var c=o.length?o[o.length-1].value:i[1];return n[1]>c&&(t.expandToNicedExtent?o.push({value:zx(c+e,r)}):o.push({value:n[1]})),t.breakTicks,o},e.prototype.getMinorTicks=function(t){for(var e=this.getTicks({expandToNicedExtent:!0}),n=[],i=this.getExtent(),r=1;ri[0]&&h0&&(o=null===o?s:Math.min(o,s))}n[i]=o}}return n}(t),n=[];return Cn(t,function(t){var i,r=t.coordinateSystem.getBaseAxis(),o=r.getExtent();if("category"===r.type)i=r.getBandWidth();else if("value"===r.type||"time"===r.type){var a=r.dim+"_"+r.index,s=e[a],l=Math.abs(o[1]-o[0]),c=r.scale.getExtent(),u=Math.abs(c[1]-c[0]);i=s?l/u*s:l}else{var h=t.getData();i=Math.abs(o[1]-o[0])/h.count()}var d=Ss(t.get("barWidth"),i),p=Ss(t.get("barMaxWidth"),i),f=Ss(t.get("barMinWidth")||(function(t){return t.pipelineContext&&t.pipelineContext.large}(t)?.5:1),i),g=t.get("barGap"),v=t.get("barCategoryGap"),m=t.get("defaultBarGap");n.push({bandWidth:i,barWidth:d,barMaxWidth:p,barMinWidth:f,barGap:g,barCategoryGap:v,defaultBarGap:m,axisKey:Fx(r),stackId:Hx(t)})}),function(t){var e={};Cn(t,function(t,n){var i=t.axisKey,r=t.bandWidth,o=e[i]||{bandWidth:r,remainedWidth:r,autoWidthCount:0,categoryGap:null,gap:t.defaultBarGap||0,stacks:{}},a=o.stacks;e[i]=o;var s=t.stackId;a[s]||o.autoWidthCount++,a[s]=a[s]||{width:0,maxWidth:0};var l=t.barWidth;l&&!a[s].width&&(a[s].width=l,l=Math.min(o.remainedWidth,l),o.remainedWidth-=l);var c=t.barMaxWidth;c&&(a[s].maxWidth=c);var u=t.barMinWidth;u&&(a[s].minWidth=u);var h=t.barGap;null!=h&&(o.gap=h);var d=t.barCategoryGap;null!=d&&(o.categoryGap=d)});var n={};return Cn(e,function(t,e){n[e]={};var i=t.stacks,r=t.bandWidth,o=t.categoryGap;if(null==o){var a=In(i).length;o=Math.max(35-4*a,15)+"%"}var s=Ss(o,r),l=Ss(t.gap,1),c=t.remainedWidth,u=t.autoWidthCount,h=(c-s)/(u+(u-1)*l);h=Math.max(h,0),Cn(i,function(t){var e=t.maxWidth,n=t.minWidth;if(t.width){i=t.width;e&&(i=Math.min(i,e)),n&&(i=Math.max(i,n)),t.width=i,c-=i+l*i,u--}else{var i=h;e&&ei&&(i=n),i!==h&&(t.width=i,c-=i+l*i,u--)}}),h=(c-s)/(u+(u-1)*l),h=Math.max(h,0);var d,p=0;Cn(i,function(t,e){t.width||(t.width=h),d=t,p+=t.width*(1+l)}),d&&(p-=d.width*l);var f=-p/2;Cn(i,function(t,i){n[e][i]=n[e][i]||{bandWidth:r,offset:f,width:t.width},f+=t.width*(1+l)})}),n}(n)}var Vx=function(t){function e(e){var n=t.call(this,e)||this;return n.type="time",n}return y(e,t),e.prototype.getLabel=function(t){var e=this.getSetting("useUTC");return ff(t.value,sf[function(t){switch(t){case"year":case"month":return"day";case"millisecond":return"millisecond";default:return"second"}}(df(this._minLevelUnit))]||sf.second,e,this.getSetting("locale"))},e.prototype.getFormattedLabel=function(t,e,n){var i=this.getSetting("useUTC");return function(t,e,n,i,r){var o=null;if(En(n))o=n;else if(Ln(n)){var a={time:t.time,level:t.time.level},s=null;s&&s.makeAxisLabelFormatterParamBreak(a,t.break),o=n(t.value,e,a)}else{var l=t.time;if(l){var c=n[l.lowerTimeUnit][l.upperTimeUnit];o=c[Math.min(l.level,c.length-1)]||""}else{var u=gf(t.value,r);o=n[u][u][0]}}return ff(new Date(t.value),o,r,i)}(t,e,n,this.getSetting("locale"),i)},e.prototype.getTicks=function(t){var e=this._interval,n=this._extent,i=[];if(!e)return i;var r=this.getSetting("useUTC"),o=gf(n[1],r);i.push({value:n[0],time:{level:0,upperTimeUnit:o,lowerTimeUnit:o}});var a=function(t,e,n,i,r,o){var a=1e4,s=cf,l=0;function c(t,e,n,r,s,c,u){for(var h=function(t,e){var n=new Date(0);n[t](1);var i=n.getTime();n[t](1+e);var r=n.getTime()-i;return function(t,e){return Math.max(0,Math.round((e-t)/r))}}(s,t),d=e,p=new Date(d);da));)if(p[s](p[r]()+t),d=p.getTime(),o){var f=o.calcNiceTickMultiple(d,h);f>0&&(p[s](p[r]()+f*t),d=p.getTime())}u.push({value:d,notAdd:!0})}function u(t,r,o){var a=[],s=!r.length;if(!function(t,e,n,i){return vf(new Date(e),t,i).getTime()===vf(new Date(n),t,i).getTime()}(df(t),i[0],i[1],n)){s&&(r=[{value:Yx(i[0],t,n)},{value:i[1]}]);for(var l=0;l=i[0]&&u<=i[1]&&c(d,u,h,p,f,g,a),"year"===t&&o.length>1&&0===l&&o.unshift({value:o[0].value-d})}}for(l=0;l=i[0]&&_<=i[1]&&p++)}var b=r/e;if(p>1.5*b&&f>b/1.5)break;if(h.push(m),p>b||t===s[g])break}d=[]}}var x=Tn(kn(h,function(t){return Tn(t,function(t){return t.value>=i[0]&&t.value<=i[1]&&!t.notAdd})}),function(t){return t.length>0}),w=[],S=x.length-1;for(g=0;gn&&(this._approxInterval=n);var r=Wx.length,o=Math.min(function(t,e,n,i){for(;n>>1;t[r][1]16?16:t>7.5?7:t>3.5?4:t>1.5?2:1}function Gx(t){return(t/=2592e6)>6?6:t>3?3:t>2?2:1}function qx(t){return(t/=tf)>12?12:t>6?6:t>3.5?4:t>2?2:1}function jx(t,e){return(t/=e?Jp:Qp)>30?30:t>20?20:t>15?15:t>10?10:t>5?5:t>2?2:1}function Xx(t){return Es(t)}function Yx(t,e,n){var i=Math.max(0,xn(lf,e)-1);return vf(new Date(t),lf[i],n).getTime()}Dx.registerClass(Vx);var Zx=ks,Kx=Math.floor,Qx=Math.ceil,Jx=Math.pow,tw=Math.log,ew=function(t){function e(){var e=null!==t&&t.apply(this,arguments)||this;return e.type="log",e.base=10,e._originalScale=new Nx,e}return y(e,t),e.prototype.getTicks=function(e){e=e||{};var n=this._extent.slice(),i=this._originalScale.getExtent(),r=t.prototype.getTicks.call(this,e),o=this.base;return this._originalScale._innerGetBreaks(),kn(r,function(t){var e=t.value,r=null,a=Jx(o,e);return e===n[0]&&this._fixMin?r=i[0]:e===n[1]&&this._fixMax&&(r=i[1]),null!=r&&(a=nw(a,r)),{value:a,break:void 0}},this)},e.prototype._getNonTransBreaks=function(){return this._originalScale._innerGetBreaks()},e.prototype.setExtent=function(e,n){this._originalScale.setExtent(e,n);var i=Ix(this.base,[e,n]);t.prototype.setExtent.call(this,i[0],i[1])},e.prototype.getExtent=function(){var e=this.base,n=t.prototype.getExtent.call(this);n[0]=Jx(e,n[0]),n[1]=Jx(e,n[1]);var i=this._originalScale.getExtent();return this._fixMin&&(n[0]=nw(n[0],i[0])),this._fixMax&&(n[1]=nw(n[1],i[1])),n},e.prototype.unionExtentFromData=function(t,e){this._originalScale.unionExtentFromData(t,e);var n=Ix(this.base,t.getApproximateExtent(e),!0);this._innerUnionExtent(n)},e.prototype.calcNiceTicks=function(t){t=t||10;var e=this._extent.slice(),n=this._getExtentSpanWithBreaks();if(isFinite(n)&&!(n<=0)){var i,r=(i=n,Math.pow(10,Ls(i)));for(t/n*r<=.5&&(r*=10);!isNaN(r)&&Math.abs(r)<1&&Math.abs(r)>0;)r*=10;var o=[Zx(Qx(e[0]/r)*r),Zx(Kx(e[1]/r)*r)];this._interval=r,this._intervalPrecision=xx(r),this._niceExtent=o}},e.prototype.calcNiceExtent=function(e){t.prototype.calcNiceExtent.call(this,e),this._fixMin=e.fixMin,this._fixMax=e.fixMax},e.prototype.contain=function(e){return e=tw(e)/tw(this.base),t.prototype.contain.call(this,e)},e.prototype.normalize=function(e){return e=tw(e)/tw(this.base),t.prototype.normalize.call(this,e)},e.prototype.scale=function(e){return e=t.prototype.scale.call(this,e),Jx(this.base,e)},e.prototype.setBreaksFromOption=function(t){},e.type="log",e}(Nx);function nw(t,e){return Zx(t,Ms(e))}Dx.registerClass(ew);var iw=function(){function t(t,e,n){this._prepareParams(t,e,n)}return t.prototype._prepareParams=function(t,e,n){n[1]0&&s>0&&!l&&(a=0),a<0&&s<0&&!c&&(s=0));var h=this._determinedMin,d=this._determinedMax;return null!=h&&(a=h,l=!0),null!=d&&(s=d,c=!0),{min:a,max:s,minFixed:l,maxFixed:c,isBlank:u}},t.prototype.modifyDataMinMax=function(t,e){this[ow[t]]=e},t.prototype.setDeterminedMinMax=function(t,e){this[rw[t]]=e},t.prototype.freeze=function(){this.frozen=!0},t}(),rw={min:"_determinedMin",max:"_determinedMax"},ow={min:"_dataMin",max:"_dataMax"};function aw(t,e){return null==e?null:Bn(e)?NaN:t.parse(e)}function sw(t,e){var n=t.type,i=function(t,e,n){var i=t.rawExtentInfo;return i||(i=new iw(t,e,n),t.rawExtentInfo=i,i)}(t,e,t.getExtent()).calculate();t.setBlank(i.isBlank);var r=i.min,o=i.max,a=e.ecModel;if(a&&"time"===n){var s=function(t,e){var n=[];return e.eachSeriesByType(t,function(t){(function(t){return t.coordinateSystem&&"cartesian2d"===t.coordinateSystem.type})(t)&&n.push(t)}),n}("bar",a),l=!1;if(Cn(s,function(t){l=l||t.getBaseAxis()===e.axis}),l){var c=Bx(s),u=function(t,e,n,i){var r=n.axis.getExtent(),o=Math.abs(r[1]-r[0]),a=function(t,e){if(t&&e)return t[Fx(e)]}(i,n.axis);if(void 0===a)return{min:t,max:e};var s=1/0;Cn(a,function(t){s=Math.min(t.offset,s)});var l=-1/0;Cn(a,function(t){l=Math.max(t.offset+t.width,l)}),s=Math.abs(s),l=Math.abs(l);var c=s+l,u=e-t,h=u/(1-(s+l)/o)-u;return e+=h*(l/c),t-=h*(s/c),{min:t,max:e}}(r,o,e,c);r=u.min,o=u.max}}return{extent:[r,o],fixMin:i.minFixed,fixMax:i.maxFixed}}function lw(t,e){var n=e,i=sw(t,n),r=i.extent,o=n.get("splitNumber");t instanceof ew&&(t.base=n.get("logBase"));var a=t.type,s=n.get("interval"),l="interval"===a||"time"===a;t.setBreaksFromOption(vw(n)),t.setExtent(r[0],r[1]),t.calcNiceExtent({splitNumber:o,fixMin:i.fixMin,fixMax:i.fixMax,minInterval:l?n.get("minInterval"):null,maxInterval:l?n.get("maxInterval"):null}),null!=s&&t.setInterval&&t.setInterval(s)}function cw(t){var e=t.getLabelModel().get("formatter");if("time"===t.type){var n=uf(e);return function(e,i){return t.scale.getFormattedLabel(e,i,n)}}if(En(e))return function(n){var i=t.scale.getLabel(n);return e.replace("{value}",null!=i?i:"")};if(Ln(e)){if("category"===t.type)return function(n,i){return e(uw(t,n),n.value-t.scale.getExtent()[0],null)};var i=null;return function(n,r){var o=null;return i&&(o=i.makeAxisLabelFormatterParamBreak(o,n.break)),e(uw(t,n),r,o)}}return function(e){return t.scale.getLabel(e)}}function uw(t,e){return"category"===t.type?t.scale.getLabel(e):e.value}function hw(t){var e=t.get("interval");return null==e?"auto":e}function dw(t){return"category"===t.type&&0===hw(t.getLabelModel())}function pw(t,e){var n={};return Cn(t.mapDimensionsAll(e),function(e){n[function(t,e){return vx(t,e)?t.getCalculationInfo("stackResultDimension"):e}(t,e)]=!0}),In(n)}function fw(t){return"middle"===t||"center"===t}function gw(t){return t.getShallow("show")}function vw(t){t.get("breaks",!0)}var mw=function(){function t(){}return t.prototype.getNeedCrossZero=function(){return!this.option.scale},t.prototype.getCoordSysModel=function(){},t}(),yw=[],_w={registerPreprocessor:Ib,registerProcessor:Db,registerPostInit:function(t){Ab("afterinit",t)},registerPostUpdate:function(t){Ab("afterupdate",t)},registerUpdateLifecycle:Ab,registerAction:Pb,registerCoordinateSystem:function(t,e){Bf.register(t,e)},registerLayout:function(t,e){zb(_b,t,e,1e3,"layout")},registerVisual:Lb,registerTransform:Ob,registerLoading:Nb,registerMap:function(t,e,n){var i=P_["registerMap"];i&&i(t,e,n)},registerImpl:function(t,e){P_[t]=e},PRIORITY:z_,ComponentModel:ag,ComponentView:Um,SeriesModel:Om,ChartView:Xm,registerComponentModel:function(t){ag.registerClass(t)},registerComponentView:function(t){Um.registerClass(t)},registerSeriesModel:function(t){Om.registerClass(t)},registerChartView:function(t){Xm.registerClass(t)},registerCustomSeries:function(t,e){},registerSubTypeDefaulter:function(t,e){ag.registerSubTypeDefaulter(t,e)},registerPainter:function(t,e){var n;n=e,ps[t]=n}};function bw(t){Pn(t)?Cn(t,function(t){bw(t)}):xn(yw,t)>=0||(yw.push(t),Ln(t)&&(t={install:t}),t.install(_w))}var xw=el(),ww=el(),Sw=1,Cw=2;function kw(t){return{out:{noPxChangeTryDetermine:[]},kind:t}}function Mw(t,e){var n=kn(e,function(e){return t.scale.parse(e)});return"time"===t.type&&n.length>0&&(n.sort(),n.unshift(n[0]),n.push(n[n.length-1])),n}function Tw(t,e){var n=t.getLabelModel().get("customValues");if(n){var i=cw(t),r=t.scale.getExtent();return{labels:kn(Tn(Mw(t,n),function(t){return t>=r[0]&&t<=r[1]}),function(e){var n={value:e};return{formattedLabel:i(n),rawLabel:t.scale.getLabel(n),tickValue:e,time:void 0,break:void 0}})}}return"category"===t.type?function(t,e){var n=t.getLabelModel(),i=Dw(t,n,e);return!n.get("show")||t.scale.isBlank()?{labels:[]}:i}(t,e):function(t){var e=t.scale.getTicks(),n=cw(t);return{labels:kn(e,function(e,i){return{formattedLabel:n(e,i),rawLabel:t.scale.getLabel(e),tickValue:e.value,time:e.time,break:e.break}})}}(t)}function Iw(t,e,n){var i=t.getTickModel().get("customValues");if(i){var r=t.scale.getExtent();return{ticks:Tn(Mw(t,i),function(t){return t>=r[0]&&t<=r[1]})}}return"category"===t.type?function(t,e){var n,i,r=Aw(t),o=hw(e),a=Ew(r,o);if(a)return a;e.get("show")&&!t.scale.isBlank()||(n=[]);if(Ln(o))n=$w(t,o,!0);else if("auto"===o){var s=Dw(t,t.getLabelModel(),kw(Cw));i=s.labelCategoryInterval,n=kn(s.labels,function(t){return t.tickValue})}else n=Rw(t,i=o,!0);return zw(r,o,{ticks:n,tickCategoryInterval:i})}(t,e):{ticks:kn(t.scale.getTicks(n),function(t){return t.value})}}function Dw(t,e,n){var i,r,o=Pw(t),a=hw(e),s=n.kind===Sw;if(!s){var l=Ew(o,a);if(l)return l}Ln(a)?i=$w(t,a):(r="auto"===a?function(t,e){if(e.kind===Sw){var n=t.calculateCategoryInterval(e);return e.out.noPxChangeTryDetermine.push(function(){return ww(t).autoInterval=n,!0}),n}var i=ww(t).autoInterval;return null!=i?i:ww(t).autoInterval=t.calculateCategoryInterval(e)}(t,n):a,i=Rw(t,r));var c={labels:i,labelCategoryInterval:r};return s?n.out.noPxChangeTryDetermine.push(function(){return zw(o,a,c),!0}):zw(o,a,c),c}var Aw=Lw("axisTick"),Pw=Lw("axisLabel");function Lw(t){return function(e){return ww(e)[t]||(ww(e)[t]={list:[]})}}function Ew(t,e){for(var n=0;ne&&i.axisExtent0===r[0]&&i.axisExtent1===r[1])return o;i.lastTickCount=n,i.lastAutoInterval=e,i.axisExtent0=r[0],i.axisExtent1=r[1]}function Rw(t,e,n){var i=cw(t),r=t.scale,o=r.getExtent(),a=t.getLabelModel(),s=[],l=Math.max((e||0)+1,1),c=o[0],u=r.count();0!==c&&l>1&&u/l>2&&(c=Math.round(Math.ceil(c/l)*l));var h=dw(t),d=a.get("showMinLabel")||h,p=a.get("showMaxLabel")||h;d&&c!==o[0]&&g(o[0]);for(var f=c;f<=o[1];f+=l)g(f);function g(t){var e={value:t};s.push(n?t:{formattedLabel:i(e),rawLabel:r.getLabel(e),tickValue:t,time:void 0,break:void 0})}return p&&f-l!==o[1]&&g(o[1]),s}function $w(t,e,n){var i=t.scale,r=cw(t),o=[];return Cn(i.getTicks(),function(t){var a=i.getLabel(t),s=t.value;e(t.value,a)&&o.push(n?s:{formattedLabel:r(t),rawLabel:a,tickValue:s,time:void 0,break:void 0})}),o}var Hw=[0,1],Fw=function(){function t(t,e,n){this.onBand=!1,this.inverse=!1,this.dim=t,this.scale=e,this._extent=n||[0,0]}return t.prototype.contain=function(t){var e=this._extent,n=Math.min(e[0],e[1]),i=Math.max(e[0],e[1]);return t>=n&&t<=i},t.prototype.containData=function(t){return this.scale.contain(this.scale.parse(t))},t.prototype.getExtent=function(){return this._extent.slice()},t.prototype.getPixelPrecision=function(t){return function(t,e){var n=Math.log,i=Math.LN10,r=Math.floor(n(t[1]-t[0])/i),o=Math.round(n(xs(e[1]-e[0]))/i),a=Math.min(Math.max(-r+o,0),20);return isFinite(a)?a:20}(t||this.scale.getExtent(),this._extent)},t.prototype.setExtent=function(t,e){var n=this._extent;n[0]=t,n[1]=e},t.prototype.dataToCoord=function(t,e){var n=this._extent,i=this.scale;return t=i.normalize(i.parse(t)),this.onBand&&"ordinal"===i.type&&Bw(n=n.slice(),i.count()),ws(t,Hw,n,e)},t.prototype.coordToData=function(t,e){var n=this._extent,i=this.scale;this.onBand&&"ordinal"===i.type&&Bw(n=n.slice(),i.count());var r=ws(t,n,Hw,e);return this.scale.scale(r)},t.prototype.pointToData=function(t,e){},t.prototype.getTicksCoords=function(t){var e=(t=t||{}).tickModel||this.getTickModel(),n=kn(Iw(this,e,{breakTicks:t.breakTicks,pruneByBreak:t.pruneByBreak}).ticks,function(t){return{coord:this.dataToCoord("ordinal"===this.scale.type?this.scale.getRawOrdinalNumber(t):t),tickValue:t}},this);return function(t,e,n,i){var r=e.length;if(!t.onBand||n||!r)return;var o,a,s=t.getExtent();if(1===r)e[0].coord=s[0],e[0].onBand=!0,o=e[1]={coord:s[1],tickValue:e[0].tickValue,onBand:!0};else{var l=e[r-1].tickValue-e[0].tickValue,c=(e[r-1].coord-e[0].coord)/l;Cn(e,function(t){t.coord-=c/2,t.onBand=!0});var u=t.scale.getExtent();a=1+u[1]-e[r-1].tickValue,o={coord:e[r-1].coord+c*a,tickValue:u[1]+1,onBand:!0},e.push(o)}var h=s[0]>s[1];d(e[0].coord,s[0])&&(i?e[0].coord=s[0]:e.shift());i&&d(s[0],e[0].coord)&&e.unshift({coord:s[0],onBand:!0});d(s[1],o.coord)&&(i?o.coord=s[1]:e.pop());i&&d(o.coord,s[1])&&e.push({coord:s[1],onBand:!0});function d(t,e){return t=ks(t),e=ks(e),h?t>e:t0&&t<100||(t=5),kn(this.scale.getMinorTicks(t),function(t){return kn(t,function(t){return{coord:this.dataToCoord(t),tickValue:t}},this)},this)},t.prototype.getViewLabels=function(t){return Tw(this,t=t||kw(Cw)).labels},t.prototype.getLabelModel=function(){return this.model.getModel("axisLabel")},t.prototype.getTickModel=function(){return this.model.getModel("axisTick")},t.prototype.getBandWidth=function(){var t=this._extent,e=this.scale.getExtent(),n=e[1]-e[0]+(this.onBand?1:0);0===n&&(n=1);var i=Math.abs(t[1]-t[0]);return Math.abs(i)/n},t.prototype.calculateCategoryInterval=function(t){return function(t,e){var n=e.kind,i=function(t){var e=t.getLabelModel();return{axisRotate:t.getRotate?t.getRotate():t.isHorizontal&&!t.isHorizontal()?90:0,labelRotate:e.get("rotate")||0,font:e.getFont()}}(t),r=cw(t),o=(i.axisRotate-i.labelRotate)/180*Math.PI,a=t.scale,s=a.getExtent(),l=a.count();if(s[1]-s[0]<1)return 0;var c=1;l>40&&(c=Math.max(1,Math.floor(l/40)));for(var u=s[0],h=t.dataToCoord(u+1)-t.dataToCoord(u),d=Math.abs(h*Math.cos(o)),p=Math.abs(h*Math.sin(o)),f=0,g=0;u<=s[1];u+=c){var v,m,y=Ya(r({value:u}),i.font,"center","top");v=1.3*y.width,m=1.3*y.height,f=Math.max(f,v,7),g=Math.max(g,m,7)}var _=f/d,b=g/p;isNaN(_)&&(_=1/0),isNaN(b)&&(b=1/0);var x=Math.max(0,Math.floor(Math.min(_,b)));if(n===Sw)return e.out.noPxChangeTryDetermine.push(Dn(Nw,null,t,x,l)),x;var w=Ow(t,x,l);return null!=w?w:x}(this,t=t||kw(Cw))},t}();function Bw(t,e){var n=(t[1]-t[0])/e/2;t[0]+=n,t[1]-=n}var Vw=["label","labelLine","layoutOption","priority","defaultAttr","marginForce","minMarginForce","marginDefault","suggestIgnore"];function Ww(t,e,n){n=n||3,e?t.dirty|=n:t.dirty&=~n}function Uw(t,e){return e=e||3,null==t.dirty||!!(t.dirty&e)}function Gw(t){if(t)return Uw(t)&&function(t,e,n){var i=e.getComputedTransform();t.transform=dp(t.transform,i);var r=t.localRect=hp(t.localRect,e.getBoundingRect()),o=e.style,a=o.margin,s=n&&n.marginForce,l=n&&n.minMarginForce,c=n&&n.marginDefault,u=o.__marginType;null==u&&c&&(a=c,u=Ap.textMargin);for(var h=0;h<4;h++)qw[h]=u===Ap.minMargin&&l&&null!=l[h]?l[h]:s&&null!=s[h]?s[h]:a?a[h]:0;u===Ap.textMargin&&ip(r,qw,!1,!1);var d=t.rect=hp(t.rect,r);i&&d.applyTransform(i);u===Ap.minMargin&&ip(d,qw,!1,!1);t.axisAligned=cp(i),(t.label=t.label||{}).ignore=e.ignore,Ww(t,!1),Ww(t,!0,2)}(t,t.label,t),t}var qw=[0,0,0,0];function jw(t,e){for(var n=0;n-1&&(s.style.stroke=s.style.fill,s.style.fill=sg.color.neutral00,s.style.lineWidth=2),e},e.type="series.line",e.dependencies=["grid","polar"],e.defaultOption={z:3,coordinateSystem:"cartesian2d",legendHoverLink:!0,clip:!0,label:{position:"top"},endLabel:{show:!1,valueAnimation:!0,distance:8},lineStyle:{width:2,type:"solid"},emphasis:{scale:!0},step:!1,smooth:!1,smoothMonotone:null,symbol:"emptyCircle",symbolSize:6,symbolRotate:null,showSymbol:!0,showAllSymbol:"auto",connectNulls:!1,sampling:"none",animationEasing:"linear",progressive:0,hoverLayerThreshold:1/0,universalTransition:{divideShape:"clone"},triggerLineEvent:!1},e}(Om);function Kw(t,e){var n=t.mapDimensionsAll("defaultedLabel"),i=n.length;if(1===i){var r=Fv(t,e,n[0]);return null!=r?r+"":null}if(i){for(var o=[],a=0;a0?+d:1;M.scaleX=this._sizeX*T,M.scaleY=this._sizeY*T,this.setSymbolScale(1),hh(this,l,c,u)},e.prototype.setSymbolScale=function(t){this.scaleX=this.scaleY=t},e.prototype.fadeOut=function(t,e,n){var i=this.childAt(0),r=Cu(this).dataIndex,o=n&&n.animation;if(this.silent=i.silent=!0,n&&n.fadeLabel){var a=i.getTextContent();a&&Rd(a,{style:{opacity:0}},e,{dataIndex:r,removeOpt:o,cb:function(){i.removeTextContent()}})}else i.removeTextContent();Rd(i,{style:{opacity:0},scaleX:0,scaleY:0},e,{dataIndex:r,cb:t,removeOpt:o})},e.getSymbolSize=function(t,e){return Pn(n=t.getItemVisual(e,"symbolSize"))||(n=[+n,+n]),[n[0]||0,n[1]||0];var n},e.getSymbolZ2=function(t,e){return t.getItemVisual(e,"z2")},e}(ds);function Jw(t,e){this.parent.drift(t,e)}function tS(t,e,n,i){return e&&!isNaN(e[0])&&!isNaN(e[1])&&!(i.isIgnore&&i.isIgnore(n))&&!(i.clipShape&&!i.clipShape.contain(e[0],e[1]))&&"none"!==t.getItemVisual(n,"symbol")}function eS(t){return null==t||On(t)||(t={isIgnore:t}),t||{}}function nS(t){var e=t.hostModel,n=e.getModel("emphasis");return{emphasisItemStyle:n.getModel("itemStyle").getItemStyle(),blurItemStyle:e.getModel(["blur","itemStyle"]).getItemStyle(),selectItemStyle:e.getModel(["select","itemStyle"]).getItemStyle(),focus:n.get("focus"),blurScope:n.get("blurScope"),emphasisDisabled:n.get("disabled"),hoverScale:n.get("scale"),labelStatesModels:bp(e),cursorStyle:e.get("cursor")}}var iS=function(){function t(t){this.group=new ds,this._SymbolCtor=t||Qw}return t.prototype.updateData=function(t,e){this._progressiveEls=null,e=eS(e);var n=this.group,i=t.hostModel,r=this._data,o=this._SymbolCtor,a=e.disableAnimation,s=nS(t),l={disableAnimation:a},c=e.getSymbolPoint||function(e){return t.getItemLayout(e)};r||n.removeAll(),t.diff(r).add(function(i){var r=c(i);if(tS(t,r,i,e)){var a=new o(t,i,s,l);a.setPosition(r),t.setItemGraphicEl(i,a),n.add(a)}}).update(function(u,h){var d=r.getItemGraphicEl(h),p=c(u);if(tS(t,p,u,e)){var f=t.getItemVisual(u,"symbol")||"circle",g=d&&d.getSymbolType&&d.getSymbolType();if(!d||g&&g!==f)n.remove(d),(d=new o(t,u,s,l)).setPosition(p);else{d.updateData(t,u,s,l);var v={x:p[0],y:p[1]};a?d.attr(v):zd(d,v,i)}n.add(d),t.setItemGraphicEl(u,d)}else n.remove(d)}).remove(function(t){var e=r.getItemGraphicEl(t);e&&e.fadeOut(function(){n.remove(e)},i)}).execute(),this._getSymbolPoint=c,this._data=t},t.prototype.updateLayout=function(){var t=this,e=this._data;e&&e.eachItemGraphicEl(function(e,n){var i=t._getSymbolPoint(n);e.setPosition(i),e.markRedraw()})},t.prototype.incrementalPrepareUpdate=function(t){this._seriesScope=nS(t),this._data=null,this.group.removeAll()},t.prototype.incrementalUpdate=function(t,e,n){function i(t){t.isGroup||(t.incremental=!0,t.ensureState("emphasis").hoverLayer=!0)}this._progressiveEls=[],n=eS(n);for(var r=t.start;r0?n=i[0]:i[1]<0&&(n=i[1]);return n}(r,n),a=i.dim,s=r.dim,l=e.mapDimension(s),c=e.mapDimension(a),u="x"===s||"radius"===s?1:0,h=kn(t.dimensions,function(t){return e.mapDimension(t)}),d=!1,p=e.getCalculationInfo("stackResultDimension");return vx(e,h[0])&&(d=!0,h[0]=p),vx(e,h[1])&&(d=!0,h[1]=p),{dataDimsForPoint:h,valueStart:o,valueAxisDim:s,baseAxisDim:a,stacked:!!d,valueDim:l,baseDim:c,baseDataOffset:u,stackedOverDimension:e.getCalculationInfo("stackedOverDimension")}}function oS(t,e,n,i){var r=NaN;t.stacked&&(r=n.get(n.getCalculationInfo("stackedOverDimension"),i)),isNaN(r)&&(r=t.valueStart);var o=t.baseDataOffset,a=[];return a[o]=n.get(t.baseDim,i),a[1-o]=r,e.dataToPoint(a)}var aS=Math.min,sS=Math.max;function lS(t,e){return isNaN(t)||isNaN(e)}function cS(t,e,n,i,r,o,a,s,l){for(var c,u,h,d,p,f,g=n,v=0;v=r||g<0)break;if(lS(m,y)){if(l){g+=o;continue}break}if(g===n)t[o>0?"moveTo":"lineTo"](m,y),h=m,d=y;else{var _=m-c,b=y-u;if(_*_+b*b<.5){g+=o;continue}if(a>0){for(var x=g+o,w=e[2*x],S=e[2*x+1];w===m&&S===y&&v=i||lS(w,S))p=m,f=y;else{M=w-c,T=S-u;var A=m-c,P=w-m,L=y-u,E=S-y,z=void 0,N=void 0;if("x"===s){var O=M>0?1:-1;p=m-O*(z=Math.abs(A))*a,f=y,I=m+O*(N=Math.abs(P))*a,D=y}else if("y"===s){var R=T>0?1:-1;p=m,f=y-R*(z=Math.abs(L))*a,I=m,D=y+R*(N=Math.abs(E))*a}else z=Math.sqrt(A*A+L*L),p=m-M*a*(1-(k=(N=Math.sqrt(P*P+E*E))/(N+z))),f=y-T*a*(1-k),D=y+T*a*k,I=aS(I=m+M*a*k,sS(w,m)),D=aS(D,sS(S,y)),I=sS(I,aS(w,m)),f=y-(T=(D=sS(D,aS(S,y)))-y)*z/N,p=aS(p=m-(M=I-m)*z/N,sS(c,m)),f=aS(f,sS(u,y)),I=m+(M=m-(p=sS(p,aS(c,m))))*N/z,D=y+(T=y-(f=sS(f,aS(u,y))))*N/z}t.bezierCurveTo(h,d,p,f,m,y),h=I,d=D}else t.lineTo(m,y)}c=m,u=y,g+=o}return v}var uS=function(){this.smooth=0,this.smoothConstraint=!0},hS=function(t){function e(e){var n=t.call(this,e)||this;return n.type="ec-polyline",n}return y(e,t),e.prototype.getDefaultStyle=function(){return{stroke:sg.color.neutral99,fill:null}},e.prototype.getDefaultShape=function(){return new uS},e.prototype.buildPath=function(t,e){var n=e.points,i=0,r=n.length/2;if(e.connectNulls){for(;r>0&&lS(n[2*r-2],n[2*r-1]);r--);for(;i=0){var v=a?(u-i)*g+i:(c-n)*g+n;return a?[t,v]:[v,t]}n=c,i=u;break;case o.C:c=r[l++],u=r[l++],h=r[l++],d=r[l++],p=r[l++],f=r[l++];var m=a?Yr(n,c,h,p,t,s):Yr(i,u,d,f,t,s);if(m>0)for(var y=0;y=0){v=a?jr(i,u,d,f,_):jr(n,c,h,p,_);return a?[t,v]:[v,t]}}n=p,i=f}}},e}(Yc),dS=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return y(e,t),e}(uS),pS=function(t){function e(e){var n=t.call(this,e)||this;return n.type="ec-polygon",n}return y(e,t),e.prototype.getDefaultShape=function(){return new dS},e.prototype.buildPath=function(t,e){var n=e.points,i=e.stackedOnPoints,r=0,o=n.length/2,a=e.smoothMonotone;if(e.connectNulls){for(;o>0&&lS(n[2*o-2],n[2*o-1]);o--);for(;r=0&&t<=1){var i=t*(e.length-1),r=Math.floor(i),o=Math.ceil(i),a=So(e[r]),s=So(e[o]),l=i-r,c=To([ho(mo(a[0],s[0],l)),ho(mo(a[1],s[1],l)),ho(mo(a[2],s[2],l)),po(mo(a[3],s[3],l))],"rgba");return n?{color:c,leftIndex:r,rightIndex:o,value:i}:c}}((n-i)/(e.coord-i),[t.color,e.color])}}for(var s=0;se){i?r.push(a(i,l,e)):n&&r.push(a(n,l,0),a(n,l,e));break}n&&(r.push(a(n,l,0)),n=null),r.push(l),i=l}}return r}function bS(t,e,n){var i=t.get("showAllSymbol"),r="auto"===i;if(!i||r){var o=n.getAxesByScale("ordinal")[0];if(o&&(!r||!function(t,e){var n=t.getExtent(),i=Math.abs(n[1]-n[0])/t.scale.count();isNaN(i)&&(i=0);for(var r=e.count(),o=Math.max(1,Math.round(r/5)),a=0;ai)return!1;return!0}(o,e))){var a=e.mapDimension(o.dim),s={};return Cn(o.getViewLabels(),function(t){var e=o.scale.getRawOrdinalNumber(t.tickValue);s[e]=1}),function(t){return!s.hasOwnProperty(e.get(a,t))}}}}function xS(t,e){return isNaN(t)||isNaN(e)}function wS(t,e){return[t[2*e],t[2*e+1]]}function SS(t){if(t.get(["endLabel","show"]))return!0;for(var e=0;e=0;a--){var s=t.getDimensionInfo(i[a].dimension);if("x"===(r=s&&s.coordDim)||"y"===r){o=i[a];break}}if(o){var l=e.getAxis(r),c=kn(o.stops,function(t){return{coord:l.toGlobalCoord(l.dataToCoord(t.value)),color:t.color}}),u=c.length,h=o.outerColors.slice();u&&c[0].coord>c[u-1].coord&&(c.reverse(),h.reverse());var d=_S(c,"x"===r?n.getWidth():n.getHeight()),p=d.length;if(!p&&u)return c[0].coord<0?h[1]?h[1]:c[u-1].color:h[0]?h[0]:c[0].color;var f=d[0].coord-10,g=d[p-1].coord+10,v=g-f;if(v<.001)return"transparent";Cn(d,function(t){t.offset=(t.coord-f)/v}),d.push({offset:p?d[p-1].offset:.5,color:h[1]||"transparent"}),d.unshift({offset:p?d[0].offset:.5,color:h[0]||"transparent"});var m=new _d(0,0,0,0,d,!0);return m[r]=f,m[r+"2"]=g,m}}}(o,i,n)||o.getVisual("style")[o.getVisual("drawType")];if(d&&u.type===i.type&&k===this._step){v&&!p?p=this._newPolygon(l,_):p&&!v&&(f.remove(p),p=this._polygon=null),c||this._initOrUpdateEndLabel(t,i,$f(M));var T=f.getClipPath();if(T)Nd(T,{shape:CS(this,i,!1,t).shape},t);else f.setClipPath(CS(this,i,!0,t));b&&h.updateData(o,{isIgnore:w,clipShape:C,disableAnimation:!0,getSymbolPoint:function(t){return[l[2*t],l[2*t+1]]}}),fS(this._stackedOnPoints,_)&&fS(this._points,l)||(g?this._doUpdateAnimation(o,_,i,n,k,m,x):(k&&(_&&(_=yS(_,l,i,k,x)),l=yS(l,null,i,k,x)),d.setShape({points:l}),p&&p.setShape({points:l,stackedOnPoints:_})))}else b&&h.updateData(o,{isIgnore:w,clipShape:C,disableAnimation:!0,getSymbolPoint:function(t){return[l[2*t],l[2*t+1]]}}),g&&this._initSymbolLabelAnimation(o,i,C),k&&(_&&(_=yS(_,l,i,k,x)),l=yS(l,null,i,k,x)),d=this._newPolyline(l),v?p=this._newPolygon(l,_):p&&(f.remove(p),p=this._polygon=null),c||this._initOrUpdateEndLabel(t,i,$f(M)),f.setClipPath(CS(this,i,!0,t));var I=t.getModel("emphasis"),D=I.get("focus"),A=I.get("blurScope"),P=I.get("disabled");(d.useStyle(bn(a.getLineStyle(),{fill:"none",stroke:M,lineJoin:"bevel"})),fh(d,t,"lineStyle"),d.style.lineWidth>0&&"bolder"===t.get(["emphasis","lineStyle","width"]))&&(d.getState("emphasis").style.lineWidth=+d.style.lineWidth+1);Cu(d).seriesIndex=t.seriesIndex,hh(d,D,A,P);var L=mS(t.get("smooth")),E=t.get("smoothMonotone");if(d.setShape({smooth:L,smoothMonotone:E,connectNulls:x}),p){var z=o.getCalculationInfo("stackedOnSeries"),N=0;p.useStyle(bn(s.getAreaStyle(),{fill:M,opacity:.7,lineJoin:"bevel",decal:o.getVisual("style").decal})),z&&(N=mS(z.get("smooth"))),p.setShape({smooth:L,stackedOnSmooth:N,smoothMonotone:E,connectNulls:x}),fh(p,t,"areaStyle"),Cu(p).seriesIndex=t.seriesIndex,hh(p,D,A,P)}var O=this._changePolyState;o.eachItemGraphicEl(function(t){t&&(t.onHoverStateChange=O)}),this._polyline.onHoverStateChange=O,this._data=o,this._coordSys=i,this._stackedOnPoints=_,this._points=l,this._step=k,this._valueOrigin=m,t.get("triggerLineEvent")&&(this.packEventData(t,d),p&&this.packEventData(t,p))},e.prototype.packEventData=function(t,e){Cu(e).eventData={componentType:"series",componentSubType:"line",componentIndex:t.componentIndex,seriesIndex:t.seriesIndex,seriesName:t.name,seriesType:"line"}},e.prototype.highlight=function(t,e,n,i){var r=t.getData(),o=tl(r,i);if(this._changePolyState("emphasis"),!(o instanceof Array)&&null!=o&&o>=0){var a=r.getLayout("points"),s=r.getItemGraphicEl(o);if(!s){var l=a[2*o],c=a[2*o+1];if(isNaN(l)||isNaN(c))return;if(this._clipShapeForSymbol&&!this._clipShapeForSymbol.contain(l,c))return;var u=t.get("zlevel")||0,h=t.get("z")||0;(s=new Qw(r,o)).x=l,s.y=c,s.setZ(u,h);var d=s.getSymbolPath().getTextContent();d&&(d.zlevel=u,d.z=h,d.z2=this._polyline.z2+1),s.__temp=!0,r.setItemGraphicEl(o,s),s.stopSymbolAnimation(!0),this.group.add(s)}s.highlight()}else Xm.prototype.highlight.call(this,t,e,n,i)},e.prototype.downplay=function(t,e,n,i){var r=t.getData(),o=tl(r,i);if(this._changePolyState("normal"),null!=o&&o>=0){var a=r.getItemGraphicEl(o);a&&(a.__temp?(r.setItemGraphicEl(o,null),this.group.remove(a)):a.downplay())}else Xm.prototype.downplay.call(this,t,e,n,i)},e.prototype._changePolyState=function(t){var e=this._polygon;ju(this._polyline,t),e&&ju(e,t)},e.prototype._newPolyline=function(t){var e=this._polyline;return e&&this._lineGroup.remove(e),e=new hS({shape:{points:t},segmentIgnoreThreshold:2,z2:10}),this._lineGroup.add(e),this._polyline=e,e},e.prototype._newPolygon=function(t,e){var n=this._polygon;return n&&this._lineGroup.remove(n),n=new pS({shape:{points:t,stackedOnPoints:e},segmentIgnoreThreshold:2}),this._lineGroup.add(n),this._polygon=n,n},e.prototype._initSymbolLabelAnimation=function(t,e,n){var i,r,o=e.getBaseAxis(),a=o.inverse;"cartesian2d"===e.type?(i=o.isHorizontal(),r=!1):"polar"===e.type&&(i="angle"===o.dim,r=!0);var s=t.hostModel,l=s.get("animationDuration");Ln(l)&&(l=l(null));var c=s.get("animationDelay")||0,u=Ln(c)?c(null):c;t.eachItemGraphicEl(function(t,o){var s=t;if(s){var h=[t.x,t.y],d=void 0,p=void 0,f=void 0;if(n)if(r){var g=n,v=e.pointToCoord(h);i?(d=g.startAngle,p=g.endAngle,f=-v[1]/180*Math.PI):(d=g.r0,p=g.r,f=v[0])}else{var m=n;i?(d=m.x,p=m.x+m.width,f=t.x):(d=m.y+m.height,p=m.y,f=t.y)}var y=p===d?0:(f-d)/(p-d);a&&(y=1-y);var _=Ln(c)?c(o):l*y+u,b=s.getSymbolPath(),x=b.getTextContent();s.attr({scaleX:0,scaleY:0}),s.animateTo({scaleX:1,scaleY:1},{duration:200,setToFinal:!0,delay:_}),x&&x.animateFrom({style:{opacity:0}},{duration:300,delay:_}),b.disableLabelAnimation=!0}})},e.prototype._initOrUpdateEndLabel=function(t,e,n){var i=t.getModel("endLabel");if(SS(t)){var r=t.getData(),o=this._polyline,a=r.getLayout("points");if(!a)return o.removeTextContent(),void(this._endLabel=null);var s=this._endLabel;s||((s=this._endLabel=new hu({z2:200})).ignoreClip=!0,o.setTextContent(this._endLabel),o.disableLabelAnimation=!0);var l=function(t){for(var e=t.length/2;e>0&&xS(t[2*e-2],t[2*e-1]);e--);return e-1}(a);l>=0&&(_p(o,bp(t,"endLabel"),{inheritColor:n,labelFetcher:t,labelDataIndex:l,defaultText:function(t,e,n){return null!=n?function(t,e){var n=t.mapDimensionsAll("defaultedLabel");if(!Pn(e))return e+"";for(var i=[],r=0;r=0&&i.push(e[o])}return i.join(" ")}(r,n):Kw(r,t)},enableTextSetter:!0},function(t,e){var n=e.getBaseAxis(),i=n.isHorizontal(),r=n.inverse,o=i?r?"right":"left":"center",a=i?"middle":r?"top":"bottom";return{normal:{align:t.get("align")||o,verticalAlign:t.get("verticalAlign")||a}}}(i,e)),o.textConfig.position=null)}else this._endLabel&&(this._polyline.removeTextContent(),this._endLabel=null)},e.prototype._endLabelOnDuring=function(t,e,n,i,r,o,a){var s=this._endLabel,l=this._polyline;if(s){t<1&&null==i.originalX&&(i.originalX=s.x,i.originalY=s.y);var c=n.getLayout("points"),u=n.hostModel,h=u.get("connectNulls"),d=o.get("precision"),p=o.get("distance")||0,f=a.getBaseAxis(),g=f.isHorizontal(),v=f.inverse,m=e.shape,y=v?g?m.x:m.y+m.height:g?m.x+m.width:m.y,_=(g?p:0)*(v?-1:1),b=(g?0:-p)*(v?-1:1),x=g?"x":"y",w=function(t,e,n){for(var i,r,o=t.length/2,a="x"===n?0:1,s=0,l=-1,c=0;c=e||i>=e&&r<=e){l=c;break}s=c,i=r}else i=r;return{range:[s,l],t:(e-i)/(r-i)}}(c,y,x),S=w.range,C=S[1]-S[0],k=void 0;if(C>=1){if(C>1&&!h){var M=wS(c,S[0]);s.attr({x:M[0]+_,y:M[1]+b}),r&&(k=u.getRawValue(S[0]))}else{(M=l.getPointOn(y,x))&&s.attr({x:M[0]+_,y:M[1]+b});var T=u.getRawValue(S[0]),I=u.getRawValue(S[1]);r&&(k=function(t,e,n,i,r){var o=null==e||"auto"===e;if(null==i)return i;if(Nn(i))return ks(f=Bs(n||0,i,r),o?Math.max(Ms(n||0),Ms(i)):e);if(En(i))return r<1?n:i;for(var a=[],s=n,l=i,c=Math.max(s?s.length:0,l.length),u=0;u0?S[0]:0;M=wS(c,D);r&&(k=u.getRawValue(D)),s.attr({x:M[0]+_,y:M[1]+b})}if(r){var A=Dp(s);"function"==typeof A.setLabelText&&A.setLabelText(k)}}},e.prototype._doUpdateAnimation=function(t,e,n,i,r,o,a){var s=this._polyline,l=this._polygon,c=t.hostModel,u=function(t,e,n,i,r,o,a){for(var s=function(t,e){var n=[];return e.diff(t).add(function(t){n.push({cmd:"+",idx:t})}).update(function(t,e){n.push({cmd:"=",idx:e,idx1:t})}).remove(function(t){n.push({cmd:"-",idx:t})}).execute(),n}(t,e),l=[],c=[],u=[],h=[],d=[],p=[],f=[],g=rS(r,e,a),v=t.getLayout("points")||[],m=e.getLayout("points")||[],y=0;y3e3||l&&vS(d,f)>3e3)return s.stopAnimation(),s.setShape({points:p}),void(l&&(l.stopAnimation(),l.setShape({points:p,stackedOnPoints:f})));s.shape.__points=u.current,s.shape.points=h;var g={shape:{points:p}};u.current!==h&&(g.shape.__points=u.next),s.stopAnimation(),zd(s,g,c),l&&(l.setShape({points:h,stackedOnPoints:d}),l.stopAnimation(),zd(l,{shape:{stackedOnPoints:f}},c),s.shape.points!==l.shape.points&&(l.shape.points=s.shape.points));for(var v=[],m=u.status,y=0;ye&&(e=t[n]);return isFinite(e)?e:NaN},min:function(t){for(var e=1/0,n=0;ne[1]&&e.reverse(),e},e.prototype.pointToData=function(t,e){return this.coordToData(this.toLocalCoord(t["x"===this.dim?0:1]),e)},e.prototype.setCategorySortInfo=function(t){if("category"!==this.type)return!1;this.model.option.categorySortInfo=t,this.scale.setSortInfo(t)},e}(Fw),US="expandAxisBreak",GS=Math.PI,qS=[[1,2,1,2],[5,3,5,3],[8,3,8,3]],jS=[[0,1,0,1],[0,3,0,3],[0,3,0,3]],XS=el(),YS=el(),ZS=function(){function t(t){this.recordMap={},this.resolveAxisNameOverlap=t}return t.prototype.ensureRecord=function(t){var e=t.axis.dim,n=t.componentIndex,i=this.recordMap,r=i[e]||(i[e]=[]);return r[n]||(r[n]={ready:{}})},t}();var KS=[1,0,0,1,0,0],QS=new cr(0,0,0,0),JS=function(t,e,n,i,r,o){if(fw(t.nameLocation)){var a=o.stOccupiedRect;a&&tC(function(t,e,n){return t.transform=dp(t.transform,n),t.localRect=hp(t.localRect,e),t.rect=hp(t.rect,e),n&&t.rect.applyTransform(n),t.axisAligned=cp(n),t.obb=void 0,(t.label=t.label||{}).ignore=!1,t}({},a,o.transGroup.transform),i,r)}else eC(o.labelInfoList,o.dirVec,i,r)};function tC(t,e,n){var i=new Yi;Yw(t,e,i,{direction:Math.atan2(n.y,n.x),bidirectional:!1,touchThreshold:.05})&&function(t,e){if(t){t.label.x+=e.x,t.label.y+=e.y,t.label.markRedraw();var n=t.transform;n&&(n[4]+=e.x,n[5]+=e.y);var i=t.rect;i&&(i.x+=e.x,i.y+=e.y);var r=t.obb;r&&r.fromBoundingRect(t.localRect,n)}}(e,i)}function eC(t,e,n,i){for(var r=Yi.dot(i,e)>=0,o=0,a=t.length;o0?"top":"bottom",i="center"):Ds(o-GS)?(r=n>0?"bottom":"top",i="center"):(r="middle",i=o>0&&o0?"right":"left":n>0?"left":"right"),{rotation:o,textAlign:i,textVerticalAlign:r}},t.makeAxisEventDataBase=function(t){var e={componentType:t.mainType,componentIndex:t.componentIndex};return e[t.mainType+"Index"]=t.componentIndex,e},t.isLabelSilent=function(t){var e=t.get("tooltip");return t.get("silent")||!(t.get("triggerEvent")||e&&e.show)},t}(),iC=["axisLine","axisTickLabelEstimate","axisTickLabelDetermine","axisName"],rC={axisLine:function(t,e,n,i,r,o,a){var s=i.get(["axisLine","show"]);if("auto"===s&&(s=!0,null!=t.raw.axisLineAutoShow&&(s=!!t.raw.axisLineAutoShow)),s){var l=i.axis.getExtent(),c=o.transform,u=[l[0],0],h=[l[1],0],d=u[0]>h[0];c&&(gi(u,u,c),gi(h,h,c));var p=_n({lineCap:"round"},i.getModel(["axisLine","lineStyle"]).getLineStyle()),f={strokeContainThreshold:t.raw.strokeContainThreshold||5,silent:!0,z2:1,style:p};if(i.get(["axisLine","breakLine"])&&i.axis.scale.hasBreaks())null.buildAxisBreakLine(i,r,o,f);else{var g=new ud(_n({shape:{x1:u[0],y1:u[1],x2:h[0],y2:h[1]}},f));Yd(g.shape,g.style.lineWidth),g.anid="line",r.add(g)}var v=i.get(["axisLine","symbol"]);if(null!=v){var m=i.get(["axisLine","symbolSize"]);En(v)&&(v=[v,v]),(En(m)||Nn(m))&&(m=[m,m]);var y=n_(i.get(["axisLine","symbolOffset"])||0,m),_=m[0],b=m[1];Cn([{rotate:t.rotation+Math.PI/2,offset:y[0],r:0},{rotate:t.rotation-Math.PI/2,offset:y[1],r:Math.sqrt((u[0]-h[0])*(u[0]-h[0])+(u[1]-h[1])*(u[1]-h[1]))}],function(e,n){if("none"!==v[n]&&null!=v[n]){var i=e_(v[n],-_/2,-b/2,_,b,p.stroke,!0),o=e.r+e.offset,a=d?h:u;i.attr({rotation:e.rotate,x:a[0]+o*Math.cos(t.rotation),y:a[1]-o*Math.sin(t.rotation),silent:!0,z2:11}),r.add(i)}})}}},axisTickLabelEstimate:function(t,e,n,i,r,o,a,s){lC(e,r,s)&&oC(t,e,n,i,r,o,a,Sw)},axisTickLabelDetermine:function(t,e,n,i,r,o,a,s){lC(e,r,s)&&oC(t,e,n,i,r,o,a,Cw);var l=function(t,e,n,i){var r=i.axis,o=i.getModel("axisTick"),a=o.get("show");"auto"===a&&(a=!0,null!=t.raw.axisTickAutoShow&&(a=!!t.raw.axisTickAutoShow));if(!a||r.scale.isBlank())return[];for(var s=o.getModel("lineStyle"),l=t.tickDirection*o.get("length"),c=sC(r.getTicksCoords(),n.transform,l,bn(s.getLineStyle(),{stroke:i.get(["axisLine","lineStyle","color"])}),"ticks"),u=0;ui[1],l="start"===e&&!s||"start"!==e&&s;Ds(a-GS/2)?(o=l?"bottom":"top",r="center"):Ds(a-1.5*GS)?(o=l?"top":"bottom",r="center"):(o="middle",r=a<1.5*GS&&a>GS/2?l?"left":"right":l?"right":"left");return{rotation:a,textAlign:r,textVerticalAlign:o}}(t.rotation,u,x||0,f),null!=(b=t.raw.axisNameAvailableWidth)&&(b=Math.abs(b/Math.sin(_.rotation)),!isFinite(b)&&(b=null)));var w=d.getFont(),S=i.get("nameTruncate",!0)||{},C=S.ellipsis,k=Vn(t.raw.nameTruncateMaxWidth,S.maxWidth,b),M=s.nameMarginLevel||0,T=new hu({x:v.x,y:v.y,rotation:_.rotation,silent:nC.isLabelSilent(i),style:xp(d,{text:c,font:w,overflow:"truncate",width:k,ellipsis:C,fill:d.getTextColor()||i.get(["axisLine","lineStyle","color"]),align:d.get("align")||_.textAlign,verticalAlign:d.get("verticalAlign")||_.textVerticalAlign}),z2:1});if(ap({el:T,componentModel:i,itemName:c}),T.__fullText=c,T.anid="name",i.get("triggerEvent")){var I=nC.makeAxisEventDataBase(i);I.targetType="axisName",I.name=c,Cu(T).eventData=I}o.add(T),T.updateTransform(),e.nameEl=T;var D=l.nameLayout=Gw({label:T,priority:T.z2,defaultAttr:{ignore:T.ignore},marginDefault:fw(u)?qS[M]:jS[M]});if(l.nameLocation=u,r.add(T),T.decomposeTransform(),t.shouldNameMoveOverlap&&D){var A=n.ensureRecord(i);n.resolveAxisNameOverlap(t,n,i,D,m,A)}}}};function oC(t,e,n,i,r,o,a,s){cC(e)||function(t,e,n,i,r,o){var a=r.axis,s=Vn(t.raw.axisLabelShow,r.get(["axisLabel","show"])),l=new ds;n.add(l);var c=kw(i);if(!s||a.scale.isBlank())return void uC(e,[],l,c);var u=r.getModel("axisLabel"),h=a.getViewLabels(c),d=(Vn(t.raw.labelRotate,u.get("rotate"))||0)*GS/180,p=nC.innerTextLayout(t.rotation,d,t.labelDirection),f=r.getCategories&&r.getCategories(!0),g=[],v=r.get("triggerEvent"),m=1/0,y=-1/0;Cn(h,function(t,e){var n,i="ordinal"===a.scale.type?a.scale.getRawOrdinalNumber(t.tickValue):t.tickValue,s=t.formattedLabel,c=t.rawLabel,d=u;if(f&&f[i]){var _=f[i];On(_)&&_.textStyle&&(d=new Bp(_.textStyle,u,r.ecModel))}var b=d.getTextColor()||r.get(["axisLine","lineStyle","color"]),x=d.getShallow("align",!0)||p.textAlign,w=Wn(d.getShallow("alignMinLabel",!0),x),S=Wn(d.getShallow("alignMaxLabel",!0),x),C=d.getShallow("verticalAlign",!0)||d.getShallow("baseline",!0)||p.textVerticalAlign,k=Wn(d.getShallow("verticalAlignMinLabel",!0),C),M=Wn(d.getShallow("verticalAlignMaxLabel",!0),C),T=10+((null===(n=t.time)||void 0===n?void 0:n.level)||0);m=Math.min(m,T),y=Math.max(y,T);var I=new hu({x:0,y:0,rotation:0,silent:nC.isLabelSilent(r),z2:T,style:xp(d,{text:s,align:0===e?w:e===h.length-1?S:x,verticalAlign:0===e?k:e===h.length-1?M:C,fill:Ln(b)?b("category"===a.type?c:"value"===a.type?i+"":i,e):b})});I.anid="label_"+i;var D=XS(I);if(D.break=t.break,D.tickValue=i,D.layoutRotation=p.rotation,ap({el:I,componentModel:r,itemName:s,formatterParamsExtra:{isTruncated:function(){return I.isTruncated},value:c,tickIndex:e}}),v){var A=nC.makeAxisEventDataBase(r);A.targetType="axisLabel",A.value=c,A.tickIndex=e,t.break&&(A.break={start:t.break.parsedBreak.vmin,end:t.break.parsedBreak.vmax}),"category"===a.type&&(A.dataIndex=i),Cu(I).eventData=A,t.break&&function(t,e,n,i){n.on("click",function(n){var r={type:US,breaks:[{start:i.parsedBreak.breakOption.start,end:i.parsedBreak.breakOption.end}]};r[t.axis.dim+"AxisIndex"]=t.componentIndex,e.dispatchAction(r)})}(r,o,I,t.break)}g.push(I),l.add(I)});var _=kn(g,function(t){return{label:t,priority:XS(t).break?t.z2+(y-m+1):t.z2,defaultAttr:{ignore:t.ignore}}});uC(e,_,l,c)}(t,e,r,s,i,a);var l=e.labelLayoutList;!function(t,e,n,i){var r=e.get(["axisLabel","margin"]);Cn(n,function(n,o){var a=Gw(n);if(a){var s=a.label,l=XS(s);a.suggestIgnore=s.ignore,s.ignore=!1,Va(hC,dC),hC.x=e.axis.dataToCoord(l.tickValue),hC.y=t.labelOffset+t.labelDirection*r,hC.rotation=l.layoutRotation,i.add(hC),hC.updateTransform(),i.remove(hC),hC.decomposeTransform(),Va(s,hC),s.markRedraw(),Ww(a,!0),Gw(a)}})}(t,i,l,o),t.rotation;var c=t.optionHideOverlap;!function(t,e,n){if(dw(t.axis))return;function i(t,i,r){var o=Gw(e[i]),a=Gw(e[r]);if(o&&a)if(!1===t||o.suggestIgnore)aC(o.label);else if(a.suggestIgnore)aC(a.label);else{var s=.1;if(!n){var l=[0,0,0,0];o=jw({marginForce:l},o),a=jw({marginForce:l},a)}Yw(o,a,null,{touchThreshold:s})&&aC(t?a.label:o.label)}}var r=t.get(["axisLabel","showMinLabel"]),o=t.get(["axisLabel","showMaxLabel"]),a=e.length;i(r,0,1),i(o,a-1,a-2)}(i,l,c),c&&function(t){var e=[];function n(t){if(!t.ignore){var e=t.ensureState("emphasis");null==e.ignore&&(e.ignore=!1)}t.ignore=!0}t.sort(function(t,e){return(e.suggestIgnore?1:0)-(t.suggestIgnore?1:0)||e.priority-t.priority});for(var i=0;i.1?"x":"y",u=a.transGroup[c];if(s.sort(function(t,e){return Math.abs(t.label[c]-u)-Math.abs(e.label[c]-u)}),l&&r){var h=o.getExtent(),d=Math.min(h[0],h[1]),p=Math.max(h[0],h[1])-d;r.union(new cr(d,0,p,1))}a.stOccupiedRect=r,a.labelInfoList=s}(t,n,i,l)}function aC(t){t&&(t.ignore=!0)}function sC(t,e,n,i,r){for(var o=[],a=[],s=[],l=0;l=0;o--){var a=t[+n[o]],s=a.model,l=a.scale;yx(l)&&s.get("alignTicks")&&null==s.get("interval")?r.push(a):(lw(l,s),yx(l)&&(e=a))}r.length&&(e||lw((e=r.pop()).scale,e.model),Cn(r,function(t){!function(t,e,n){var i=Nx.prototype,r=i.getTicks.call(n),o=i.getTicks.call(n,{expandToNicedExtent:!0}),a=r.length-1,s=i.getInterval.call(n),l=sw(t,e),c=l.extent,u=l.fixMin,h=l.fixMax;"log"===t.type&&(c=Ix(t.base,c,!0)),t.setBreaksFromOption(vw(e)),t.setExtent(c[0],c[1]),t.calcNiceExtent({splitNumber:a,fixMin:u,fixMax:h});var d=i.getExtent.call(t);u&&(c[0]=d[0]),h&&(c[1]=d[1]);var p=i.getInterval.call(t),f=c[0],g=c[1];if(u&&h)p=(g-f)/a;else if(u)for(g=c[0]+p*a;gc[0]&&isFinite(f)&&isFinite(c[0]);)p=bx(p),f=c[1]-p*a;else{t.getTicks().length-1>a&&(p=bx(p));var v=p*a;(f=ks((g=Math.ceil(c[1]/p)*p)-v))<0&&c[0]>=0?(f=0,g=ks(v)):g>0&&c[1]<=0&&(g=0,f=-ks(v))}var m=(r[0].value-o[0].value)/s,y=(r[a].value-o[a].value)/s;i.setExtent.call(t,f+p*m,g+p*y),i.setInterval.call(t,p),(m||y)&&i.setNiceExtent.call(t,f+p,g-p)}(t.scale,t.model,e.scale)}))}}this._updateScale(t,this.model),i(n.x),i(n.y);var r={};Cn(n.x,function(t){_C(n,"y",t,r)}),Cn(n.y,function(t){_C(n,"x",t,r)}),this.resize(this.model,e)},t.prototype.resize=function(t,e,n){var i=eg(t,e),r=this._rect=Jf(t.getBoxLayoutParams(),i.refContainer),o=this._axesMap,a=this._coordsList,s=t.get("containLabel");if(xC(o,r),!n){var l=function(t,e,n,i,r){var o=new ZS(kC);return Cn(n,function(n){return Cn(n,function(n){if(gw(n.model)){var a=!i;n.axisBuilder=function(t,e,n,i,r,o){for(var a=fC(t,n),s=!1,l=!1,c=0;c0&&i>0||n<0&&i<0)}(t)}function xC(t,e){Cn(t.x,function(t){return wC(t,e.x,e.width)}),Cn(t.y,function(t){return wC(t,e.y,e.height)})}function wC(t,e,n){var i=[0,n],r=t.inverse?1:0;t.setExtent(i[r],i[1-r]),function(t,e){var n=t.getExtent(),i=n[0]+n[1];t.toGlobalCoord="x"===t.dim?function(t){return t+e}:function(t){return i-t+e},t.toLocalCoord="x"===t.dim?function(t){return t-e}:function(t){return i-t+e}}(t,e)}function SC(t,e,n,i,r,o,a){CC(i,r,Sw,e,!1,a);var s=[0,0,0,0];c(0),c(1),u(i,0,NaN),u(i,1,NaN);var l=null==function(t,e,n){if(t&&e)for(var i=0,r=t.length;i0});return ip(i,s,!0,!0,n),xC(r,i),l;function c(t){Cn(r[Fd[t]],function(e){if(gw(e.model)){var n=o.ensureRecord(e.model),i=n.labelInfoList;if(i)for(var r=0;r0&&!Bn(e)&&e>1e-4&&(t/=e),t}}function CC(t,e,n,i,r,o){var a=n===Cw;Cn(e,function(e){return Cn(e,function(e){gw(e.model)&&(!function(t,e,n){var i=fC(e,n);t.updateCfg(i)}(e.axisBuilder,t,e.model),e.axisBuilder.build(a?{axisTickLabelDetermine:!0}:{axisTickLabelEstimate:!0},{noPxChange:r}))})});var s={x:0,y:0};function l(e){s[Fd[1-e]]=t[Bd[e]]<=.5*o.refContainer[Bd[e]]?0:1-e==1?2:1}l(0),l(1),Cn(e,function(t,e){return Cn(t,function(t){gw(t.model)&&(("all"===i||a)&&t.axisBuilder.build({axisName:!0},{nameMarginLevel:s[e]}),a&&t.axisBuilder.build({axisLine:!0}))})})}var kC=function(t,e,n,i,r,o){var a="x"===n.axis.dim?"y":"x";JS(t,0,0,i,r,o),fw(t.nameLocation)||Cn(e.recordMap[a],function(t){t&&t.labelInfoList&&t.dirVec&&eC(t.labelInfoList,t.dirVec,i,r)})};function MC(t,e){var n={axesInfo:{},seriesInvolved:!1,coordSysAxesInfo:{},coordSysMap:{}};return function(t,e,n){var i=e.getComponent("tooltip"),r=e.getComponent("axisPointer"),o=r.get("link",!0)||[],a=[];Cn(n.getCoordinateSystems(),function(n){if(n.axisPointerEnabled){var s=AC(n.model),l=t.coordSysAxesInfo[s]={};t.coordSysMap[s]=n;var c=n.model.getModel("tooltip",i);if(Cn(n.getAxes(),An(p,!1,null)),n.getTooltipAxes&&i&&c.get("show")){var u="axis"===c.get("trigger"),h="cross"===c.get(["axisPointer","type"]),d=n.getTooltipAxes(c.get(["axisPointer","axis"]));(u||h)&&Cn(d.baseAxes,An(p,!h||"cross",u)),h&&Cn(d.otherAxes,An(p,"cross",!1))}}function p(i,s,u){var h=u.model.getModel("axisPointer",r),d=h.get("show");if(d&&("auto"!==d||i||DC(h))){null==s&&(s=h.get("triggerTooltip")),h=i?function(t,e,n,i,r,o){var a=e.getModel("axisPointer"),s={};Cn(["type","snap","lineStyle","shadowStyle","label","animation","animationDurationUpdate","animationEasingUpdate","z"],function(t){s[t]=mn(a.get(t))}),s.snap="category"!==t.type&&!!o,"cross"===a.get("type")&&(s.type="line");var l=s.label||(s.label={});if(null==l.show&&(l.show=!1),"cross"===r){var c=a.get(["label","show"]);if(l.show=null==c||c,!o){var u=s.lineStyle=a.get("crossStyle");u&&bn(l,u.textStyle)}}return t.model.getModel("axisPointer",new Bp(s,n,i))}(u,c,r,e,i,s):h;var p=h.get("snap"),f=h.get("triggerEmphasis"),g=AC(u.model),v=s||p||"category"===u.type,m=t.axesInfo[g]={key:g,axis:u,coordSys:n,axisPointerModel:h,triggerTooltip:s,triggerEmphasis:f,involveSeries:v,snap:p,useHandle:DC(h),seriesModels:[],linkGroup:null};l[g]=m,t.seriesInvolved=t.seriesInvolved||v;var y=function(t,e){for(var n=e.model,i=e.dim,r=0;r=0||t===e}function IC(t){var e=(t.ecModel.getComponent("axisPointer")||{}).coordSysAxesInfo;return e&&e.axesInfo[AC(t)]}function DC(t){return!!t.get(["handle","show"])}function AC(t){return t.type+"||"+t.id}var PC={},LC=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return y(e,t),e.prototype.render=function(e,n,i,r){this.axisPointerClass&&function(t){var e=IC(t);if(e){var n=e.axisPointerModel,i=e.axis.scale,r=n.option,o=n.get("status"),a=n.get("value");null!=a&&(a=i.parse(a));var s=DC(n);null==o&&(r.status=s?"show":"hide");var l=i.getExtent().slice();l[0]>l[1]&&l.reverse(),(null==a||a>l[1])&&(a=l[1]),aa)return!0;if(o){var s=IC(t).seriesDataCount,l=i.getExtent();return Math.abs(l[0]-l[1])/s>a}return!1}return!0===n},t.prototype.makeElOption=function(t,e,n,i,r){},t.prototype.createPointerEl=function(t,e,n,i){var r=e.pointer;if(r){var o=VC(t).pointerEl=new vp[r.type](WC(e.pointer));t.add(o)}},t.prototype.createLabelEl=function(t,e,n,i){if(e.label){var r=VC(t).labelEl=new hu(WC(e.label));t.add(r),XC(r,i)}},t.prototype.updatePointerEl=function(t,e,n){var i=VC(t).pointerEl;i&&e.pointer&&(i.setStyle(e.pointer.style),n(i,{shape:e.pointer.shape}))},t.prototype.updateLabelEl=function(t,e,n,i){var r=VC(t).labelEl;r&&(r.setStyle(e.label.style),n(r,{x:e.label.x,y:e.label.y}),XC(r,i))},t.prototype._renderHandle=function(t){if(!this._dragging&&this.updateHandleTransform){var e,n=this._axisPointerModel,i=this._api.getZr(),r=this._handle,o=n.getModel("handle"),a=n.get("status");if(!o.get("show")||!a||"hide"===a)return r&&i.remove(r),void(this._handle=null);this._handle||(e=!0,r=this._handle=tp(o.get("icon"),{cursor:"move",draggable:!0,onmousemove:function(t){Hi(t.event)},onmousedown:UC(this._onHandleDragMove,this,0,0),drift:UC(this._onHandleDragMove,this),ondragend:UC(this._onHandleDragEnd,this)}),i.add(r)),ZC(r,n,!1),r.setStyle(o.getItemStyle(null,["color","borderColor","borderWidth","opacity","shadowColor","shadowBlur","shadowOffsetX","shadowOffsetY"]));var s=o.get("size");Pn(s)||(s=[s,s]),r.scaleX=s[0]/2,r.scaleY=s[1]/2,ry(this,"_doDispatchAxisPointer",o.get("throttle")||0,"fixRate"),this._moveHandleToValue(t,e)}},t.prototype._moveHandleToValue=function(t,e){qC(this._axisPointerModel,!e&&this._moveAnimation,this._handle,YC(this.getHandleTransform(t,this._axisModel,this._axisPointerModel)))},t.prototype._onHandleDragMove=function(t,e){var n=this._handle;if(n){this._dragging=!0;var i=this.updateHandleTransform(YC(n),[t,e],this._axisModel,this._axisPointerModel);this._payloadInfo=i,n.stopAnimation(),n.attr(YC(i)),VC(n).lastProp=null,this._doDispatchAxisPointer()}},t.prototype._doDispatchAxisPointer=function(){if(this._handle){var t=this._payloadInfo,e=this._axisModel;this._api.dispatchAction({type:"updateAxisPointer",x:t.cursorPoint[0],y:t.cursorPoint[1],tooltipOption:t.tooltipOption,axesInfo:[{axisDim:e.axis.dim,axisIndex:e.componentIndex}]})}},t.prototype._onHandleDragEnd=function(){if(this._dragging=!1,this._handle){var t=this._axisPointerModel.get("value");this._moveHandleToValue(t),this._api.dispatchAction({type:"hideTip"})}},t.prototype.clear=function(t){this._lastValue=null,this._lastStatus=null;var e=t.getZr(),n=this._group,i=this._handle;e&&n&&(this._lastGraphicKey=null,n&&e.remove(n),i&&e.remove(i),this._group=null,this._handle=null,this._payloadInfo=null),oy(this,"_doDispatchAxisPointer")},t.prototype.doClear=function(){},t.prototype.buildLabel=function(t,e,n){return{x:t[n=n||0],y:t[1-n],width:e[n],height:e[1-n]}},t}();function qC(t,e,n,i){jC(VC(n).lastProp,i)||(VC(n).lastProp=i,e?zd(n,i,t):(n.stopAnimation(),n.attr(i)))}function jC(t,e){if(On(t)&&On(e)){var n=!0;return Cn(e,function(e,i){n=n&&jC(t[i],e)}),!!n}return t===e}function XC(t,e){t[e.get(["label","show"])?"show":"hide"]()}function YC(t){return{x:t.x||0,y:t.y||0,rotation:t.rotation||0}}function ZC(t,e,n){var i=e.get("z"),r=e.get("zlevel");t&&t.traverse(function(t){"group"!==t.type&&(null!=i&&(t.z=i),null!=r&&(t.zlevel=r),t.silent=n)})}function KC(t,e,n,i,r){var o=QC(n.get("value"),e.axis,e.ecModel,n.get("seriesDataIndices"),{precision:n.get(["label","precision"]),formatter:n.get(["label","formatter"])}),a=n.getModel("label"),s=Ef(a.get("padding")||0),l=a.getFont(),c=Ya(o,l),u=r.position,h=c.width+s[1]+s[3],d=c.height+s[0]+s[2],p=r.align;"right"===p&&(u[0]-=h),"center"===p&&(u[0]-=h/2);var f=r.verticalAlign;"bottom"===f&&(u[1]-=d),"middle"===f&&(u[1]-=d/2),function(t,e,n,i){var r=i.getWidth(),o=i.getHeight();t[0]=Math.min(t[0]+e,r)-e,t[1]=Math.min(t[1]+n,o)-n,t[0]=Math.max(t[0],0),t[1]=Math.max(t[1],0)}(u,h,d,i);var g=a.get("backgroundColor");g&&"auto"!==g||(g=e.get(["axisLine","lineStyle","color"])),t.label={x:u[0],y:u[1],style:xp(a,{text:o,font:l,fill:a.getTextColor(),padding:s,backgroundColor:g}),z2:10}}function QC(t,e,n,i,r){t=e.scale.parse(t);var o=e.scale.getLabel({value:t},{precision:r.precision}),a=r.formatter;if(a){var s={value:uw(e,{value:t}),axisDimension:e.dim,axisIndex:e.index,seriesData:[]};Cn(i,function(t){var e=n.getSeriesByIndex(t.seriesIndex),i=t.dataIndexInside,r=e&&e.getDataParams(i);r&&s.seriesData.push(r)}),En(a)?o=a.replace("{value}",o):Ln(a)&&(o=a(s))}return o}function JC(t,e,n){var i=[1,0,0,1,0,0];return ji(i,i,n.rotation),qi(i,i,n.position),Kd([t.dataToCoord(e),(n.labelOffset||0)+(n.labelDirection||1)*(n.labelMargin||0)],i)}var tk=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return y(e,t),e.prototype.makeElOption=function(t,e,n,i,r){var o=n.axis,a=o.grid,s=i.get("type"),l=ek(a,o).getOtherAxis(o).getGlobalExtent(),c=o.toGlobalCoord(o.dataToCoord(e,!0));if(s&&"none"!==s){var u=function(t){var e,n=t.get("type"),i=t.getModel(n+"Style");return"line"===n?(e=i.getLineStyle()).fill=null:"shadow"===n&&((e=i.getAreaStyle()).stroke=null),e}(i),h=nk[s](o,c,l);h.style=u,t.graphicKey=h.type,t.pointer=h}!function(t,e,n,i,r,o){var a=nC.innerTextLayout(n.rotation,0,n.labelDirection);n.labelMargin=r.get(["label","margin"]),KC(e,i,r,o,{position:JC(i.axis,t,n),align:a.textAlign,verticalAlign:a.textVerticalAlign})}(e,t,fC(a.getRect(),n),n,i,r)},e.prototype.getHandleTransform=function(t,e,n){var i=fC(e.axis.grid.getRect(),e,{labelInside:!1});i.labelMargin=n.get(["handle","margin"]);var r=JC(e.axis,t,i);return{x:r[0],y:r[1],rotation:i.rotation+(i.labelDirection<0?Math.PI:0)}},e.prototype.updateHandleTransform=function(t,e,n,i){var r=n.axis,o=r.grid,a=r.getGlobalExtent(!0),s=ek(o,r).getOtherAxis(r).getGlobalExtent(),l="x"===r.dim?0:1,c=[t.x,t.y];c[l]+=e[l],c[l]=Math.min(a[1],c[l]),c[l]=Math.max(a[0],c[l]);var u=(s[1]+s[0])/2,h=[u,u];h[l]=c[l];return{x:c[0],y:c[1],rotation:t.rotation,cursorPoint:h,tooltipOption:[{verticalAlign:"middle"},{align:"center"}][l]}},e}(GC);function ek(t,e){var n={};return n[e.dim+"AxisIndex"]=e.index,t.getCartesian(n)}var nk={line:function(t,e,n){var i,r,o;return{type:"Line",subPixelOptimize:!0,shape:(i=[e,n[0]],r=[e,n[1]],o=ik(t),{x1:i[o=o||0],y1:i[1-o],x2:r[o],y2:r[1-o]})}},shadow:function(t,e,n){var i,r,o,a=Math.max(1,t.getBandWidth()),s=n[1]-n[0];return{type:"Rect",shape:(i=[e-a/2,n[0]],r=[a,s],o=ik(t),{x:i[o=o||0],y:i[1-o],width:r[o],height:r[1-o]})}}};function ik(t){return"x"===t.dim?0:1}var rk=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return y(e,t),e.type="axisPointer",e.defaultOption={show:"auto",z:50,type:"line",snap:!1,triggerTooltip:!0,triggerEmphasis:!0,value:null,status:null,link:[],animation:null,animationDurationUpdate:200,lineStyle:{color:sg.color.border,width:1,type:"dashed"},shadowStyle:{color:sg.color.shadowTint},label:{show:!0,formatter:null,precision:"auto",margin:3,color:sg.color.neutral00,padding:[5,7,5,7],backgroundColor:sg.color.accent60,borderColor:null,borderWidth:0,borderRadius:3},handle:{show:!1,icon:"M10.7,11.9v-1.3H9.3v1.3c-4.9,0.3-8.8,4.4-8.8,9.4c0,5,3.9,9.1,8.8,9.4h1.3c4.9-0.3,8.8-4.4,8.8-9.4C19.5,16.3,15.6,12.2,10.7,11.9z M13.3,24.4H6.7v-1.2h6.6z M13.3,22H6.7v-1.2h6.6z M13.3,19.6H6.7v-1.2h6.6z",size:45,margin:50,color:sg.color.accent40,throttle:40}},e}(ag),ok=el(),ak=Cn;function sk(t,e,n){if(!Ye.node){var i=e.getZr();ok(i).records||(ok(i).records={}),function(t,e){if(ok(t).initialized)return;function n(n,i){t.on(n,function(n){var r=function(t){var e={showTip:[],hideTip:[]},n=function(i){var r=e[i.type];r?r.push(i):(i.dispatchAction=n,t.dispatchAction(i))};return{dispatchAction:n,pendings:e}}(e);ak(ok(t).records,function(t){t&&i(t,n,r.dispatchAction)}),function(t,e){var n,i=t.showTip.length,r=t.hideTip.length;i?n=t.showTip[i-1]:r&&(n=t.hideTip[r-1]);n&&(n.dispatchAction=null,e.dispatchAction(n))}(r.pendings,e)})}ok(t).initialized=!0,n("click",An(ck,"click")),n("mousemove",An(ck,"mousemove")),n("globalout",lk)}(i,e),(ok(i).records[t]||(ok(i).records[t]={})).handler=n}}function lk(t,e,n){t.handler("leave",null,n)}function ck(t,e,n,i){e.handler(t,n,i)}function uk(t,e){if(!Ye.node){var n=e.getZr();(ok(n).records||{})[t]&&(ok(n).records[t]=null)}}var hk=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return y(e,t),e.prototype.render=function(t,e,n){var i=e.getComponent("tooltip"),r=t.get("triggerOn")||i&&i.get("triggerOn")||"mousemove|click";sk("axisPointer",n,function(t,e,n){"none"!==r&&("leave"===t||r.indexOf(t)>=0)&&n({type:"updateAxisPointer",currTrigger:t,x:e&&e.offsetX,y:e&&e.offsetY})})},e.prototype.remove=function(t,e){uk("axisPointer",e)},e.prototype.dispose=function(t,e){uk("axisPointer",e)},e.type="axisPointer",e}(Um);function dk(t,e){var n,i=[],r=t.seriesIndex;if(null==r||!(n=e.getSeriesByIndex(r)))return{point:[]};var o=n.getData(),a=tl(o,t);if(null==a||a<0||Pn(a))return{point:[]};var s=o.getItemGraphicEl(a),l=n.coordinateSystem;if(n.getTooltipPosition)i=n.getTooltipPosition(a)||[];else if(l&&l.dataToPoint)if(t.isStacked){var c=l.getBaseAxis(),u=l.getOtherAxis(c).dim,h=c.dim,d="x"===u||"radius"===u?1:0,p=o.mapDimension(h),f=[];f[d]=o.get(p,a),f[1-d]=o.get(o.getCalculationInfo("stackResultDimension"),a),i=l.dataToPoint(f)||[]}else i=l.dataToPoint(o.getValues(kn(l.dimensions,function(t){return o.mapDimension(t)}),a))||[];else if(s){var g=s.getBoundingRect().clone();g.applyTransform(s.transform),i=[g.x+g.width/2,g.y+g.height/2]}return{point:i,el:s}}var pk=el();function fk(t,e,n){var i=t.currTrigger,r=[t.x,t.y],o=t,a=t.dispatchAction||Dn(n.dispatchAction,n),s=e.getComponent("axisPointer").coordSysAxesInfo;if(s){_k(r)&&(r=dk({seriesIndex:o.seriesIndex,dataIndex:o.dataIndex},e).point);var l=_k(r),c=o.axesInfo,u=s.axesInfo,h="leave"===i||_k(r),d={},p={},f={list:[],map:{}},g={showPointer:An(vk,p),showTooltip:An(mk,f)};Cn(s.coordSysMap,function(t,e){var n=l||t.containPoint(r);Cn(s.coordSysAxesInfo[e],function(t,e){var i=t.axis,o=function(t,e){for(var n=0;n<(t||[]).length;n++){var i=t[n];if(e.axis.dim===i.axisDim&&e.axis.model.componentIndex===i.axisIndex)return i}}(c,t);if(!h&&n&&(!c||o)){var a=o&&o.value;null!=a||l||(a=i.pointToData(r)),null!=a&&gk(t,a,g,!1,d)}})});var v={};return Cn(u,function(t,e){var n=t.linkGroup;n&&!p[e]&&Cn(n.axesInfo,function(e,i){var r=p[i];if(e!==t&&r){var o=r.value;n.mapper&&(o=t.axis.scale.parse(n.mapper(o,yk(e),yk(t)))),v[t.key]=o}})}),Cn(v,function(t,e){gk(u[e],t,g,!0,d)}),function(t,e,n){var i=n.axesInfo=[];Cn(e,function(e,n){var r=e.axisPointerModel.option,o=t[n];o?(!e.useHandle&&(r.status="show"),r.value=o.value,r.seriesDataIndices=(o.payloadBatch||[]).slice()):!e.useHandle&&(r.status="hide"),"show"===r.status&&i.push({axisDim:e.axis.dim,axisIndex:e.axis.model.componentIndex,value:r.value})})}(p,u,d),function(t,e,n,i){if(_k(e)||!t.list.length)return void i({type:"hideTip"});var r=((t.list[0].dataByAxis[0]||{}).seriesDataIndices||[])[0]||{};i({type:"showTip",escapeConnect:!0,x:e[0],y:e[1],tooltipOption:n.tooltipOption,position:n.position,dataIndexInside:r.dataIndexInside,dataIndex:r.dataIndex,seriesIndex:r.seriesIndex,dataByCoordSys:t.list})}(f,r,t,a),function(t,e,n){var i=n.getZr(),r="axisPointerLastHighlights",o=pk(i)[r]||{},a=pk(i)[r]={};Cn(t,function(t,e){var n=t.axisPointerModel.option;"show"===n.status&&t.triggerEmphasis&&Cn(n.seriesDataIndices,function(t){var e=t.seriesIndex+" | "+t.dataIndex;a[e]=t})});var s=[],l=[];Cn(o,function(t,e){!a[e]&&l.push(t)}),Cn(a,function(t,e){!o[e]&&s.push(t)}),l.length&&n.dispatchAction({type:"downplay",escapeConnect:!0,notBlur:!0,batch:l}),s.length&&n.dispatchAction({type:"highlight",escapeConnect:!0,notBlur:!0,batch:s})}(u,0,n),d}}function gk(t,e,n,i,r){var o=t.axis;if(!o.scale.isBlank()&&o.containData(e))if(t.involveSeries){var a=function(t,e){var n=e.axis,i=n.dim,r=t,o=[],a=Number.MAX_VALUE,s=-1;return Cn(e.seriesModels,function(e,l){var c,u,h=e.getData().mapDimensionsAll(i);if(e.getAxisTooltipData){var d=e.getAxisTooltipData(h,t,n);u=d.dataIndices,c=d.nestestValue}else{if(!(u=e.indicesOfNearest(i,h[0],t,"category"===n.type?.5:null)).length)return;c=e.getData().get(h[0],u[0])}if(null!=c&&isFinite(c)){var p=t-c,f=Math.abs(p);f<=a&&((f=0&&s<0)&&(a=f,s=p,r=c,o.length=0),Cn(u,function(t){o.push({seriesIndex:e.seriesIndex,dataIndexInside:t,dataIndex:e.getData().getRawIndex(t)})}))}}),{payloadBatch:o,snapToValue:r}}(e,t),s=a.payloadBatch,l=a.snapToValue;s[0]&&null==r.seriesIndex&&_n(r,s[0]),!i&&t.snap&&o.containData(l)&&null!=l&&(e=l),n.showPointer(t,e,s),n.showTooltip(t,a,l)}else n.showPointer(t,e)}function vk(t,e,n,i){t[e.key]={value:n,payloadBatch:i}}function mk(t,e,n,i){var r=n.payloadBatch,o=e.axis,a=o.model,s=e.axisPointerModel;if(e.triggerTooltip&&r.length){var l=e.coordSys.model,c=AC(l),u=t.map[c];u||(u=t.map[c]={coordSysId:l.id,coordSysIndex:l.componentIndex,coordSysType:l.type,coordSysMainType:l.mainType,dataByAxis:[]},t.list.push(u)),u.dataByAxis.push({axisDim:o.dim,axisIndex:a.componentIndex,axisType:a.type,axisId:a.id,value:i,valueLabelOpt:{precision:s.get(["label","precision"]),formatter:s.get(["label","formatter"])},seriesDataIndices:r.slice()})}}function yk(t){var e=t.axis.model,n={},i=n.axisDim=t.axis.dim;return n.axisIndex=n[i+"AxisIndex"]=e.componentIndex,n.axisName=n[i+"AxisName"]=e.name,n.axisId=n[i+"AxisId"]=e.id,n}function _k(t){return!t||null==t[0]||isNaN(t[0])||null==t[1]||isNaN(t[1])}function bk(t){LC.registerAxisPointerClass("CartesianAxisPointer",tk),t.registerComponentModel(rk),t.registerComponentView(hk),t.registerPreprocessor(function(t){if(t){(!t.axisPointer||0===t.axisPointer.length)&&(t.axisPointer={});var e=t.axisPointer.link;e&&!Pn(e)&&(t.axisPointer.link=[e])}}),t.registerProcessor(t.PRIORITY.PROCESSOR.STATISTIC,function(t,e){t.getComponent("axisPointer").coordSysAxesInfo=MC(t,e)}),t.registerAction({type:"updateAxisPointer",event:"updateAxisPointer",update:":updateAxisPointer"},fk)}var xk=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return y(e,t),e.type="tooltip",e.dependencies=["axisPointer"],e.defaultOption={z:60,show:!0,showContent:!0,trigger:"item",triggerOn:"mousemove|click",alwaysShowContent:!1,renderMode:"auto",confine:null,showDelay:0,hideDelay:100,transitionDuration:.4,displayTransition:!0,enterable:!1,backgroundColor:sg.color.neutral00,shadowBlur:10,shadowColor:"rgba(0, 0, 0, .2)",shadowOffsetX:1,shadowOffsetY:2,borderRadius:4,borderWidth:1,defaultBorderColor:sg.color.border,padding:null,extraCssText:"",axisPointer:{type:"line",axis:"auto",animation:"auto",animationDurationUpdate:200,animationEasingUpdate:"exponentialOut",crossStyle:{color:sg.color.borderShade,width:1,type:"dashed",textStyle:{}}},textStyle:{color:sg.color.tertiary,fontSize:14}},e}(ag);function wk(t){var e=t.get("confine");return null!=e?!!e:"richText"===t.get("renderMode")}function Sk(t){if(Ye.domSupported)for(var e=document.documentElement.style,n=0,i=t.length;n0&&r.push(function(t,e,n){var i="cubic-bezier(0.23,1,0.32,1)",r="",o="";return n&&(o="opacity"+(r=" "+t/2+"s "+i)+",visibility"+r),e||(r=" "+t+"s "+i,o+=(o.length?",":"")+(Ye.transformSupported?""+Tk+r:",left"+r+",top"+r)),Mk+":"+o}(o,n,i)),a&&r.push("background-color:"+a),Cn(["width","color","radius"],function(e){var n="border-"+e,i=Lf(n),o=t.get(i);null!=o&&r.push(n+":"+o+("color"===e?"":"px"))}),r.push(function(t){var e=[],n=t.get("fontSize"),i=t.getTextColor();i&&e.push("color:"+i),e.push("font:"+t.getFont());var r=Wn(t.get("lineHeight"),Math.round(3*n/2));n&&e.push("line-height:"+r+"px");var o=t.get("textShadowColor"),a=t.get("textShadowBlur")||0,s=t.get("textShadowOffsetX")||0,l=t.get("textShadowOffsetY")||0;return o&&a&&e.push("text-shadow:"+s+"px "+l+"px "+a+"px "+o),Cn(["decoration","align"],function(n){var i=t.get(n);i&&e.push("text-"+n+":"+i)}),e.join(";")}(h)),null!=d&&r.push("padding:"+Ef(d).join("px ")+"px"),r.join(";")+";"}function Pk(t,e,n,i,r){var o=e&&e.painter;if(n){var a=o&&o.getViewportRoot();a&&function(t,e,n,i,r){Mi(ki,e,i,r,!0)&&Mi(t,n,ki[0],ki[1])}(t,a,n,i,r)}else{t[0]=i,t[1]=r;var s=o&&o.getViewportRootOffset();s&&(t[0]+=s.offsetLeft,t[1]+=s.offsetTop)}t[2]=t[0]/e.getWidth(),t[3]=t[1]/e.getHeight()}var Lk=function(){function t(t,e){if(this._show=!1,this._styleCoord=[0,0,0,0],this._enterable=!0,this._alwaysShowContent=!1,this._firstShow=!0,this._longHide=!0,Ye.wxa)return null;var n=document.createElement("div");n.domBelongToZr=!0,this.el=n;var i=this._zr=t.getZr(),r=e.appendTo,o=r&&(En(r)?document.querySelector(r):Hn(r)?r:Ln(r)&&r(t.getDom()));Pk(this._styleCoord,i,o,t.getWidth()/2,t.getHeight()/2),(o||t.getDom()).appendChild(n),this._api=t,this._container=o;var a=this;n.onmouseenter=function(){a._enterable&&(clearTimeout(a._hideTimeout),a._show=!0),a._inContent=!0},n.onmousemove=function(t){if(t=t||window.event,!a._enterable){var e=i.handler;Ri(i.painter.getViewportRoot(),t,!0),e.dispatch("mousemove",t)}},n.onmouseleave=function(){a._inContent=!1,a._enterable&&a._show&&a.hideLater(a._hideDelay)}}return t.prototype.update=function(t){if(!this._container){var e=this._api.getDom(),n=(o="position",(a=(r=e).currentStyle||document.defaultView&&document.defaultView.getComputedStyle(r))?a[o]:null),i=e.style;"absolute"!==i.position&&"absolute"!==n&&(i.position="relative")}var r,o,a,s=t.get("alwaysShowContent");s&&this._moveIfResized(),this._alwaysShowContent=s,this._enableDisplayTransition=t.get("displayTransition")&&t.get("transitionDuration")>0,this.el.className=t.get("className")||""},t.prototype.show=function(t,e){clearTimeout(this._hideTimeout),clearTimeout(this._longHideTimeout);var n=this.el,i=n.style,r=this._styleCoord;n.innerHTML?i.cssText=Ik+Ak(t,!this._firstShow,this._longHide,this._enableDisplayTransition)+Dk(r[0],r[1],!0)+"border-color:"+$f(e)+";"+(t.get("extraCssText")||"")+";pointer-events:"+(this._enterable?"auto":"none"):i.display="none",this._show=!0,this._firstShow=!1,this._longHide=!1},t.prototype.setContent=function(t,e,n,i,r){var o=this.el;if(null!=t){var a="";if(En(r)&&"item"===n.get("trigger")&&!wk(n)&&(a=function(t,e,n){if(!En(n)||"inside"===n)return"";var i=t.get("backgroundColor"),r=t.get("borderWidth");e=$f(e);var o,a,s="left"===(o=n)?"right":"right"===o?"left":"top"===o?"bottom":"top",l=Math.max(1.5*Math.round(r),6),c="",u=Tk+":";xn(["left","right"],s)>-1?(c+="top:50%",u+="translateY(-50%) rotate("+(a="left"===s?-225:-45)+"deg)"):(c+="left:50%",u+="translateX(-50%) rotate("+(a="top"===s?225:45)+"deg)");var h=a*Math.PI/180,d=l+r,p=d*Math.abs(Math.cos(h))+d*Math.abs(Math.sin(h)),f=e+" solid "+r+"px;";return'
'}(n,i,r)),En(t))o.innerHTML=t+a;else if(t){o.innerHTML="",Pn(t)||(t=[t]);for(var s=0;s=0?this._tryShow(n,i):"leave"===e&&this._hide(i))},this))},e.prototype._keepShow=function(){var t=this._tooltipModel,e=this._ecModel,n=this._api,i=t.get("triggerOn");if(null!=this._lastX&&null!=this._lastY&&"none"!==i&&"click"!==i){var r=this;clearTimeout(this._refreshUpdateTimeout),this._refreshUpdateTimeout=setTimeout(function(){!n.isDisposed()&&r.manuallyShowTip(t,e,n,{x:r._lastX,y:r._lastY,dataByCoordSys:r._lastDataByCoordSys})})}},e.prototype.manuallyShowTip=function(t,e,n,i){if(i.from!==this.uid&&!Ye.node&&n.getDom()){var r=Fk(i,n);this._ticket="";var o=i.dataByCoordSys,a=function(t,e,n){var i=rl(t).queryOptionMap,r=i.keys()[0];if(!r||"series"===r)return;var o=al(e,r,i.get(r),{useDefault:!1,enableAll:!1,enableNone:!1}),a=o.models[0];if(!a)return;var s,l=n.getViewOfComponentModel(a);if(l.group.traverse(function(e){var n=Cu(e).tooltipConfig;if(n&&n.name===t.name)return s=e,!0}),s)return{componentMainType:r,componentIndex:a.componentIndex,el:s}}(i,e,n);if(a){var s=a.el.getBoundingRect().clone();s.applyTransform(a.el.transform),this._tryShow({offsetX:s.x+s.width/2,offsetY:s.y+s.height/2,target:a.el,position:i.position,positionDefault:"bottom"},r)}else if(i.tooltip&&null!=i.x&&null!=i.y){var l=Rk;l.x=i.x,l.y=i.y,l.update(),Cu(l).tooltipConfig={name:null,option:i.tooltip},this._tryShow({offsetX:i.x,offsetY:i.y,target:l},r)}else if(o)this._tryShow({offsetX:i.x,offsetY:i.y,position:i.position,dataByCoordSys:o,tooltipOption:i.tooltipOption},r);else if(null!=i.seriesIndex){if(this._manuallyAxisShowTip(t,e,n,i))return;var c=dk(i,e),u=c.point[0],h=c.point[1];null!=u&&null!=h&&this._tryShow({offsetX:u,offsetY:h,target:c.el,position:i.position,positionDefault:"bottom"},r)}else null!=i.x&&null!=i.y&&(n.dispatchAction({type:"updateAxisPointer",x:i.x,y:i.y}),this._tryShow({offsetX:i.x,offsetY:i.y,position:i.position,target:n.getZr().findHover(i.x,i.y).target},r))}},e.prototype.manuallyHideTip=function(t,e,n,i){var r=this._tooltipContent;this._tooltipModel&&r.hideLater(this._tooltipModel.get("hideDelay")),this._lastX=this._lastY=this._lastDataByCoordSys=null,i.from!==this.uid&&this._hide(Fk(i,n))},e.prototype._manuallyAxisShowTip=function(t,e,n,i){var r=i.seriesIndex,o=i.dataIndex,a=e.getComponent("axisPointer").coordSysAxesInfo;if(null!=r&&null!=o&&null!=a){var s=e.getSeriesByIndex(r);if(s)if("axis"===Hk([s.getData().getItemModel(o),s,(s.coordinateSystem||{}).model],this._tooltipModel).get("trigger"))return n.dispatchAction({type:"updateAxisPointer",seriesIndex:r,dataIndex:o,position:i.position}),!0}},e.prototype._tryShow=function(t,e){var n=t.target;if(this._tooltipModel){this._lastX=t.offsetX,this._lastY=t.offsetY;var i=t.dataByCoordSys;if(i&&i.length)this._showAxisTooltip(i,t);else if(n){var r,o;if("legend"===Cu(n).ssrType)return;this._lastDataByCoordSys=null,Wy(n,function(t){if(t.tooltipDisabled)return r=o=null,!0;r||o||(null!=Cu(t).dataIndex?r=t:null!=Cu(t).tooltipConfig&&(o=t))},!0),r?this._showSeriesItemTooltip(t,r,e):o?this._showComponentItemTooltip(t,o,e):this._hide(e)}else this._lastDataByCoordSys=null,this._hide(e)}},e.prototype._showOrMove=function(t,e){var n=t.get("showDelay");e=Dn(e,this),clearTimeout(this._showTimout),n>0?this._showTimout=setTimeout(e,n):e()},e.prototype._showAxisTooltip=function(t,e){var n=this._ecModel,i=this._tooltipModel,r=[e.offsetX,e.offsetY],o=Hk([e.tooltipOption],i),a=this._renderMode,s=[],l=wm("section",{blocks:[],noHeader:!0}),c=[],u=new Lm;Cn(t,function(t){Cn(t.dataByAxis,function(t){var e=n.getComponent(t.axisDim+"Axis",t.axisIndex),r=t.value;if(e&&null!=r){var o=QC(r,e.axis,n,t.seriesDataIndices,t.valueLabelOpt),h=wm("section",{header:o,noHeader:!Xn(o),sortBlocks:!0,blocks:[]});l.blocks.push(h),Cn(t.seriesDataIndices,function(l){var d=n.getSeriesByIndex(l.seriesIndex),p=l.dataIndexInside,f=d.getDataParams(p);if(!(f.dataIndex<0)){f.axisDim=t.axisDim,f.axisIndex=t.axisIndex,f.axisType=t.axisType,f.axisId=t.axisId,f.axisValue=uw(e.axis,{value:r}),f.axisValueLabel=o,f.marker=u.makeTooltipMarker("item",$f(f.color),a);var g=Wv(d.formatTooltip(p,!0,null)),v=g.frag;if(v){var m=Hk([d],i).get("valueFormatter");h.blocks.push(m?_n({valueFormatter:m},v):v)}g.text&&c.push(g.text),s.push(f)}})}})}),l.blocks.reverse(),c.reverse();var h=e.position,d=o.get("order"),p=Im(l,u,a,d,n.get("useUTC"),o.get("textStyle"));p&&c.unshift(p);var f="richText"===a?"\n\n":"
",g=c.join(f);this._showOrMove(o,function(){this._updateContentNotChangedOnAxis(t,s)?this._updatePosition(o,h,r[0],r[1],this._tooltipContent,s):this._showTooltipContent(o,g,s,Math.random()+"",r[0],r[1],h,null,u)})},e.prototype._showSeriesItemTooltip=function(t,e,n){var i=this._ecModel,r=Cu(e),o=r.seriesIndex,a=i.getSeriesByIndex(o),s=r.dataModel||a,l=r.dataIndex,c=r.dataType,u=s.getData(c),h=this._renderMode,d=t.positionDefault,p=Hk([u.getItemModel(l),s,a&&(a.coordinateSystem||{}).model],this._tooltipModel,d?{position:d}:null),f=p.get("trigger");if(null==f||"item"===f){var g=s.getDataParams(l,c),v=new Lm;g.marker=v.makeTooltipMarker("item",$f(g.color),h);var m=Wv(s.formatTooltip(l,!1,c)),y=p.get("order"),_=p.get("valueFormatter"),b=m.frag,x=b?Im(_?_n({valueFormatter:_},b):b,v,h,y,i.get("useUTC"),p.get("textStyle")):m.text,w="item_"+s.name+"_"+l;this._showOrMove(p,function(){this._showTooltipContent(p,x,g,w,t.offsetX,t.offsetY,t.position,t.target,v)}),n({type:"showTip",dataIndexInside:l,dataIndex:u.getRawIndex(l),seriesIndex:o,from:this.uid})}},e.prototype._showComponentItemTooltip=function(t,e,n){var i="html"===this._renderMode,r=Cu(e),o=r.tooltipConfig.option||{},a=o.encodeHTMLContent;if(En(o)){o={content:o,formatter:o},a=!0}a&&i&&o.content&&((o=mn(o)).content=Ai(o.content));var s=[o],l=this._ecModel.getComponent(r.componentMainType,r.componentIndex);l&&s.push(l),s.push({formatter:o.content});var c=t.positionDefault,u=Hk(s,this._tooltipModel,c?{position:c}:null),h=u.get("content"),d=Math.random()+"",p=new Lm;this._showOrMove(u,function(){var n=mn(u.get("formatterParams")||{});this._showTooltipContent(u,h,n,d,t.offsetX,t.offsetY,t.position,e,p)}),n({type:"showTip",from:this.uid})},e.prototype._showTooltipContent=function(t,e,n,i,r,o,a,s,l){if(this._ticket="",t.get("showContent")&&t.get("show")){var c=this._tooltipContent;c.setEnterable(t.get("enterable"));var u=t.get("formatter");a=a||t.get("position");var h=e,d=this._getNearestPoint([r,o],n,t.get("trigger"),t.get("borderColor"),t.get("defaultBorderColor",!0)).color;if(u)if(En(u)){var p=t.ecModel.get("useUTC"),f=Pn(n)?n[0]:n;h=u,f&&f.axisType&&f.axisType.indexOf("time")>=0&&(h=ff(f.axisValue,h,p)),h=Rf(h,n,!0)}else if(Ln(u)){var g=Dn(function(e,i){e===this._ticket&&(c.setContent(i,l,t,d,a),this._updatePosition(t,a,r,o,c,n,s))},this);this._ticket=i,h=u(n,i,g)}else h=u;c.setContent(h,l,t,d,a),c.show(t,d),this._updatePosition(t,a,r,o,c,n,s)}},e.prototype._getNearestPoint=function(t,e,n,i,r){return"axis"===n||Pn(e)?{color:i||r}:Pn(e)?void 0:{color:i||e.color||e.borderColor}},e.prototype._updatePosition=function(t,e,n,i,r,o,a){var s=this._api.getWidth(),l=this._api.getHeight();e=e||t.get("position");var c=r.getSize(),u=t.get("align"),h=t.get("verticalAlign"),d=a&&a.getBoundingRect().clone();if(a&&d.applyTransform(a.transform),Ln(e)&&(e=e([n,i],o,r.el,d,{viewSize:[s,l],contentSize:c.slice()})),Pn(e))n=Ss(e[0],s),i=Ss(e[1],l);else if(On(e)){var p=e;p.width=c[0],p.height=c[1];var f=Jf(p,{width:s,height:l});n=f.x,i=f.y,u=null,h=null}else if(En(e)&&a){var g=function(t,e,n,i){var r=n[0],o=n[1],a=Math.ceil(Math.SQRT2*i)+8,s=0,l=0,c=e.width,u=e.height;switch(t){case"inside":s=e.x+c/2-r/2,l=e.y+u/2-o/2;break;case"top":s=e.x+c/2-r/2,l=e.y-o-a;break;case"bottom":s=e.x+c/2-r/2,l=e.y+u+a;break;case"left":s=e.x-r-a,l=e.y+u/2-o/2;break;case"right":s=e.x+c+a,l=e.y+u/2-o/2}return[s,l]}(e,d,c,t.get("borderWidth"));n=g[0],i=g[1]}else{g=function(t,e,n,i,r,o,a){var s=n.getSize(),l=s[0],c=s[1];null!=o&&(t+l+o+2>i?t-=l+o:t+=o);null!=a&&(e+c+a>r?e-=c+a:e+=a);return[t,e]}(n,i,r,s,l,u?null:20,h?null:20);n=g[0],i=g[1]}if(u&&(n-=Bk(u)?c[0]/2:"right"===u?c[0]:0),h&&(i-=Bk(h)?c[1]/2:"bottom"===h?c[1]:0),wk(t)){g=function(t,e,n,i,r){var o=n.getSize(),a=o[0],s=o[1];return t=Math.min(t+a,i)-a,e=Math.min(e+s,r)-s,t=Math.max(t,0),e=Math.max(e,0),[t,e]}(n,i,r,s,l);n=g[0],i=g[1]}r.moveTo(n,i)},e.prototype._updateContentNotChangedOnAxis=function(t,e){var n=this._lastDataByCoordSys,i=this._cbParamsList,r=!!n&&n.length===t.length;return r&&Cn(n,function(n,o){var a=n.dataByAxis||[],s=(t[o]||{}).dataByAxis||[];(r=r&&a.length===s.length)&&Cn(a,function(t,n){var o=s[n]||{},a=t.seriesDataIndices||[],l=o.seriesDataIndices||[];(r=r&&t.value===o.value&&t.axisType===o.axisType&&t.axisId===o.axisId&&a.length===l.length)&&Cn(a,function(t,e){var n=l[e];r=r&&t.seriesIndex===n.seriesIndex&&t.dataIndex===n.dataIndex}),i&&Cn(t.seriesDataIndices,function(t){var n=t.seriesIndex,o=e[n],a=i[n];o&&a&&a.data!==o.data&&(r=!1)})})}),this._lastDataByCoordSys=t,this._cbParamsList=e,!!r},e.prototype._hide=function(t){this._lastDataByCoordSys=null,t({type:"hideTip",from:this.uid})},e.prototype.dispose=function(t,e){!Ye.node&&e.getDom()&&(oy(this,"_updatePosition"),this._tooltipContent.dispose(),uk("itemTooltip",e))},e.type="tooltip",e}(Um);function Hk(t,e,n){var i,r=e.ecModel;n?(i=new Bp(n,r,r),i=new Bp(e.option,i,r)):i=e;for(var o=t.length-1;o>=0;o--){var a=t[o];a&&(a instanceof Bp&&(a=a.get("tooltip",!0)),En(a)&&(a={formatter:a}),a&&(i=new Bp(a,i,r)))}return i}function Fk(t,e){return t.dispatchAction||Dn(e.dispatchAction,e)}function Bk(t){return"center"===t||"middle"===t}var Vk=Math.sin,Wk=Math.cos,Uk=Math.PI,Gk=2*Math.PI,qk=180/Uk,jk=function(){function t(){}return t.prototype.reset=function(t){this._start=!0,this._d=[],this._str="",this._p=Math.pow(10,t||4)},t.prototype.moveTo=function(t,e){this._add("M",t,e)},t.prototype.lineTo=function(t,e){this._add("L",t,e)},t.prototype.bezierCurveTo=function(t,e,n,i,r,o){this._add("C",t,e,n,i,r,o)},t.prototype.quadraticCurveTo=function(t,e,n,i){this._add("Q",t,e,n,i)},t.prototype.arc=function(t,e,n,i,r,o){this.ellipse(t,e,n,n,0,i,r,o)},t.prototype.ellipse=function(t,e,n,i,r,o,a,s){var l=a-o,c=!s,u=Math.abs(l),h=zo(u-Gk)||(c?l>=Gk:-l>=Gk),d=l>0?l%Gk:l%Gk+Gk,p=!1;p=!!h||!zo(u)&&d>=Uk==!!c;var f=t+n*Wk(o),g=e+i*Vk(o);this._start&&this._add("M",f,g);var v=Math.round(r*qk);if(h){var m=1/this._p,y=(c?1:-1)*(Gk-m);this._add("A",n,i,v,1,+c,t+n*Wk(o+y),e+i*Vk(o+y)),m>.01&&this._add("A",n,i,v,0,+c,f,g)}else{var _=t+n*Wk(a),b=e+i*Vk(a);this._add("A",n,i,v,+p,+c,_,b)}},t.prototype.rect=function(t,e,n,i){this._add("M",t,e),this._add("l",n,0),this._add("l",0,i),this._add("l",-n,0),this._add("Z")},t.prototype.closePath=function(){this._d.length>0&&this._add("Z")},t.prototype._add=function(t,e,n,i,r,o,a,s,l){for(var c=[],u=this._p,h=1;h"}(r,o)+("style"!==r?Ai(a):a||"")+(i?""+n+kn(i,function(e){return t(e)}).join(n)+n:"")+("")}(t)}function oM(t){return{zrId:t,shadowCache:{},patternCache:{},gradientCache:{},clipPathCache:{},defs:{},cssNodes:{},cssAnims:{},cssStyleCache:{},cssAnimIdx:0,shadowIdx:0,gradientIdx:0,patternIdx:0,clipPathIdx:0}}function aM(t,e,n,i){return iM("svg","root",{width:t,height:e,xmlns:Jk,"xmlns:xlink":tM,version:"1.1",baseProfile:"full",viewBox:!!i&&"0 0 "+t+" "+e},n)}var sM=0;function lM(){return sM++}var cM={cubicIn:"0.32,0,0.67,0",cubicOut:"0.33,1,0.68,1",cubicInOut:"0.65,0,0.35,1",quadraticIn:"0.11,0,0.5,0",quadraticOut:"0.5,1,0.89,1",quadraticInOut:"0.45,0,0.55,1",quarticIn:"0.5,0,0.75,0",quarticOut:"0.25,1,0.5,1",quarticInOut:"0.76,0,0.24,1",quinticIn:"0.64,0,0.78,0",quinticOut:"0.22,1,0.36,1",quinticInOut:"0.83,0,0.17,1",sinusoidalIn:"0.12,0,0.39,0",sinusoidalOut:"0.61,1,0.88,1",sinusoidalInOut:"0.37,0,0.63,1",exponentialIn:"0.7,0,0.84,0",exponentialOut:"0.16,1,0.3,1",exponentialInOut:"0.87,0,0.13,1",circularIn:"0.55,0,1,0.45",circularOut:"0,0.55,0.45,1",circularInOut:"0.85,0,0.15,1"},uM="transform-origin";function hM(t,e,n){var i=_n({},t.shape);_n(i,e),t.buildPath(n,i);var r=new jk;return r.reset(Uo(t)),n.rebuildPath(r,1),r.generateStr(),r.getStr()}function dM(t,e){var n=e.originX,i=e.originY;(n||i)&&(t[uM]=n+"px "+i+"px")}var pM={fill:"fill",opacity:"opacity",lineWidth:"stroke-width",lineDashOffset:"stroke-dashoffset"};function fM(t,e){var n=e.zrId+"-ani-"+e.cssAnimIdx++;return e.cssAnims[n]=t,n}function gM(t){return En(t)?cM[t]?"cubic-bezier("+cM[t]+")":oo(t)?t:"":""}function vM(t,e,n,i){var r=t.animators,o=r.length,a=[];if(t instanceof md){var s=function(t,e,n){var i,r,o=t.shape.paths,a={};if(Cn(o,function(t){var e=oM(n.zrId);e.animation=!0,vM(t,{},e,!0);var o=e.cssAnims,s=e.cssNodes,l=In(o),c=l.length;if(c){var u=o[r=l[c-1]];for(var h in u){var d=u[h];a[h]=a[h]||{d:""},a[h].d+=d.d||""}for(var p in s){var f=s[p].animation;f.indexOf(r)>=0&&(i=f)}}}),i){e.d=!1;var s=fM(a,n);return i.replace(r,s)}}(t,e,n);if(s)a.push(s);else if(!o)return}else if(!o)return;for(var l={},c=0;c0}).length)return fM(u,n)+" "+r[0]+" both"}for(var v in l){(s=g(l[v]))&&a.push(s)}if(a.length){var m=n.zrId+"-cls-"+lM();n.cssNodes["."+m]={animation:a.join(",")},e.class=m}}function mM(t,e,n,i){var r=JSON.stringify(t),o=n.cssStyleCache[r];o||(o=n.zrId+"-cls-"+lM(),n.cssStyleCache[r]=o,n.cssNodes["."+o+":hover"]=t),e.class=e.class?e.class+" "+o:o}var yM=Math.round;function _M(t){return t&&En(t.src)}function bM(t){return t&&Ln(t.toDataURL)}function xM(t,e,n,i){Qk(function(r,o){var a="fill"===r||"stroke"===r;a&&Vo(o)?LM(e,t,r,i):a&&Ho(o)?EM(n,t,r,i):t[r]=o,a&&i.ssr&&"none"===o&&(t["pointer-events"]="visible")},e,n,!1),function(t,e,n){var i=t.style;if(function(t){return t&&(t.shadowBlur||t.shadowOffsetX||t.shadowOffsetY)}(i)){var r=function(t){var e=t.style,n=t.getGlobalScale();return[e.shadowColor,(e.shadowBlur||0).toFixed(2),(e.shadowOffsetX||0).toFixed(2),(e.shadowOffsetY||0).toFixed(2),n[0],n[1]].join(",")}(t),o=n.shadowCache,a=o[r];if(!a){var s=t.getGlobalScale(),l=s[0],c=s[1];if(!l||!c)return;var u=i.shadowOffsetX||0,h=i.shadowOffsetY||0,d=i.shadowBlur,p=Lo(i.shadowColor),f=p.opacity,g=p.color,v=d/2/l+" "+d/2/c;a=n.zrId+"-s"+n.shadowIdx++,n.defs[a]=iM("filter",a,{id:a,x:"-100%",y:"-100%",width:"300%",height:"300%"},[iM("feDropShadow","",{dx:u/l,dy:h/c,stdDeviation:v,"flood-color":g,"flood-opacity":f})]),o[r]=a}e.filter=Wo(a)}}(n,t,i)}function wM(t,e){var n=function(t){if("function"==typeof gs)return gs(t)}(e);n&&(n.each(function(e,n){null!=e&&(t[(eM+n).toLowerCase()]=e+"")}),e.isSilent()&&(t[eM+"silent"]="true"))}function SM(t){return zo(t[0]-1)&&zo(t[1])&&zo(t[2])&&zo(t[3]-1)}function CM(t,e,n){if(e&&(!function(t){return zo(t[4])&&zo(t[5])}(e)||!SM(e))){var i=1e4;t.transform=SM(e)?"translate("+yM(e[4]*i)/i+" "+yM(e[5]*i)/i+")":function(t){return"matrix("+No(t[0])+","+No(t[1])+","+No(t[2])+","+No(t[3])+","+Oo(t[4])+","+Oo(t[5])+")"}(e)}}function kM(t,e,n){for(var i=t.points,r=[],o=0;o=0&&a||o;s&&(r=Ao(s))}var l=i.lineWidth;l&&(l/=!i.strokeNoScale&&t.transform?t.transform[0]:1);var c={cursor:"pointer"};r&&(c.fill=r),i.stroke&&(c.stroke=i.stroke),l&&(c["stroke-width"]=l),mM(c,e,n)}}(t,o,e),iM(s,t.id+"",o)}function PM(t,e){return t instanceof Yc?AM(t,e):t instanceof tu?function(t,e){var n=t.style,i=n.image;if(i&&!En(i)&&(_M(i)?i=i.src:bM(i)&&(i=i.toDataURL())),i){var r=n.x||0,o=n.y||0,a={href:i,width:n.width,height:n.height};return r&&(a.x=r),o&&(a.y=o),CM(a,t.transform),xM(a,n,t,e),wM(a,t),e.animation&&vM(t,a,e),iM("image",t.id+"",a)}}(t,e):t instanceof Kc?function(t,e){var n=t.style,i=n.text;if(null!=i&&(i+=""),i&&!isNaN(n.x)&&!isNaN(n.y)){var r=n.font||Ke,o=n.x||0,a=function(t,e,n){return"top"===n?t+=e/2:"bottom"===n&&(t-=e/2),t}(n.y||0,Qa(r),n.textBaseline),s={"dominant-baseline":"central","text-anchor":Ro[n.textAlign]||n.textAlign};if(mu(n)){var l="",c=n.fontStyle,u=gu(n.fontSize);if(!parseFloat(u))return;var h=n.fontFamily||Ze,d=n.fontWeight;l+="font-size:"+u+";font-family:"+h+";",c&&"normal"!==c&&(l+="font-style:"+c+";"),d&&"normal"!==d&&(l+="font-weight:"+d+";"),s.style=l}else s.style="font: "+r;return i.match(/\s/)&&(s["xml:space"]="preserve"),o&&(s.x=o),a&&(s.y=a),CM(s,t.transform),xM(s,n,t,e),wM(s,t),e.animation&&vM(t,s,e),iM("text",t.id+"",s,void 0,i)}}(t,e):void 0}function LM(t,e,n,i){var r,o=t[n],a={gradientUnits:o.global?"userSpaceOnUse":"objectBoundingBox"};if(Fo(o))r="linearGradient",a.x1=o.x,a.y1=o.y,a.x2=o.x2,a.y2=o.y2;else{if(!Bo(o))return;r="radialGradient",a.cx=Wn(o.x,.5),a.cy=Wn(o.y,.5),a.r=Wn(o.r,.5)}for(var s=o.colorStops,l=[],c=0,u=s.length;cl?XM(t,null==n[h+1]?null:n[h+1].elm,n,s,h):YM(t,e,a,l))}(n,i,r):UM(r)?(UM(t.text)&&BM(n,""),XM(n,null,r,0,r.length-1)):UM(i)?YM(n,i,0,i.length-1):UM(t.text)&&BM(n,""):t.text!==e.text&&(UM(i)&&YM(n,i,0,i.length-1),BM(n,e.text)))}var QM=0,JM=function(){function t(t,e,n){if(this.type="svg",this.refreshHover=function(){},this.configLayer=function(){},this.storage=e,this._opts=n=_n({},n),this.root=t,this._id="zr"+QM++,this._oldVNode=aM(n.width,n.height),t&&!n.ssr){var i=this._viewport=document.createElement("div");i.style.cssText="position:relative;overflow:hidden";var r=this._svgDom=this._oldVNode.elm=nM("svg");ZM(null,this._oldVNode),i.appendChild(r),t.appendChild(i)}this.resize(n.width,n.height)}return t.prototype.getType=function(){return this.type},t.prototype.getViewportRoot=function(){return this._viewport},t.prototype.getViewportRootOffset=function(){var t=this.getViewportRoot();if(t)return{offsetLeft:t.offsetLeft||0,offsetTop:t.offsetTop||0}},t.prototype.getSvgDom=function(){return this._svgDom},t.prototype.refresh=function(){if(this.root){var t=this.renderToVNode({willUpdate:!0});t.attrs.style="position:absolute;left:0;top:0;user-select:none",function(t,e){if(qM(t,e))KM(t,e);else{var n=t.elm,i=HM(n);jM(e),null!==i&&(OM(i,e.elm,FM(n)),YM(i,[t],0,0))}}(this._oldVNode,t),this._oldVNode=t}},t.prototype.renderOneToVNode=function(t){return PM(t,oM(this._id))},t.prototype.renderToVNode=function(t){t=t||{};var e=this.storage.getDisplayList(!0),n=this._width,i=this._height,r=oM(this._id);r.animation=t.animation,r.willUpdate=t.willUpdate,r.compress=t.compress,r.emphasis=t.emphasis,r.ssr=this._opts.ssr;var o=[],a=this._bgVNode=function(t,e,n,i){var r;if(n&&"none"!==n)if(r=iM("rect","bg",{width:t,height:e,x:"0",y:"0"}),Vo(n))LM({fill:n},r.attrs,"fill",i);else if(Ho(n))EM({style:{fill:n},dirty:ri,getBoundingRect:function(){return{width:t,height:e}}},r.attrs,"fill",i);else{var o=Lo(n),a=o.color,s=o.opacity;r.attrs.fill=a,s<1&&(r.attrs["fill-opacity"]=s)}return r}(n,i,this._backgroundColor,r);a&&o.push(a);var s=t.compress?null:this._mainVNode=iM("g","main",{},[]);this._paintList(e,r,s?s.children:o),s&&o.push(s);var l=kn(In(r.defs),function(t){return r.defs[t]});if(l.length&&o.push(iM("defs","defs",{},l)),t.animation){var c=function(t,e,n){var i=(n=n||{}).newline?"\n":"",r=" {"+i,o=i+"}",a=kn(In(t),function(e){return e+r+kn(In(t[e]),function(n){return n+":"+t[e][n]+";"}).join(i)+o}).join(i),s=kn(In(e),function(t){return"@keyframes "+t+r+kn(In(e[t]),function(n){return n+r+kn(In(e[t][n]),function(i){var r=e[t][n][i];return"d"===i&&(r='path("'+r+'")'),i+":"+r+";"}).join(i)+o}).join(i)+o}).join(i);return a||s?[""].join(i):""}(r.cssNodes,r.cssAnims,{newline:!0});if(c){var u=iM("style","stl",{},[],c);o.push(u)}}return aM(n,i,o,t.useViewBox)},t.prototype.renderToString=function(t){return t=t||{},rM(this.renderToVNode({animation:Wn(t.cssAnimation,!0),emphasis:Wn(t.cssEmphasis,!0),willUpdate:!1,compress:!0,useViewBox:Wn(t.useViewBox,!0)}),{newline:!0})},t.prototype.setBackgroundColor=function(t){this._backgroundColor=t},t.prototype.getSvgRoot=function(){return this._mainVNode&&this._mainVNode.elm},t.prototype._paintList=function(t,e,n){for(var i,r,o=t.length,a=[],s=0,l=0,c=0;c=0&&(!h||!r||h[f]!==r[f]);f--);for(var g=p-1;g>f;g--)i=a[--s-1];for(var v=f+1;v10&&"cartesian2d"===o.type&&r){var s=o.getBaseAxis(),l=o.getOtherAxis(s),c=s.getExtent(),u=n.getDevicePixelRatio(),h=Math.abs(c[1]-c[0])*(u||1),d=Math.round(a/h);if(isFinite(d)&&d>1){"lttb"===r?t.setData(i.lttbDownSample(i.mapDimension(l.dim),1/d)):"minmax"===r&&t.setData(i.minmaxDownSample(i.mapDimension(l.dim),1/d));var p=void 0;En(r)?p=MS[r]:Ln(r)&&(p=r),p&&t.setData(i.downSample(i.mapDimension(l.dim),1/d,p,TS))}}}}}("line"))},function(t){bw(BC),bw(bk)},function(t){bw(bk),t.registerComponentModel(xk),t.registerComponentView($k),t.registerAction({type:"showTip",event:"showTip",update:"tooltip:manuallyShowTip"},ri),t.registerAction({type:"hideTip",event:"hideTip",update:"tooltip:manuallyHideTip"},ri)},function(t){t.registerPainter("svg",JM)}]);class tT extends Dt{constructor(){super(...arguments),this.options=null,this.data=[],this.height="120px",this._chart=null,this._resizeObserver=null}render(){return ut`
`}connectedCallback(){super.connectedCallback(),this.hasUpdated&&!this._chart&&this.options&&(this._mount(),this._observeResize())}firstUpdated(){this._mount(),this._observeResize()}updated(t){(t.has("options")||t.has("data"))&&(this._chart&&this.options?this._chart.setOption(this._mergedOptions(),!0):!this._chart&&this.options&&this._mount()),t.has("height")&&this._chart&&this._chart.resize()}disconnectedCallback(){this._destroy(),super.disconnectedCallback()}_mount(){if(!this.options)return;const t=this.shadowRoot?.querySelector(".chart-host");t&&(this._chart=Mb(t,void 0,{renderer:"svg"}),this._chart.setOption(this._mergedOptions(),!0))}_mergedOptions(){if(!this.options)throw new Error("span-chart: options not set");return{...this.options,series:this.data}}_observeResize(){if("undefined"==typeof ResizeObserver)return;this._resizeObserver=new ResizeObserver(()=>{this._chart&&this._chart.resize()});const t=this.shadowRoot?.querySelector(".chart-host");t&&this._resizeObserver.observe(t)}_destroy(){this._resizeObserver&&(this._resizeObserver.disconnect(),this._resizeObserver=null),this._chart&&(this._chart.dispose(),this._chart=null)}}tT.styles=M` + :host { + display: block; + width: 100%; + } + .chart-host { + width: 100%; + height: 100%; + } + `,_([Et({attribute:!1})],tT.prototype,"options",void 0),_([Et({attribute:!1})],tT.prototype,"data",void 0),_([Et({type:String})],tT.prototype,"height",void 0);try{customElements.get("span-chart")||customElements.define("span-chart",tT)}catch{}function eT(t,e,n,i,o,a,s,l,c){const{options:u,series:h}=function(t,e,n,i,o,a=!1){n||(n=f[r]);const s=i?"140, 160, 220":"77, 217, 175",l=`rgb(${s})`,c=Date.now(),u=c-e,h=void 0!==n.fixedMin&&void 0!==n.fixedMax,d=(t??[]).filter(t=>t.time>=u).map(t=>[t.time,Math.abs(t.value)]),p=[{type:"line",data:d,showSymbol:!1,smooth:!1,...a?{}:{step:"end"},lineStyle:{width:1.5,color:l},areaStyle:{color:{type:"linear",x:0,y:0,x2:0,y2:1,colorStops:[{offset:0,color:`rgba(${s}, 0.18)`},{offset:1,color:`rgba(${s}, 0.18)`}]}},itemStyle:{color:l}}],g=d.length>0?function(t){let e=0;for(const n of t)n[1]>e&&(e=n[1]);return e}(d):0,v={type:"value",splitNumber:4,axisLabel:{fontSize:10,formatter:g<10?t=>0===t?"0":t.toFixed(1):t=>n.format(t)},splitLine:{lineStyle:{opacity:.15}}};h?(v.min=n.fixedMin,v.max=n.fixedMax):g<1&&(v.min=0,v.max=1),o&&"current"===n.entityRole&&(v.min=0,v.max=Math.ceil(1.25*o),p.push({type:"line",data:[[u,.8*o],[c,.8*o]],showSymbol:!1,lineStyle:{width:1,color:"rgba(255, 200, 40, 0.6)",type:"dashed"},itemStyle:{color:"transparent"},tooltip:{show:!1}}),p.push({type:"line",data:[[u,o],[c,o]],showSymbol:!1,lineStyle:{width:1.5,color:"rgba(255, 60, 60, 0.7)",type:"solid"},itemStyle:{color:"transparent"},tooltip:{show:!1}}));const m={xAxis:{type:"time",min:u,max:c,axisLabel:{fontSize:10},splitLine:{show:!1}},yAxis:v,grid:{top:8,right:4,bottom:0,left:0,containLabel:!0},tooltip:{trigger:"axis",axisPointer:{type:"line",lineStyle:{type:"dashed"}},formatter:t=>{if(!t||0===t.length)return"";const e=t[0],i=new Date(e.value[0]).toLocaleString(void 0,{month:"short",day:"numeric",hour:"2-digit",minute:"2-digit",second:"2-digit"}),r=parseFloat(e.value[1].toFixed(2));return`
${i}
${n.format(r)} ${n.unit(r)}
`}},animation:!1};return{options:m,series:p}}(n,i,o,a,l,c),d=s??120;t.style.minHeight=d+"px";let p=t.querySelector("span-chart");p||(p=document.createElement("span-chart"),p.style.display="block",p.style.width="100%",t.innerHTML="",t.appendChild(p));const g=t.clientHeight;p.height=(g>0?g:d)+"px",p.options=u,p.data=h}function nT(t){return"function"==typeof globalThis.CSS?.escape?CSS.escape(t):t.replace(/["\\]/g,"\\$&")}function iT(t,e,n,i,r){const o="current"===(i.chart_metric||"power"),a=t.querySelector(".stat-consumption .stat-value"),s=t.querySelector(".stat-consumption .stat-unit");if(o){const t=n.panel_entities?.site_power,i=t?e.states[t]:null,r=i?parseFloat(i.attributes?.amperage):NaN;a&&(a.textContent=Number.isFinite(r)?Math.abs(r).toFixed(1):"--"),s&&(s.textContent="A")}else{let t=r;const i=n.panel_entities?.site_power;if(i){const n=e.states[i];n&&(t=Math.abs(parseFloat(n.state)||0))}a&&(a.textContent=fe(t)),s&&(s.textContent="kW")}const l=t.querySelector(".stat-upstream .stat-value"),c=t.querySelector(".stat-upstream .stat-unit");if(l){const t=n.panel_entities?.current_power,i=t?e.states[t]:null;if(o){const t=i?parseFloat(i.attributes?.amperage):NaN;l.textContent=Number.isFinite(t)?Math.abs(t).toFixed(1):"--",c&&(c.textContent="A")}else{const t=i?Math.abs(parseFloat(i.state)||0):0;l.textContent=fe(t),c&&(c.textContent="kW")}}const u=t.querySelector(".stat-downstream .stat-value"),h=t.querySelector(".stat-downstream .stat-unit");if(u){const t=n.panel_entities?.feedthrough_power,i=t?e.states[t]:null;if(o){const t=i?parseFloat(i.attributes?.amperage):NaN;u.textContent=Number.isFinite(t)?Math.abs(t).toFixed(1):"--",h&&(h.textContent="A")}else{const t=i?Math.abs(parseFloat(i.state)||0):0;u.textContent=fe(t),h&&(h.textContent="kW")}}const d=t.querySelector(".stat-solar .stat-value"),p=t.querySelector(".stat-solar .stat-unit");if(d){const t=n.panel_entities?.pv_power,i=t?e.states[t]:null;if(o){const t=i?parseFloat(i.attributes?.amperage):NaN;d.textContent=Number.isFinite(t)?Math.abs(t).toFixed(1):"--",p&&(p.textContent="A")}else{if(i){const t=Math.abs(parseFloat(i.state)||0);d.textContent=fe(t)}else d.textContent="--";p&&(p.textContent="kW")}}const f=t.querySelector(".stat-battery .stat-value");if(f){const t=n.panel_entities?.battery_level,i=t?e.states[t]:null;i&&(f.textContent=`${Math.round(parseFloat(i.state)||0)}`)}const g=t.querySelector(".stat-grid-state .stat-value");if(g){const t=n.panel_entities?.dsm_state,i=t?e.states[t]:null;g.textContent=i?e.formatEntityState?.(i)||i.state:"--"}}function rT(t,e,i,r,o,a){if(!t||!i||!e)return;const s=$e(r);let u=0;for(const[,t]of Object.entries(i.circuits)){const n=t.entities?.power;if(!n)continue;const i=e.states[n],r=i&&parseFloat(i.state)||0;t.device_type!==c&&(u+=Math.abs(r))}!function(t,e,n,i,r){const o=t.querySelector(".panel-stats");o&&iT(o,e,n,i,r)}(t,e,i,r,u);const h=ye(r),d="current"===h.entityRole;for(const[r,u]of Object.entries(i.circuits)){const i=t.querySelector(`.circuit-slot[data-uuid="${nT(r)}"]`);if(!i)continue;const p=u.entities?.power,f=p?e.states[p]:null,g=f&&parseFloat(f.state)||0,m=u.device_type===c||g<0,y=u.entities?.switch,_=y?e.states[y]:null,b=_?"on"===_.state:(f?.attributes?.relay_state||u.relay_state)===l,x=i.querySelector(".power-value");if(x)if(d){const t=u.entities?.current,n=t?e.states[t]:null,i=n&&parseFloat(n.state)||0;x.innerHTML=`${h.format(i)}A`}else x.innerHTML=`${pe(g)}${de(g)}`;const w=i.querySelector(".toggle-pill");if(w){w.className="toggle-pill "+(b?"toggle-on":"toggle-off");const t=w.querySelector(".toggle-label");t&&(t.textContent=n(b?"grid.on":"grid.off"))}let S;if(i.classList.toggle("circuit-off",!b),i.classList.toggle("circuit-producer",m),u.always_on)S="always_on";else{const t=u.entities?.select,n=t?e.states[t]:null;S=n?n.state:"unknown"}const C=v[S]??v.unknown,k=i.querySelector(".shedding-icon");k&&(k.setAttribute("icon",C.icon),k.style.color=C.color,k.title=C.label());const M=i.querySelector(".shedding-icon-secondary");M&&(C.icon2?(M.setAttribute("icon",C.icon2),M.style.color=C.color,M.style.display=""):M.style.display="none");const T=i.querySelector(".shedding-label");T&&(C.textLabel?(T.textContent=C.textLabel,T.style.color=C.color,T.style.display=""):T.style.display="none");const I=i.querySelector(".chart-container");if(I){const t=o.get(r)||[],e=i.classList.contains("circuit-col-span")?200:100,n=a?.has(r)?He(a.get(r)):s,l=u.device_type===c;eT(I,0,t,n,h,m,e,u.breaker_rating_a??void 0,l)}}}class oT{get errorStore(){return this._errorStore}set errorStore(t){this._errorStore=t,this._retry=t?new qt(t):null}constructor(){this._errorStore=null,this._retry=null,this._settings=null,this._lastFetch=0,this._fetching=!1}async fetch(t,e){const i=Date.now();if(this._fetching)return this._settings;if(this._settings&&i-this._lastFetch<3e4)return this._settings;this._fetching=!0;try{const i={};e&&(i.config_entry_id=e);const r={type:"call_service",domain:s,service:"get_graph_settings",service_data:i,return_response:!0},o=this._retry?await this._retry.callWS(t,r,{errorId:"fetch:graph_settings",errorMessage:n("error.graph_settings_failed")}):await t.callWS(r);this._settings=o?.response??null,this._lastFetch=Date.now()}catch(t){console.warn("SPAN Panel: graph settings fetch failed",t),this._settings=null,this._retry||this._errorStore?.add({key:"fetch:graph_settings",level:"warning",message:n("error.graph_settings_failed"),persistent:!1})}finally{this._fetching=!1}return this._settings}invalidate(){this._lastFetch=0}get settings(){return this._settings}clear(){this._settings=null,this._lastFetch=0}}function aT(t,e){if(!t)return o;const n=t.circuits?.[e];return n?.has_override?n.horizon:t.global_horizon??o}function sT(t,e){if(!t)return o;const n=t.sub_devices?.[e];return n?.has_override?n.horizon:t.global_horizon??o}class lT{constructor(){this.powerHistory=new Map,this.horizonMap=new Map,this.subDeviceHorizonMap=new Map,this.monitoringCache=new be,this.monitoringMultiCache=new xe,this.graphSettingsCache=new oT,this._errorStore=null,this._hass=null,this._topology=null,this._config=null,this._configEntryId=null,this._favRefs=null,this._perPanelInfo=new Map,this._panelFavorites=null,this._showMonitoring=!1,this._updateInterval=null,this._recorderRefreshInterval=null,this._resizeObserver=null,this._lastWidth=0,this._resizeDebounce=null}get errorStore(){return this._errorStore}set errorStore(t){this._errorStore=t,this.monitoringCache.errorStore=t,this.graphSettingsCache.errorStore=t,this.monitoringMultiCache.errorStore=t}get hass(){return this._hass}set hass(t){this._hass=t}get topology(){return this._topology}get config(){return this._config}set showMonitoring(t){this._showMonitoring=t}init(t,e,n,i){this._topology=t,this._config=e,this._hass=n,this._configEntryId=i}setFavoriteRefs(t){this._favRefs=t}clearFavoriteRefs(){this._favRefs=null}setPanelFavorites(t){this._panelFavorites=t}setFavoritesPerPanelInfo(t){this._perPanelInfo=t??new Map}get _inFavoritesView(){return null!==this._favRefs}setConfig(t){this._config=t}buildHorizonMaps(t){if(this.horizonMap.clear(),this.subDeviceHorizonMap.clear(),t&&this._topology?.circuits)for(const e of Object.keys(this._topology.circuits))this.horizonMap.set(e,aT(t,e));if(t&&this._topology?.sub_devices)for(const e of Object.keys(this._topology.sub_devices))this.subDeviceHorizonMap.set(e,sT(t,e))}async fetchAndBuildHorizonMaps(){try{this._favRefs?await this._buildFavoritesHorizonMaps():(await this.graphSettingsCache.fetch(this._hass,this._configEntryId),this.buildHorizonMaps(this.graphSettingsCache.settings))}catch(t){console.warn("SPAN Panel: graph settings fetch failed",t),this.graphSettingsCache.errorStore||this._errorStore?.add({key:"fetch:graph_settings",level:"warning",message:n("error.graph_settings_failed"),persistent:!1})}}async fetchMergedMonitoringStatus(t){if(!this._hass||0===t.length)return null;const e=this._hass;return function(t){let e=!1;const n={},i={};for(const r of t)r&&(e=!0,r.circuits&&Object.assign(n,r.circuits),r.mains&&Object.assign(i,r.mains));return e?{circuits:n,mains:i}:null}(await Promise.all(t.map(t=>this.monitoringMultiCache.fetchOne(e,t))))}async _buildFavoritesHorizonMaps(){if(!this._hass||!this._favRefs||!this._topology)return;const t=new Set;for(const e of Object.values(this._favRefs))e.configEntryId&&t.add(e.configEntryId);const e=new Map;await Promise.all(Array.from(t).map(async t=>{e.set(t,await this._fetchGraphSettingsFresh(t))})),this.horizonMap.clear(),this.subDeviceHorizonMap.clear();for(const t of Object.keys(this._topology.circuits)){const n=this._favRefs[t],i=n?.configEntryId?e.get(n.configEntryId)??null:null,r=n?.targetId??t;this.horizonMap.set(t,aT(i,r))}if(this._topology.sub_devices)for(const t of Object.keys(this._topology.sub_devices)){const n=this._favRefs[t],i=n?.configEntryId?e.get(n.configEntryId)??null:null,r=n?.targetId??t;this.subDeviceHorizonMap.set(t,sT(i,r))}}async loadHistory(){await je(this._hass,this._topology,this._config,this.powerHistory,this.horizonMap,this.subDeviceHorizonMap)}recordSamples(){if(!this._topology||!this._hass||!this._config)return;const t=Date.now();for(const[e,n]of Object.entries(this._topology.circuits)){const i=this.horizonMap.get(e)??o;if(!a[i]?.useRealtime)continue;const r=_e(n,this._config);if(!r)continue;const s=this._hass.states[r];if(!s)continue;const l=parseFloat(s.state);if(isNaN(l))continue;const c=He(i),u=Fe(c),h=Be(c),d=t-c,p=this.powerHistory.get(e)??[];p.length>0&&t-p[p.length-1].time0&&t-p[p.length-1].time0&&this._topology)for(const{key:t,devId:e}of qe(this._topology))i.has(e)&&r.add(t);const o=new Map;try{await je(this._hass,this._topology,this._config,o,e,i);for(const t of e.keys()){const e=o.get(t);e?this.powerHistory.set(t,e):this.powerHistory.delete(t)}for(const t of r){const e=o.get(t);e?this.powerHistory.set(t,e):this.powerHistory.delete(t)}this.updateDOM(t)}catch(t){console.warn("SPAN Panel: history refresh failed",t),this._errorStore?.add({key:"fetch:history",level:"warning",message:n("error.history_failed"),persistent:!1})}}updateDOM(t){this._hass&&this._topology&&this._config&&(rT(t,this._hass,this._topology,this._config,this.powerHistory,this.horizonMap),function(t,e,n,i,r,o){if(!n.sub_devices)return;const a=$e(i);for(const[i,s]of Object.entries(n.sub_devices)){const n=t.querySelector(`[data-subdev="${nT(i)}"]`);if(!n)continue;const l=Pe(s);if(l){const t=e.states[l],i=t&&parseFloat(t.state)||0,r=n.querySelector(".sub-power-value");r&&(r.innerHTML=`${pe(i)} ${de(i)}`)}const c=n.querySelectorAll("[data-chart-key]");for(const t of c){const e=t.dataset.chartKey;if(!e)continue;const n=r.get(e)||[];let s=g.power;e.endsWith("_soc")?s=g.soc:e.endsWith("_soe")&&(s=g.soe);const l=!!t.closest(".bess-chart-col");eT(t,0,n,o?.has(i)?He(o.get(i)):a,s,!1,l?120:150,void 0,e.endsWith("_soc")||e.endsWith("_soe"))}for(const t of Object.keys(s.entities||{})){const i=n.querySelector(`[data-eid="${nT(t)}"]`);if(!i)continue;const r=e.states[t];if(r){let t;if(e.formatEntityState)t=e.formatEntityState(r);else{t=r.state;const e=r.attributes.unit_of_measurement||"";e&&(t+=" "+e)}if("Wh"===(r.attributes.unit_of_measurement||"")){const e=parseFloat(r.state);isNaN(e)||(t=(e/1e3).toFixed(1)+" kWh")}i.textContent=t}}}}(t,this._hass,this._topology,this._config,this.powerHistory,this.subDeviceHorizonMap))}async onGraphSettingsChanged(t){if(this._hass){this._favRefs?await this._buildFavoritesHorizonMaps():(this.graphSettingsCache.invalidate(),await this.graphSettingsCache.fetch(this._hass,this._configEntryId),this.buildHorizonMaps(this.graphSettingsCache.settings)),this.powerHistory.clear();try{await this.loadHistory()}catch{}this.updateDOM(t)}}onToggleClick(t,e){const i=t.target,r=i?.closest(".toggle-pill");if(!r)return;const o=e.querySelector(".slide-confirm");if(!o||!o.classList.contains("confirmed"))return;t.stopPropagation(),t.preventDefault();const a=r.closest("[data-uuid]");if(!a||!this._topology||!this._hass)return;const s=a.dataset.uuid;if(!s)return;const l=this._topology.circuits[s];if(!l)return;const c=l.entities?.switch;if(!c)return;const u=this._hass.states[c];if(!u)return void console.warn("SPAN Panel: switch entity not found:",c);const h="on"===u.state?"turn_off":"turn_on";this._hass.callService("switch",h,{},{entity_id:c}).catch(t=>{console.warn("SPAN Panel: switch service call failed",t),this._errorStore?.add({key:"service:relay",level:"error",message:n("error.relay_failed"),persistent:!1})})}async onGearClick(t,e){const n=t.target,i=n?.closest(".gear-icon");if(!i)return;const r=e.querySelector("span-side-panel");if(!r||!this._hass)return;if(r.hass=this._hass,r.errorStore=this.errorStore,i.classList.contains("panel-gear")){if(this._inFavoritesView){const t=await this._buildFavoritesSections();if(0===t.length)return;return void r.open({favoritesMode:!0,perPanelSections:t})}return await this.graphSettingsCache.fetch(this._hass,this._configEntryId),void r.open({panelMode:!0,topology:this._topology,graphSettings:this.graphSettingsCache.settings,showFavorites:null!==this._panelFavorites,favoritePanelDeviceId:this._panelFavorites?.panelDeviceId,favoriteCircuitUuids:this._panelFavorites?.circuitUuids,favoriteSubDeviceIds:this._panelFavorites?.subDeviceIds,configEntryId:this._configEntryId})}const a=i.dataset.uuid;if(a&&this._topology){const t=this._topology.circuits[a];if(t){const e=this._favRefs?.[a]??null,n=e&&"circuit"===e.kind?e.targetId:a,i=e?.configEntryId??this._configEntryId;let s,l;e?[s,l]=await Promise.all([this._fetchGraphSettingsFresh(i),this._fetchMonitoringStatusFresh(i)]):(await Promise.all([this.graphSettingsCache.fetch(this._hass,i),this.monitoringCache.fetch(this._hass,i)]),s=this.graphSettingsCache.settings,l=this.monitoringCache.status);const c=t.entities?.current??t.entities?.power,u=c?l?.circuits?.[c]??null:null,h=s?.global_horizon??o,d=s?.circuits?.[n],p=d?{...d,globalHorizon:h}:{horizon:h,has_override:!1,globalHorizon:h},f=e?.panelDeviceId??this._panelFavorites?.panelDeviceId,g=null!==e||(this._panelFavorites?.circuitUuids.has(n)??!1),v=this._inFavoritesView||null!==this._panelFavorites;return void r.open({...t,uuid:n,monitoringInfo:u,showMonitoring:this._showMonitoring,graphHorizonInfo:p,showFavorites:v,favoritePanelDeviceId:f,isFavorite:g,configEntryId:i})}}const s=i.dataset.subdevId;if(s&&this._topology?.sub_devices?.[s]){const t=this._topology.sub_devices[s],e=this._favRefs?.[s]??null,n=e&&"sub_device"===e.kind?e.targetId:s,i=e?.configEntryId??this._configEntryId;let a;e?a=await this._fetchGraphSettingsFresh(i):(await this.graphSettingsCache.fetch(this._hass,i),a=this.graphSettingsCache.settings);const l=a?.global_horizon??o,c=a?.sub_devices?.[n],u=c?{...c,globalHorizon:l}:{horizon:l,has_override:!1,globalHorizon:l},h=e?.panelDeviceId??this._panelFavorites?.panelDeviceId,d=null!==e||(this._panelFavorites?.subDeviceIds.has(n)??!1),p=this._inFavoritesView||null!==this._panelFavorites;r.open({subDeviceMode:!0,subDeviceId:n,name:t.name??n,deviceType:t.type??"",entities:t.entities,graphHorizonInfo:u,showFavorites:p,favoritePanelDeviceId:h,isFavorite:d,configEntryId:i})}}async _buildFavoritesSections(){if(!this._hass||!this._favRefs)return[];const t=function(t,e){const n=new Map;for(const i of Object.values(t)){if("circuit"!==i.kind)continue;const t=e.get(i.panelDeviceId);if(void 0===t)continue;let r=n.get(i.panelDeviceId);void 0===r&&(r={panelDeviceId:i.panelDeviceId,panelName:t.panelName,topology:t.topology,configEntryId:t.configEntryId,favoriteCircuitUuids:new Set},n.set(i.panelDeviceId,r)),r.favoriteCircuitUuids.add(i.targetId)}return Array.from(n.values()).sort((t,e)=>t.panelName.localeCompare(e.panelName))}(this._favRefs,this._perPanelInfo);if(0===t.length)return[];return await Promise.all(t.map(async t=>({panelDeviceId:t.panelDeviceId,panelName:t.panelName,topology:t.topology,graphSettings:await this._fetchGraphSettingsFresh(t.configEntryId),favoriteCircuitUuids:t.favoriteCircuitUuids,configEntryId:t.configEntryId})))}async _fetchGraphSettingsFresh(t){if(!this._hass)return null;try{const e={};t&&(e.config_entry_id=t);const i={type:"call_service",domain:s,service:"get_graph_settings",service_data:e,return_response:!0},r=this._errorStore?new qt(this._errorStore):null,o=r?await r.callWS(this._hass,i,{errorId:"fetch:graph_settings",errorMessage:n("error.graph_settings_failed")}):await this._hass.callWS(i);return o?.response??null}catch(t){return console.warn("SPAN Panel: fresh graph settings fetch failed",t),null}}async _fetchMonitoringStatusFresh(t){if(!this._hass)return null;try{const e={};t&&(e.config_entry_id=t);const i={type:"call_service",domain:s,service:"get_monitoring_status",service_data:e,return_response:!0},r=this._errorStore?new qt(this._errorStore):null,o=r?await r.callWS(this._hass,i,{errorId:"fetch:monitoring",errorMessage:n("error.monitoring_failed")}):await this._hass.callWS(i),a=o?.response;return a?{circuits:a.circuits,mains:a.mains}:null}catch(t){return console.warn("SPAN Panel: fresh monitoring status fetch failed",t),null}}bindSlideConfirm(t,e){const n=t.querySelector(".slide-confirm-knob"),i=t.querySelector(".slide-confirm-text");if(!n||!i)return;let r=!1,o=0,a=0;const s=e=>{t.classList.contains("confirmed")||(r=!0,o=e-n.offsetLeft,a=t.offsetWidth-n.offsetWidth-4,n.classList.remove("snapping"))},l=t=>{if(!r)return;const e=Math.max(2,Math.min(t-o,a));n.style.left=e+"px"},c=()=>{if(!r)return;r=!1;(n.offsetLeft-2)/a>=.9?(n.style.left=a+"px",t.classList.add("confirmed"),n.querySelector("span-icon")?.setAttribute("icon","mdi:lock-open"),i.textContent=t.dataset.textOn??"",e&&e.classList.remove("switches-disabled")):(n.classList.add("snapping"),n.style.left="2px")};n.addEventListener("mousedown",t=>{t.preventDefault(),s(t.clientX)}),t.addEventListener("mousemove",t=>l(t.clientX)),t.addEventListener("mouseup",c),t.addEventListener("mouseleave",c),n.addEventListener("touchstart",t=>{t.preventDefault(),s(t.touches[0].clientX)},{passive:!1}),t.addEventListener("touchmove",t=>l(t.touches[0].clientX),{passive:!0}),t.addEventListener("touchend",c),t.addEventListener("touchcancel",c),t.addEventListener("click",()=>{t.classList.contains("confirmed")&&(t.classList.remove("confirmed"),n.classList.add("snapping"),n.style.left="2px",n.querySelector("span-icon")?.setAttribute("icon","mdi:lock"),i.textContent=t.dataset.textOff??"",e&&e.classList.add("switches-disabled"))})}startIntervals(t,e){this._updateInterval=setInterval(()=>{this.recordSamples(),this.updateDOM(t),e&&e()},1e3),this._recorderRefreshInterval=setInterval(()=>{this.refreshRecorderData(t)},3e4)}stopIntervals(){this._updateInterval&&(clearInterval(this._updateInterval),this._updateInterval=null),this._recorderRefreshInterval&&(clearInterval(this._recorderRefreshInterval),this._recorderRefreshInterval=null),this.cleanupResizeObserver()}setupResizeObserver(t,e){this.cleanupResizeObserver(),e&&(this._lastWidth=e.clientWidth,this._resizeObserver=new ResizeObserver(e=>{const n=e[0];if(!n)return;const i=n.contentRect.width;Math.abs(i-this._lastWidth)<5||(this._lastWidth=i,this._resizeDebounce&&clearTimeout(this._resizeDebounce),this._resizeDebounce=setTimeout(()=>{for(const e of t.querySelectorAll(".chart-container")){const t=e.querySelector("span-chart");t&&t.remove()}this.updateDOM(t)},150))}),this._resizeObserver.observe(e))}cleanupResizeObserver(){this._resizeObserver&&(this._resizeObserver.disconnect(),this._resizeObserver=null),this._resizeDebounce&&(clearTimeout(this._resizeDebounce),this._resizeDebounce=null)}reset(){this.powerHistory.clear(),this.horizonMap.clear(),this.subDeviceHorizonMap.clear(),this.monitoringCache.clear(),this.monitoringMultiCache.clear(),this.graphSettingsCache.clear()}}function cT(t,e){const n=e.hysteresisPx??48,i=new WeakSet;let r=null;const o=t=>{const n=t.querySelector(e.nameSelector);return!!n&&(0!==t.clientWidth&&(n.clientWidth<1||n.scrollWidth>n.clientWidth+1))},a=()=>{const i=t.querySelectorAll(e.rowSelector);if(0===i.length)return;const a=i[0].clientWidth;if(0===a)return;if(null!==r){if(a>r+n){for(const t of i)t.classList.remove(e.foldClass);r=null}else for(const t of i)t.classList.add(e.foldClass);return}let s=!1;for(const t of i)if(o(t)){s=!0;break}if(s){for(const t of i)t.classList.add(e.foldClass);r=a}},s=new ResizeObserver(()=>a()),l=()=>{const n=t.querySelectorAll(e.rowSelector);for(const t of n)i.has(t)||(i.add(t),s.observe(t));requestAnimationFrame(()=>a())};l();const c=new MutationObserver(t=>{(t=>{const n=t=>{for(const n of t)if(n instanceof Element){if(n.matches(e.rowSelector))return!0;if(n.querySelector(e.rowSelector))return!0}return!1};for(const e of t)if(n(e.addedNodes)||n(e.removedNodes))return!0;return!1})(t)&&l()});return c.observe(t,{childList:!0,subtree:!0}),()=>{s.disconnect(),c.disconnect()}}const uT='\n :host {\n --span-accent: var(--primary-color, #4dd9af);\n }\n\n /* Card shell — replaces . Theme variables (--ha-card-*) are\n stable HA contracts (not the deprecated component APIs flagged by the\n 2026.4 frontend blog), so they stay in place to keep visual parity\n with the rest of HA\'s dashboards. */\n .span-card {\n display: block;\n padding: 24px;\n background: var(--card-background-color, #1c1c1c);\n color: var(--primary-text-color, #e0e0e0);\n border-radius: var(--ha-card-border-radius, 12px);\n border: var(--ha-card-border-width, 1px) solid var(--ha-card-border-color, var(--divider-color, #333));\n box-shadow: var(--ha-card-box-shadow, none);\n }\n\n .panel-header {\n display: flex;\n flex-wrap: wrap;\n justify-content: space-between;\n align-items: flex-start;\n gap: 8px 16px;\n margin-bottom: 20px;\n padding-bottom: 16px;\n border-bottom: 1px solid var(--divider-color, #333);\n }\n .header-left { flex: 1 1 300px; min-width: 0; }\n .header-center { flex: 0 0 auto; }\n .header-right { flex: 0 1 auto; min-width: 0; }\n\n .panel-identity {\n display: flex;\n align-items: center;\n flex-wrap: wrap;\n gap: 8px 12px;\n margin-bottom: 12px;\n }\n\n .panel-title {\n font-size: 1.8em;\n font-weight: 700;\n margin: 0;\n color: var(--primary-text-color, #fff);\n }\n\n .panel-serial {\n font-size: 0.85em;\n color: var(--secondary-text-color, #999);\n font-family: monospace;\n }\n\n .panel-stats {\n display: flex;\n flex-wrap: wrap;\n gap: 16px 32px;\n }\n\n /* Favorites view header: gear + slide-to-arm + right-anchored legend/W-A cluster. */\n .favorites-summary {\n padding: 8px 24px;\n border-bottom: 1px solid var(--divider-color, #e0e0e0);\n display: flex;\n align-items: center;\n gap: 12px;\n }\n /* Override the generic .gear-icon { margin-left: auto } rule so the\n favorites gear stays flush-left instead of floating to the right of\n the flex row (same idea as .panel-identity .panel-gear does for\n real-panel headers). */\n .favorites-summary .favorites-gear {\n margin-left: 0;\n }\n /* Right-anchored cluster wrapping the shedding legend + W/A unit toggle.\n margin-left:auto moved here from .favorites-summary-unit-toggle so the\n legend and toggle cluster together, matching the real-panel header\n layout. */\n .favorites-summary-right {\n margin-left: auto;\n display: flex;\n align-items: center;\n gap: 16px;\n }\n .favorites-subdevices-section {\n padding: 8px 16px 0;\n }\n\n /* Favorites view: responsive grid of per-contributing-panel status cards. */\n .favorites-panel-stats-grid {\n display: grid;\n grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));\n gap: 12px;\n padding: 12px 24px;\n border-bottom: 1px solid var(--divider-color, #333);\n }\n .favorites-panel-card {\n background: var(--secondary-background-color, rgba(255, 255, 255, 0.04));\n border: 1px solid var(--divider-color, #333);\n border-radius: 8px;\n padding: 10px 14px;\n display: flex;\n flex-direction: column;\n gap: 6px;\n }\n .favorites-panel-card-title {\n font-size: 0.85em;\n font-weight: 600;\n color: var(--primary-text-color);\n opacity: 0.85;\n }\n .favorites-panel-card .panel-stats {\n gap: 10px 20px;\n }\n .favorites-panel-card .stat-value {\n font-size: 1.15em;\n }\n\n .stat { display: flex; flex-direction: column; }\n .stat-label { font-size: 0.8em; color: var(--secondary-text-color, #999); margin-bottom: 2px; }\n .stat-row { display: flex; align-items: baseline; gap: 2px; }\n .stat-value { font-size: 1.5em; font-weight: 700; color: var(--primary-text-color, #fff); }\n .stat-unit { font-size: 0.7em; font-weight: 400; color: var(--secondary-text-color, #999); }\n\n .header-right { display: flex; flex-direction: column; align-items: flex-end; gap: 8px; padding-top: 8px; }\n .header-right-top { display: flex; gap: 20px; align-items: center; }\n .meta-item { font-size: 0.8em; color: var(--secondary-text-color, #999); }\n\n .shedding-legend { display: flex; gap: 12px; flex-wrap: wrap; justify-content: flex-end; }\n .shedding-legend-item { display: inline-flex; align-items: center; gap: 3px; }\n .shedding-legend-item span-icon { --mdc-icon-size: 16px; }\n .shedding-legend-secondary { --mdc-icon-size: 12px; opacity: 0.8; }\n .shedding-legend-text { font-size: 9px; font-weight: 600; }\n .shedding-legend-label { font-size: 0.7em; color: var(--secondary-text-color, #999); }\n\n .panel-gear {\n background: none;\n border: none;\n cursor: pointer;\n color: var(--secondary-text-color);\n opacity: 0.6;\n padding: 4px;\n margin-left: 8px;\n vertical-align: middle;\n }\n .panel-gear:hover { opacity: 1; }\n .header-center {\n display: flex;\n align-items: flex-start;\n justify-content: center;\n padding-top: 8px;\n }\n .panel-identity .panel-gear {\n margin-left: 0;\n }\n .slide-confirm {\n position: relative;\n display: inline-flex;\n align-items: center;\n width: 160px;\n height: 28px;\n border-radius: 14px;\n background: color-mix(in srgb, var(--primary-color, #4dd9af) 20%, var(--secondary-background-color, #333));\n vertical-align: middle;\n overflow: hidden;\n user-select: none;\n touch-action: none;\n }\n .slide-confirm-text {\n position: absolute;\n width: 100%;\n text-align: center;\n font-size: 0.65em;\n font-weight: 600;\n color: var(--secondary-text-color, #999);\n pointer-events: none;\n z-index: 0;\n }\n .slide-confirm-knob {\n position: absolute;\n left: 2px;\n top: 2px;\n width: 24px;\n height: 24px;\n border-radius: 50%;\n background: var(--secondary-text-color, #666);\n display: flex;\n align-items: center;\n justify-content: center;\n cursor: grab;\n z-index: 1;\n transition: none;\n }\n .slide-confirm-knob span-icon {\n --mdc-icon-size: 14px;\n color: var(--card-background-color, #1c1c1c);\n }\n .slide-confirm-knob.snapping {\n transition: left 0.25s ease;\n }\n .slide-confirm.confirmed {\n background: color-mix(in srgb, var(--state-active-color, var(--span-accent)) 25%, transparent);\n }\n .slide-confirm.confirmed .slide-confirm-text {\n color: var(--state-active-color, var(--span-accent));\n }\n .slide-confirm.confirmed .slide-confirm-knob {\n background: var(--state-active-color, var(--span-accent));\n }\n .switches-disabled .toggle-pill {\n opacity: 0.3;\n pointer-events: none;\n }\n .unit-toggle {\n display: inline-flex;\n background: var(--secondary-background-color, #333);\n border-radius: 6px;\n overflow: hidden;\n margin-left: 8px;\n }\n .unit-btn {\n padding: 4px 10px;\n border: none;\n background: none;\n color: var(--secondary-text-color);\n font-size: 0.75em;\n font-weight: 600;\n cursor: pointer;\n }\n .unit-btn.unit-active {\n background: var(--primary-color, #4dd9af);\n color: var(--text-primary-color, #000);\n }\n\n .monitoring-summary {\n display: flex;\n justify-content: space-between;\n align-items: center;\n padding: 6px 16px;\n font-size: 0.8em;\n background: rgba(76, 175, 80, 0.1);\n border: 1px solid var(--divider-color, #333);\n border-top: none;\n }\n .monitoring-active { color: #4caf50; }\n .monitoring-counts { display: flex; gap: 12px; }\n .count-warning { color: #ff9800; }\n .count-alert { color: #f44336; }\n .count-overrides { color: var(--secondary-text-color); }\n\n .panel-grid {\n display: grid;\n /* Five columns: left tab label, left cell, explicit 8px spacer,\n right cell, right tab label. Spacer is in-band rather than a\n column-gap so we can keep inter-cell space without paying an\n equal gap between each cell and its tab label. The tab columns\n are sized to fit a 2-digit breaker number (the font is 0.85em\n of the panel body ≈ 14px glyph width). */\n grid-template-columns: 14px 1fr 8px 1fr 14px;\n column-gap: 0;\n row-gap: 8px;\n align-items: stretch;\n }\n\n .tab-label {\n display: flex;\n align-items: center;\n font-size: 0.85em;\n font-weight: 600;\n color: var(--secondary-text-color, #999);\n user-select: none;\n }\n .tab-left { justify-content: flex-start; }\n .tab-right { justify-content: flex-end; }\n\n .circuit-slot {\n background: var(--secondary-background-color, var(--card-background-color, #2a2a2a));\n border: 1px solid var(--divider-color, #333);\n border-radius: 12px;\n padding: 14px 16px 20px;\n min-height: 140px;\n transition: opacity 0.3s;\n position: relative;\n overflow: hidden;\n }\n\n .circuit-col-span { min-height: 280px; }\n .circuit-row-span { border-left: 3px solid var(--span-accent); }\n .circuit-off .circuit-name,\n .circuit-off .breaker-badge,\n .circuit-off .power-value,\n .circuit-off .chart-container { opacity: 0.35; }\n .circuit-off .toggle-pill,\n .circuit-off .gear-icon { opacity: 1; }\n\n .circuit-empty {\n opacity: 0.2;\n min-height: 60px;\n display: flex;\n align-items: center;\n justify-content: center;\n border-style: dashed;\n }\n .empty-label { color: var(--secondary-text-color, #999); font-size: 0.85em; }\n\n .circuit-header {\n display: flex;\n justify-content: space-between;\n align-items: flex-start;\n margin-bottom: 6px;\n gap: 8px;\n }\n\n .circuit-info { display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0; }\n\n .breaker-badge {\n background: color-mix(in srgb, var(--span-accent) 15%, transparent);\n color: var(--span-accent);\n font-size: 0.7em;\n font-weight: 700;\n padding: 2px 3px;\n border-radius: 4px;\n white-space: nowrap;\n border: 1px solid color-mix(in srgb, var(--span-accent) 25%, transparent);\n flex-shrink: 0;\n }\n\n .circuit-name {\n font-size: 0.9em;\n font-weight: 500;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n color: var(--primary-text-color, #e0e0e0);\n }\n\n .circuit-controls { display: flex; align-items: center; gap: 10px; flex-shrink: 0; }\n\n /* Truncation-driven fold for By Panel breaker cells. The .is-folded\n class is added/removed by the JS observer in\n src/core/truncation-fold.ts when the .circuit-name actually\n ellipsizes. Pixel thresholds can\'t get this right because name\n length varies wildly per circuit (e.g. "Spa" vs\n "Commissioned PV System") — only measuring the live name vs its\n container catches the exact moment of truncation.\n\n When folded the nested flex wrappers (.circuit-header,\n .circuit-info, .circuit-controls, .circuit-status) collapse via\n \'display: contents\' so the leaf elements participate directly in\n the outer grid: name gets the whole first row, readings/controls/\n gear drop to a second row, chart stays as the full-width third. */\n .circuit-slot.is-folded {\n display: grid;\n /* Columns: badges + relay-toggle pack tight on the left, slack\n absorbed by the 1fr column between the relay and the power\n reading, keeping power + gear pinned to the right edge. The\n previous layout placed the slack between the shedding icon and\n the relay, which read as wasted padding the user pointed out. */\n grid-template-columns: auto auto auto auto 1fr auto auto;\n /* Rows: name and controls sized to content; chart absorbs any\n extra cell height. Without the explicit 1fr on row 3, a tall\n cell (e.g. .circuit-col-span\'s 280px min-height for 240V\n double-pole breakers) distributes excess space equally across\n all three rows via the default align-content:stretch, which\n pushes the chart down and vertically inflates the badge and\n relay toggle to fill the controls row. */\n grid-template-rows: auto auto 1fr;\n grid-template-areas:\n "name name name name name name name"\n "badge util shed status . power gear"\n "chart chart chart chart chart chart chart";\n row-gap: 6px;\n column-gap: 8px;\n }\n .circuit-slot.is-folded > .circuit-header,\n .circuit-slot.is-folded > .circuit-status,\n .circuit-slot.is-folded > .circuit-header > .circuit-info,\n .circuit-slot.is-folded > .circuit-header > .circuit-controls {\n display: contents;\n }\n .circuit-slot.is-folded .circuit-name {\n grid-area: name;\n justify-self: start;\n }\n .circuit-slot.is-folded .breaker-badge {\n grid-area: badge;\n }\n .circuit-slot.is-folded .utilization {\n grid-area: util;\n }\n .circuit-slot.is-folded .shedding-icon,\n .circuit-slot.is-folded .shedding-composite {\n grid-area: shed;\n }\n .circuit-slot.is-folded .toggle-pill {\n grid-area: status;\n justify-self: end;\n }\n .circuit-slot.is-folded .power-value {\n grid-area: power;\n justify-self: end;\n }\n .circuit-slot.is-folded .gear-icon.circuit-gear {\n grid-area: gear;\n justify-self: end;\n }\n .circuit-slot.is-folded > .chart-container {\n grid-area: chart;\n }\n\n .power-value { font-size: 0.9em; color: var(--primary-text-color, #fff); white-space: nowrap; }\n .power-value strong { font-weight: 700; font-size: 1.1em; }\n .power-unit { font-size: 0.8em; font-weight: 400; color: var(--secondary-text-color, #999); margin-left: 1px; }\n .circuit-producer .power-value strong { color: var(--info-color, #4fc3f7); }\n\n .toggle-pill {\n display: flex;\n align-items: center;\n gap: 3px;\n padding: 2px 4px;\n border-radius: 10px;\n cursor: pointer;\n font-size: 0.65em;\n font-weight: 600;\n transition: background 0.2s;\n user-select: none;\n min-width: 40px;\n }\n .toggle-on {\n padding-left: 6px;\n background: color-mix(in srgb, var(--state-active-color, var(--span-accent)) 25%, transparent);\n color: var(--state-active-color, var(--span-accent));\n }\n .toggle-off {\n padding-right: 6px;\n background: color-mix(in srgb, var(--secondary-text-color) 15%, transparent);\n color: var(--secondary-text-color, #999);\n }\n .toggle-knob {\n width: 14px;\n height: 14px;\n border-radius: 50%;\n transition: background 0.2s, margin 0.2s;\n }\n .toggle-on .toggle-knob {\n background: var(--state-active-color, var(--span-accent));\n margin-left: auto;\n }\n .toggle-off .toggle-knob {\n background: var(--secondary-text-color, #999);\n margin-right: auto;\n order: -1;\n }\n\n .circuit-status {\n display: flex;\n align-items: center;\n gap: 4px;\n margin-top: 4px;\n padding: 0 4px;\n }\n .shedding-icon { opacity: 0.8; cursor: default; }\n .shedding-composite {\n display: inline-flex;\n align-items: center;\n gap: 2px;\n }\n .shedding-icon-secondary { opacity: 0.8; }\n .shedding-label {\n font-size: 10px;\n font-weight: 600;\n opacity: 0.8;\n }\n .gear-icon {\n background: none;\n border: none;\n cursor: pointer;\n padding: 2px;\n opacity: 0.6;\n transition: opacity 0.2s;\n margin-left: auto;\n }\n .gear-icon:hover { opacity: 1; }\n .utilization {\n font-size: 0.75em;\n font-weight: 600;\n }\n .utilization-normal { color: #4caf50; }\n .utilization-warning { color: #ff9800; }\n .utilization-alert { color: #f44336; }\n .circuit-alert {\n border-color: #f44336 !important;\n box-shadow: 0 0 8px rgba(244, 67, 54, 0.3);\n }\n .chart-container {\n width: 100%;\n aspect-ratio: 4 / 1;\n margin-top: 4px;\n overflow: hidden;\n min-width: 0;\n }\n\n .sub-devices {\n display: grid;\n grid-template-columns: repeat(2, 1fr);\n gap: 12px;\n margin-bottom: 20px;\n padding-bottom: 16px;\n border-bottom: 1px solid var(--divider-color, #333);\n }\n\n .sub-device {\n background: var(--secondary-background-color, var(--card-background-color, #2a2a2a));\n border: 1px solid var(--divider-color, #333);\n border-radius: 12px;\n padding: 14px 16px;\n }\n .sub-device-bess,\n .sub-device-full {\n grid-column: 1 / -1;\n }\n\n .sub-device-header { display: flex; gap: 10px; align-items: baseline; margin-bottom: 8px; }\n .sub-device-type { font-size: 0.7em; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: var(--span-accent); }\n .sub-device-name { font-size: 0.85em; color: var(--secondary-text-color, #999); flex: 1; }\n .sub-power-value { font-size: 0.9em; color: var(--primary-text-color, #fff); white-space: nowrap; }\n .sub-power-value strong { font-weight: 700; font-size: 1.1em; }\n .sub-device .chart-container { margin-bottom: 8px; aspect-ratio: auto; }\n\n .bess-charts {\n display: grid;\n grid-template-columns: repeat(auto-fit, minmax(0, 1fr));\n gap: 12px;\n margin-bottom: 10px;\n }\n .bess-chart-col { min-width: 0; }\n .bess-chart-title {\n font-size: 0.75em;\n font-weight: 600;\n text-transform: uppercase;\n letter-spacing: 0.04em;\n color: var(--secondary-text-color, #999);\n margin-bottom: 4px;\n }\n .bess-chart-col .chart-container { aspect-ratio: auto; }\n .sub-entity { display: flex; gap: 6px; padding: 3px 0; font-size: 0.85em; }\n .sub-entity-name { color: var(--secondary-text-color, #999); }\n .sub-entity-value { font-weight: 500; color: var(--primary-text-color, #e0e0e0); }\n\n /* ── Shared tab bar ────────────────────────────────────── */\n\n .shared-tab-bar {\n display: flex;\n gap: 0;\n margin-bottom: 16px;\n border-bottom: 1px solid var(--divider-color, #333);\n }\n\n .shared-tab {\n padding: 8px 16px;\n cursor: pointer;\n font-size: 0.9em;\n font-weight: 500;\n color: var(--primary-text-color);\n opacity: 0.6;\n border: none;\n border-bottom: 2px solid transparent;\n background: none;\n transition: opacity 0.15s;\n }\n\n .shared-tab:hover {\n opacity: 0.85;\n }\n\n .shared-tab.active {\n opacity: 1;\n border-bottom-color: var(--span-accent);\n }\n\n /* ── List view search ──────────────────────────────────── */\n\n .list-search-container {\n margin-bottom: 12px;\n position: relative;\n }\n\n .list-search {\n width: 100%;\n padding: 8px 36px 8px 12px;\n border-radius: 8px;\n border: 1px solid var(--divider-color, #333);\n background: var(--secondary-background-color, #2a2a2a);\n color: var(--primary-text-color);\n font-size: 0.9em;\n box-sizing: border-box;\n outline: none;\n }\n\n .list-search:focus {\n border-color: var(--span-accent);\n }\n\n .list-search-clear {\n position: absolute;\n right: 8px;\n top: 50%;\n transform: translateY(-50%);\n background: none;\n border: none;\n color: var(--secondary-text-color);\n cursor: pointer;\n padding: 2px;\n display: flex;\n align-items: center;\n opacity: 0.7;\n }\n\n .list-search-clear:hover {\n opacity: 1;\n }\n\n .list-unit-toggle {\n display: inline-flex;\n margin-bottom: 12px;\n }\n\n /* ── List rows ─────────────────────────────────────────── */\n\n .list-view {\n display: flex;\n flex-direction: column;\n gap: 6px;\n }\n /* Each circuit is wrapped in a .list-cell so the row + its optional\n expanded chart stay together. In single-column flex mode the cell\n just stacks naturally. In multi-column grid mode the cell becomes\n one grid item, so the chart is always in the same column as its\n row. Area headers (rendered as siblings, not inside a cell) span\n all columns via their inline "grid-column: 1 / -1". */\n .list-cell {\n display: flex;\n flex-direction: column;\n min-width: 0;\n }\n .list-view[data-columns="2"],\n .list-view[data-columns="3"] {\n display: grid;\n grid-template-columns: repeat(var(--list-cols), minmax(0, 1fr));\n gap: 6px 8px;\n flex-direction: initial;\n }\n /* On narrow viewports a 2/3-column list would squeeze rows into an\n unreadable shape, so force stacking regardless of user preference. */\n @media (max-width: 599px) {\n .list-view[data-columns="2"],\n .list-view[data-columns="3"] {\n display: flex;\n flex-direction: column;\n }\n }\n\n .list-row {\n display: flex;\n align-items: center;\n padding: 12px 16px;\n gap: 10px;\n /* min-width: 0 lets the row shrink below the sum of its\n non-shrinking children when its parent .list-cell is in a\n narrow CSS-grid track (multi-column list mode). Without this\n the row would maintain its intrinsic min-content width and\n overflow the cell, leaving the name unshrunk and the\n truncation-fold observer with no signal to react to. */\n min-width: 0;\n background: var(--card-background-color, #1c1c1c);\n border: 1px solid var(--divider-color, #333);\n border-radius: 8px;\n cursor: pointer;\n transition: background 0.15s;\n }\n\n .list-row:hover {\n background: var(--secondary-background-color, #2a2a2a);\n }\n\n .list-row.circuit-off {\n opacity: 0.5;\n }\n\n .list-row.list-row-expanded {\n border-bottom-left-radius: 0;\n border-bottom-right-radius: 0;\n border-bottom-color: transparent;\n }\n\n .list-circuit-name {\n flex: 1;\n color: var(--primary-text-color);\n font-size: 0.9em;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n }\n\n .list-status-badge {\n font-size: 0.75em;\n font-weight: 600;\n padding: 2px 8px;\n border-radius: 4px;\n flex-shrink: 0;\n }\n\n .list-status-on {\n color: #4dd9af;\n }\n\n .list-status-off {\n color: #f44336;\n }\n\n .list-power-value {\n font-size: 0.9em;\n font-weight: 600;\n flex-shrink: 0;\n /* No min-width / text-align:right: the old 70px right-aligned\n cell left a visible blank column for short readings (e.g.\n "1.3A" in a 70px slot), which robbed horizontal space from\n .list-circuit-name on narrow rows. Let the value hug the\n preceding relay control and size to its content so the freed\n width flows back into the flex:1 name column. */\n }\n\n .list-expand-toggle {\n background: none;\n border: none;\n color: var(--secondary-text-color);\n cursor: pointer;\n padding: 4px;\n transition: transform 0.2s;\n display: flex;\n align-items: center;\n flex-shrink: 0;\n }\n\n .list-expand-toggle.expanded {\n transform: rotate(180deg);\n }\n\n .list-row .gear-icon {\n background: transparent;\n border: none;\n padding: 2px;\n cursor: pointer;\n color: #555;\n display: inline-flex;\n align-items: center;\n }\n .list-row .gear-icon:hover {\n color: var(--primary-text-color);\n }\n\n /* Truncation-driven fold for list rows. The .is-folded class is\n added/removed by the JS observer in src/core/truncation-fold.ts\n when the .list-circuit-name actually ellipsizes — pixel breakpoints\n can\'t track this because name length varies wildly per circuit\n ("Spa" vs "Commissioned PV System") and any single threshold\n misfires for the other end of the range. Switch to a two-row grid\n so the name gets the full width (paired only with the expand\n chevron) and the badges/controls/reading/gear drop to a secondary\n row underneath. Named areas keep the CSS readable despite the flat\n HTML child order. */\n .list-row.is-folded {\n display: grid;\n /* Row 1: name spans the row up to the chevron at the trailing\n column. Row 2: badge + util + shed + relay-toggle pack left,\n the 1fr column absorbs slack between the relay and the power\n reading, power + gear stay pinned to the right edge. The\n earlier layout placed the slack between the shedding icon and\n the relay, which the user flagged as wasted padding. */\n grid-template-columns: auto auto auto auto 1fr auto auto;\n grid-template-areas:\n "name name name name name name chevron"\n "badge util shed status . power gear";\n row-gap: 6px;\n column-gap: 8px;\n }\n .list-row.is-folded > .list-circuit-name {\n grid-area: name;\n justify-self: start;\n }\n .list-row.is-folded > .list-expand-toggle {\n grid-area: chevron;\n }\n .list-row.is-folded > .breaker-badge {\n grid-area: badge;\n }\n .list-row.is-folded > .utilization {\n grid-area: util;\n }\n .list-row.is-folded > .shedding-icon,\n .list-row.is-folded > .shedding-composite {\n grid-area: shed;\n }\n .list-row.is-folded > .toggle-pill,\n .list-row.is-folded > .list-status-badge {\n grid-area: status;\n }\n .list-row.is-folded > .list-power-value {\n grid-area: power;\n justify-self: end;\n }\n .list-row.is-folded > .gear-icon.circuit-gear {\n grid-area: gear;\n justify-self: end;\n }\n\n /* ── Expanded circuit content ──────────────────────────── */\n\n .list-expanded-content {\n padding: 0;\n background: var(--card-background-color, #1c1c1c);\n border: 1px solid var(--divider-color, #333);\n border-top: none;\n border-radius: 0 0 8px 8px;\n margin-top: -6px;\n margin-bottom: 2px;\n }\n\n .circuit-slot.circuit-chart-only {\n border: none;\n margin: 0;\n background: none;\n padding: 8px 12px;\n min-height: 0;\n }\n\n /* ── Area headers ──────────────────────────────────────── */\n\n .area-header {\n padding: 16px 12px 6px;\n font-weight: 600;\n font-size: 0.85em;\n color: var(--secondary-text-color);\n text-transform: uppercase;\n letter-spacing: 0.05em;\n }\n\n /* ── No results ────────────────────────────────────────── */\n\n .list-no-results {\n padding: 24px;\n text-align: center;\n color: var(--secondary-text-color);\n }\n\n';class hT{constructor(){this._ctrl=new lT,this._container=null,this._onGearClick=null,this._onToggleClick=null,this._onSidePanelClosed=null,this._onGraphSettingsChanged=null,this._foldUnobserve=null}get hass(){return this._ctrl.hass}set hass(t){this._ctrl.hass=t}set errorStore(t){this._ctrl.errorStore=t}setPanelFavorites(t){this._ctrl.setPanelFavorites(t)}async render(t,e,i,r,o){let a,s;this.stop(),this._ctrl.reset(),this._ctrl.showMonitoring=!0,this._container=t,this._ctrl.hass=e;try{const t=await se(e,i);a=t.topology,s=t.panelSize}catch(e){return void(t.innerHTML=`

${Bt(e.message)}

`)}this._ctrl.init(a,r,e,o??null),await this._ctrl.monitoringCache.fetch(e,o??null),await this._ctrl.fetchAndBuildHorizonMaps();const l=Math.ceil(s/2),c=this._ctrl.monitoringCache.status,u=ue(a,r),h=function(t){if(!t)return"";const e=Object.values(t.circuits??{}),i=Object.values(t.mains??{}),r=[...e,...i],o=r.filter(t=>void 0!==t.utilization_pct&&t.utilization_pct>=80&&t.utilization_pct<100).length,a=r.filter(t=>void 0!==t.utilization_pct&&t.utilization_pct>=100).length,s=r.filter(t=>t.has_override).length;return`\n
\n ✓ ${n("status.monitoring")} · ${e.length} ${n("status.circuits")} · ${i.length} ${n("status.mains")}\n \n ${o>0?`${o} ${n(o>1?"status.warnings":"status.warning")}`:""}\n ${a>0?`${a} ${n(a>1?"status.alerts":"status.alert")}`:""}\n ${s>0?`${s} ${n(s>1?"status.overrides":"status.override")}`:""}\n \n
\n `}(c),d=function(t,e,n,i,r){const o=new Map,a=new Set;for(const[e,n]of Object.entries(t.circuits)){const t=n.tabs;if(!t||0===t.length)continue;const i=Math.min(...t),r=1===t.length?"single":me(t)??"single";o.set(i,{uuid:e,circuit:n,layout:r});for(const e of t)a.add(e)}const s=new Set,l=new Set;for(const[t,e]of o)if("col-span"===e.layout){const n=e.circuit.tabs,i=ge(Math.max(...n));0===ve(t)?s.add(i):l.add(i)}function c(t){const e=t.circuit.entities?.current??t.circuit.entities?.power,i=r?we(r,e??""):null;let o;if(t.circuit.always_on)o="always_on";else{const e=t.circuit.entities?.select;o=e&&n.states[e]?n.states[e].state:"unknown"}return{monInfo:i,sheddingPriority:o}}let u="";for(let t=1;t<=e;t++){const e=2*t-1,r=2*t,h=o.get(e),d=o.get(r);if(u+=`
${e}
`,h&&"row-span"===h.layout){const{monInfo:e,sheddingPriority:o}=c(h);u+=Ce(h.uuid,h.circuit,t,"2 / 5","row-span",n,i,e,o),u+=`
${r}
`;continue}if(!s.has(t))if(!h||"col-span"!==h.layout&&"single"!==h.layout)a.has(e)||(u+=ke(t,"2"));else{const{monInfo:e,sheddingPriority:r}=c(h);u+=Ce(h.uuid,h.circuit,t,"2",h.layout,n,i,e,r)}if(!l.has(t))if(!d||"col-span"!==d.layout&&"single"!==d.layout)a.has(r)||(u+=ke(t,"4"));else{const{monInfo:e,sheddingPriority:r}=c(d);u+=Ce(d.uuid,d.circuit,t,"4",d.layout,n,i,e,r)}u+=`
${r}
`}return u}(a,l,e,r,c),p=Ne(a,e,r);t.innerHTML=`\n \n ${u}\n ${h}\n ${p?`
${p}
`:""}\n ${!1!==r.show_panel?`\n
\n ${d}\n
\n `:""}\n \n `,this._onGearClick=e=>{this._ctrl.onGearClick(e,t)},this._onToggleClick=e=>{this._ctrl.onToggleClick(e,t)},t.addEventListener("click",this._onGearClick),t.addEventListener("click",this._onToggleClick),this._onSidePanelClosed=()=>{this._ctrl.monitoringCache.invalidate(),this._ctrl.graphSettingsCache.invalidate()},t.addEventListener("side-panel-closed",this._onSidePanelClosed),this._onGraphSettingsChanged=()=>this._ctrl.onGraphSettingsChanged(t),t.addEventListener("graph-settings-changed",this._onGraphSettingsChanged);try{await this._ctrl.loadHistory()}catch{}this._ctrl.updateDOM(t);const f=t.querySelector(".slide-confirm");f&&(this._ctrl.bindSlideConfirm(f,t),t.classList.add("switches-disabled")),this._ctrl.setupResizeObserver(t,t),this._ctrl.startIntervals(t),this._foldUnobserve&&this._foldUnobserve(),this._foldUnobserve=cT(t,{rowSelector:".circuit-slot",nameSelector:".circuit-name",foldClass:"is-folded"})}stop(){this._ctrl.stopIntervals(),this._foldUnobserve&&(this._foldUnobserve(),this._foldUnobserve=null),this._container&&(this._onGearClick&&(this._container.removeEventListener("click",this._onGearClick),this._onGearClick=null),this._onToggleClick&&(this._container.removeEventListener("click",this._onToggleClick),this._onToggleClick=null),this._onSidePanelClosed&&(this._container.removeEventListener("side-panel-closed",this._onSidePanelClosed),this._onSidePanelClosed=null),this._onGraphSettingsChanged&&(this._container.removeEventListener("graph-settings-changed",this._onGraphSettingsChanged),this._onGraphSettingsChanged=null))}}const dT="\n display:flex;align-items:center;gap:8px;margin-bottom:8px;\n",pT="\n background:var(--secondary-background-color,#333);\n border:1px solid var(--divider-color);\n color:var(--primary-text-color);\n border-radius:4px;padding:6px 10px;width:80px;font-size:0.85em;\n",fT="\n min-width:130px;font-size:0.85em;color:var(--secondary-text-color);\n",gT="\n min-width:160px;font-size:0.85em;color:var(--secondary-text-color);\n",vT="\n background:var(--secondary-background-color,#333);\n border:1px solid var(--divider-color);\n color:var(--primary-text-color);\n border-radius:4px;padding:6px 10px;flex:1;font-size:0.85em;\n font-family:monospace;\n";function mT(t,e,n,i,r){return`\n ${i}\n `}class yT{constructor(){this.errorStore=null,this._debounceTimer=null,this._configEntryId=null,this._notifyCloseHandler=null,this._headerHTML=""}stop(){this._notifyCloseHandler&&(document.removeEventListener("click",this._notifyCloseHandler),this._notifyCloseHandler=null),this._debounceTimer&&(clearTimeout(this._debounceTimer),this._debounceTimer=null)}async render(t,e,i,r=""){let o;void 0!==i&&(this._configEntryId=i),this._headerHTML=r,this._notifyCloseHandler&&(document.removeEventListener("click",this._notifyCloseHandler),this._notifyCloseHandler=null);try{const t={};this._configEntryId&&(t.config_entry_id=this._configEntryId);const n=await e.callWS({type:"call_service",domain:s,service:"get_monitoring_status",service_data:t,return_response:!0});o=function(t){if(!t||"object"!=typeof t)return null;const e=t,n={};return"boolean"==typeof e.enabled&&(n.enabled=e.enabled),e.global_settings&&"object"==typeof e.global_settings&&(n.global_settings=e.global_settings),e.circuits&&"object"==typeof e.circuits&&(n.circuits=e.circuits),e.mains&&"object"==typeof e.mains&&(n.mains=e.mains),n}(n?.response)}catch(t){console.warn("SPAN Panel: monitoring status fetch failed",t),o=null}const a=o?.global_settings??{},l=!0===o?.enabled,c=o?.circuits??{},u=o?.mains??{},h=new Set;for(const t of Object.keys(e.states||{}))t.startsWith("notify.")&&h.add(t);const d=new Set(["notify","send_message"]);for(const t of Object.keys(e.services?.notify||{}))d.has(t)||h.add(`notify.${t}`);h.add("event_bus");const p=[...h].sort(),f=a.notify_targets??"",g=("string"==typeof f?f.split(","):f).map(t=>t.trim()).filter(Boolean),v=p.length>0&&p.every(t=>g.includes(t)),m=a.notification_title_template??"SPAN: {name} {alert_type}",y=a.notification_message_template??"{name} at {current_a}A ({utilization_pct}% of {breaker_rating_a}A rating)",_=a.notification_priority??"default",b=Object.entries(c).sort(([,t],[,e])=>(t.name??"").localeCompare(e.name??"")),x=Object.entries(u),w=[...b,...x],S=w.length>0&&w.every(([,t])=>!1!==t.monitoring_enabled),C=w.some(([,t])=>!1!==t.monitoring_enabled),k=b.map(([t,e])=>{const i=Bt(e.name??t),r=!1!==e.monitoring_enabled,o=!0===e.has_override,a=r?"":"opacity:0.4;",s=Bt(t);return`\n \n \n \n \n ${mT(s,"continuous_threshold_pct",e.continuous_threshold_pct,"%","circuit")}\n ${mT(s,"spike_threshold_pct",e.spike_threshold_pct,"%","circuit")}\n ${mT(s,"window_duration_m",e.window_duration_m,"m","circuit")}\n ${mT(s,"cooldown_duration_m",e.cooldown_duration_m,"m","circuit")}\n \n ${o?``:""}\n \n \n `}).join(""),M=Object.entries(u).map(([t,e])=>{const i=Bt(e.name??t),r=!1!==e.monitoring_enabled,o=!0===e.has_override,a=r?"":"opacity:0.4;",s=Bt(t);return`\n \n \n \n \n ${mT(s,"continuous_threshold_pct",e.continuous_threshold_pct,"%","mains")}\n ${mT(s,"spike_threshold_pct",e.spike_threshold_pct,"%","mains")}\n ${mT(s,"window_duration_m",e.window_duration_m,"m","mains")}\n ${mT(s,"cooldown_duration_m",e.cooldown_duration_m,"m","mains")}\n \n ${o?``:""}\n \n \n `}).join("");t.innerHTML=`\n ${this._headerHTML}\n
\n

${n("monitoring.heading")}

\n\n
\n
\n

${n("monitoring.global_settings")}

\n \n
\n\n
\n
\n ${n("monitoring.continuous")}\n \n
\n
\n ${n("monitoring.spike")}\n \n
\n
\n ${n("monitoring.window")}\n \n
\n
\n ${n("monitoring.cooldown")}\n \n
\n\n
\n

${n("notification.heading")}

\n\n
\n ${n("notification.targets")}\n \n
\n \n
\n ${0===p.length?`
${n("notification.no_targets")}
`:p.map(t=>{const i=g.includes(t),r="event_bus"===t,o=r?null:e.states[t],a=o?.attributes?.friendly_name,s=r?n("notification.event_bus_target"):a?`${Bt(a)} (${Bt(t)})`:Bt(t);return``}).join("")}\n
\n
\n
\n\n
\n ${n("notification.priority")}\n \n \n ${"critical"===_?n("notification.hint.critical"):"time-sensitive"===_?n("notification.hint.time_sensitive"):"passive"===_?n("notification.hint.passive"):"active"===_?n("notification.hint.active"):""}\n \n
\n\n
\n ${n("notification.title_template")}\n \n
\n\n
\n ${n("notification.message_template")}\n \n
\n\n
\n ${n("notification.placeholders")} {name} {entity_id} {alert_type}\n {current_a} {breaker_rating_a} {threshold_pct}\n {utilization_pct} {window_m} {local_time}\n
\n
\n ${n("notification.event_bus_help")} span_panel_current_alert\n ${n("notification.event_bus_payload")} alert_source alert_id\n alert_name alert_type current_a\n breaker_rating_a threshold_pct utilization_pct\n panel_serial window_duration_s local_time\n
\n\n
\n ${n("notification.test_label")}\n \n \n
\n
\n\n
\n
\n\n

${n("monitoring.monitored_points")}

\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n ${M}\n ${k}\n \n
${n("monitoring.col.name")}${n("monitoring.col.continuous")}${n("monitoring.col.spike")}${n("monitoring.col.window")}${n("monitoring.col.cooldown")}
\n \n
\n
\n `;const T=t.querySelector("#toggle-all-circuits");T&&!S&&C&&(T.indeterminate=!0);const I=t.querySelector("#notify-all-targets");if(I&&p.length>0){const t=g.length>0;!v&&t&&(I.indeterminate=!0)}this._bindGlobalControls(t,e),this._bindNotifyTargetSelect(t,e),this._bindNotificationSettings(t,e),this._bindToggleAll(t,e,c,u),this._bindCircuitToggles(t,e),this._bindMainsToggles(t,e),this._bindThresholdInputs(t,e),this._bindResetButtons(t,e)}_serviceData(t){return this._configEntryId&&(t.config_entry_id=this._configEntryId),t}_callSetGlobal(t,e){return t.callWS({type:"call_service",domain:s,service:"set_global_monitoring",service_data:this._serviceData({...e})})}_bindGlobalControls(t,e){const i=t.querySelector("#monitoring-enabled"),r=t.querySelector("#global-fields"),o=t.querySelector("#global-status"),a=()=>{const e=[["continuous_threshold_pct","#g-continuous"],["spike_threshold_pct","#g-spike"],["window_duration_m","#g-window"],["cooldown_duration_m","#g-cooldown"]],n={};for(const[i,r]of e){const e=t.querySelector(r);if(!e)return null;const o=parseInt(e.value,10);if(Number.isNaN(o))return null;n[i]=o}return n},s=(t,e,i)=>{if(!t)return;const r=e instanceof Error?e.message:i;t.textContent=`${n("error.prefix")} ${r}`,t.style.color="var(--error-color, #f44336)"},l=()=>{this._debounceTimer&&clearTimeout(this._debounceTimer),this._debounceTimer=setTimeout(async()=>{const i=a();if(i)try{await this._callSetGlobal(e,i),await this.render(t,e)}catch(t){s(o,t,n("error.failed_save"))}else s(o,null,n("error.failed_save"))},p)};i&&i.addEventListener("change",async()=>{const o=i.checked;r&&(r.style.opacity=o?"":"0.4",r.style.pointerEvents=o?"":"none");const l=t.querySelector("#global-status");try{if(o){const t=a();if(!t)return void s(l,null,n("error.failed"));await this._callSetGlobal(e,t)}else await this._callSetGlobal(e,{enabled:!1})}catch(t){return void s(l,t,n("error.failed"))}await this.render(t,e)});for(const e of t.querySelectorAll("#global-fields input[type=number]"))e.addEventListener("input",l)}_bindNotifyTargetSelect(t,e){const i=t.querySelector("#notify-target-btn"),r=t.querySelector("#notify-target-dropdown"),o=t.querySelector("#notify-target-label");if(!i||!r)return;i.addEventListener("click",t=>{t.stopPropagation();const e="none"!==r.style.display;r.style.display=e?"none":"block"});const a=e=>{const n=t.querySelector("#notify-target-select");n&&!n.contains(e.target)&&(r.style.display="none")};document.addEventListener("click",a),this._notifyCloseHandler=a;const s=()=>{const i=[...t.querySelectorAll(".notify-target-cb:checked")].map(t=>t.value);if(o){const t=i.map(t=>"event_bus"===t?n("notification.event_bus_target"):t);o.textContent=t.length?t.join(", "):n("notification.none_selected")}const r=t.querySelector("#notify-all-targets");if(r){const e=[...t.querySelectorAll(".notify-target-cb")];r.checked=e.length>0&&e.every(t=>t.checked),r.indeterminate=!r.checked&&e.some(t=>t.checked)}this._debounceTimer&&clearTimeout(this._debounceTimer),this._debounceTimer=setTimeout(async()=>{try{await this._callSetGlobal(e,{notify_targets:i.join(", ")})}catch(t){console.warn("SPAN Panel: notification targets save failed",t),this.errorStore?.add({key:"service:monitoring",level:"error",message:n("error.threshold_failed"),persistent:!1})}},p)},l=t.querySelector("#notify-all-targets");l&&l.addEventListener("change",()=>{for(const e of t.querySelectorAll(".notify-target-cb"))e.checked=l.checked;const e=t.querySelector("#notify-target-btn");e&&(e.style.opacity=l.checked?"0.4":"",e.style.pointerEvents=l.checked?"none":""),l.checked&&(r.style.display="none"),s()});for(const e of t.querySelectorAll(".notify-target-cb"))e.addEventListener("change",()=>{s()})}_bindNotificationSettings(t,e){const i=t.querySelector("#g-priority"),r=t.querySelector("#g-title-template"),o=t.querySelector("#g-message-template"),a=(t,i)=>{this._debounceTimer&&clearTimeout(this._debounceTimer),this._debounceTimer=setTimeout(async()=>{try{await this._callSetGlobal(e,{[t]:i})}catch(t){console.warn("SPAN Panel: notification settings save failed",t),this.errorStore?.add({key:"service:monitoring",level:"error",message:n("error.threshold_failed"),persistent:!1})}},p)};i&&i.addEventListener("change",async()=>{try{await this._callSetGlobal(e,{notification_priority:i.value}),await this.render(t,e)}catch(t){console.warn("SPAN Panel: notification priority change failed",t),this.errorStore?.add({key:"service:monitoring",level:"error",message:n("error.threshold_failed"),persistent:!1})}}),r&&r.addEventListener("input",()=>{a("notification_title_template",r.value)}),o&&o.addEventListener("input",()=>{a("notification_message_template",o.value)});const l=t.querySelector("#test-notification-btn"),c=t.querySelector("#test-notification-status");l&&l.addEventListener("click",async()=>{l.disabled=!0,c&&(c.textContent=n("notification.test_sending"),c.style.color="var(--secondary-text-color)");try{this._debounceTimer&&(clearTimeout(this._debounceTimer),this._debounceTimer=null);const i=[...t.querySelectorAll(".notify-target-cb:checked")].map(t=>t.value).join(", ");await this._callSetGlobal(e,{notify_targets:i});const r={};this._configEntryId&&(r.config_entry_id=this._configEntryId),await e.callWS({type:"call_service",domain:s,service:"test_notification",service_data:r}),c&&(c.textContent=n("notification.test_sent"),c.style.color="var(--success-color, #4caf50)")}catch(t){if(c){const e=t instanceof Error?t.message:n("error.failed");c.textContent=`${n("error.prefix")} ${e}`,c.style.color="var(--error-color, #f44336)"}}finally{l.disabled=!1}})}_bindToggleAll(t,e,i,r){const o=t.querySelector("#toggle-all-circuits");o&&o.addEventListener("change",async()=>{const a=o.checked,l=[...Object.keys(i).map(t=>e.callWS({type:"call_service",domain:s,service:"set_circuit_threshold",service_data:this._serviceData({circuit_id:t,monitoring_enabled:a})}).catch(t=>{console.warn("SPAN Panel: circuit monitoring toggle failed",t),this.errorStore?.add({key:"service:monitoring",level:"error",message:n("error.threshold_failed"),persistent:!1})})),...Object.keys(r).map(t=>e.callWS({type:"call_service",domain:s,service:"set_mains_threshold",service_data:this._serviceData({leg:t,monitoring_enabled:a})}).catch(t=>{console.warn("SPAN Panel: mains monitoring toggle failed",t),this.errorStore?.add({key:"service:monitoring",level:"error",message:n("error.threshold_failed"),persistent:!1})}))];await Promise.all(l),await this.render(t,e)})}_bindMainsToggles(t,e){for(const i of t.querySelectorAll(".mains-toggle"))i.addEventListener("change",async()=>{const r=i.dataset.entity,o=i.checked;try{await e.callWS({type:"call_service",domain:s,service:"set_mains_threshold",service_data:this._serviceData({leg:r,monitoring_enabled:o})})}catch(t){return console.warn("SPAN Panel: mains threshold toggle failed",t),this.errorStore?.add({key:"service:monitoring",level:"error",message:n("error.threshold_failed"),persistent:!1}),void(i.checked=!o)}await this.render(t,e)})}_bindCircuitToggles(t,e){for(const i of t.querySelectorAll(".circuit-toggle"))i.addEventListener("change",async()=>{const r=i.dataset.entity,o=i.checked;try{await e.callWS({type:"call_service",domain:s,service:"set_circuit_threshold",service_data:this._serviceData({circuit_id:r,monitoring_enabled:o})})}catch(t){return console.warn("SPAN Panel: circuit threshold toggle failed",t),this.errorStore?.add({key:"service:monitoring",level:"error",message:n("error.threshold_failed"),persistent:!1}),void(i.checked=!o)}await this.render(t,e)})}_bindThresholdInputs(t,e){const i=new Map;for(const r of t.querySelectorAll(".threshold-input"))r.addEventListener("input",()=>{const o=`${r.dataset.entity}-${r.dataset.field}`,a=i.get(o);a&&clearTimeout(a),i.set(o,setTimeout(async()=>{const i=parseInt(r.value,10);if(!i||i<1)return;const o=r.dataset.entity,a=r.dataset.field,l=r.dataset.type,c="mains"===l?"set_mains_threshold":"set_circuit_threshold",u="mains"===l?"leg":"circuit_id";try{await e.callWS({type:"call_service",domain:s,service:c,service_data:this._serviceData({[u]:o,[a]:i})}),await this.render(t,e)}catch(t){console.warn("SPAN Panel: threshold input save failed",t),this.errorStore?.add({key:"service:monitoring",level:"error",message:n("error.threshold_failed"),persistent:!1}),r.style.borderColor="var(--error-color, #f44336)"}},800))})}_bindResetButtons(t,e){for(const n of t.querySelectorAll(".reset-btn"))n.addEventListener("click",async()=>{const i=n.dataset.entity;if(!i)return;const r=n.dataset.type,o="mains"===r?"clear_mains_threshold":"clear_circuit_threshold",a=this._serviceData("mains"===r?{leg:i}:{circuit_id:i});await e.callService(s,o,a),await this.render(t,e)})}}function _T(t=""){const e=t?` value="${Bt(t)}"`:"",i=t?"":"display:none;";return`\n
\n \n \n
\n `}function bT(t,e,i,r,o,a,s){const c=e.entities?.power,u=c?i.states[c]:null,h=u&&parseFloat(u.state)||0,d=e.entities?.switch,p=d?i.states[d]:null,f=p?"on"===p.state:(u?.attributes?.relay_state||e.relay_state)===l,g=e.breaker_rating_a,m=g?`${Math.round(g)}A`:"",y=Bt(e.name||n("grid.unknown")),_=ye(r),b="current"===_.entityRole;let x;if(f)if(b){const t=e.entities?.current,n=t?i.states[t]:null,r=n&&parseFloat(n.state)||0;x=`${_.format(r)}A`}else x=`${pe(h)}${de(h)}`;else x="";const w=a||"unknown";let S="";if("unknown"!==w){const t=v[w]??v.unknown??{icon:"mdi:help",color:"#999",label:()=>"Unknown"};S=t.icon2?`\n \n \n `:t.textLabel?`\n \n ${t.textLabel}\n `:``}let C="",k=o?.utilization_pct??null;if(null==k&&e.breaker_rating_a){const t=e.entities?.current,n=t?i.states[t]:null,r=n?Math.abs(parseFloat(n.state)||0):0;k=Math.round(r/e.breaker_rating_a*1e3)/10}if(null!=k){C=`=80?"utilization-warning":"utilization-normal"}">${Math.round(k)}%`}const M=``,T=!1!==e.is_user_controllable&&!!e.entities?.switch?`
\n ${n(f?"grid.on":"grid.off")}\n \n
`:`${f?"ON":"OFF"}`;return`\n
\n ${m?`${m}`:""}\n ${C}\n ${y}\n ${S}\n ${T}\n \n ${x}\n \n ${M}\n \n
\n `}function xT(t,e,n,i,r){const o=e.entities?.power,a=o?n.states[o]:null,s=a&&parseFloat(a.state)||0,u=e.device_type===c||s<0,h=e.entities?.switch,d=h?n.states[h]:null,p=Se(0,r,d?"on"===d.state:(a?.attributes?.relay_state||e.relay_state)===l,u),f=Bt(t);return`\n
\n
\n
\n
\n
\n `}function wT(t){return`
${Bt(t)}
`}function ST(t,e,n){const i=t.entities?.switch,r=i?e.states[i]:null,o=t.entities?.power,a=o?e.states[o]:null,s=r?"on"===r.state:(a?.attributes?.relay_state||t.relay_state)===l;let c;if("current"===(n.chart_metric||"power")){const n=t.entities?.current,i=n?e.states[n]:null;c=i?Math.abs(parseFloat(i.state)||0):0}else c=a?Math.abs(parseFloat(a.state)||0):0;return{isOn:s,value:c}}function CT(t,e){if(t.always_on)return"always_on";const n=t.entities?.select,i=n?e.states[n]:null;return i?i.state:"unknown"}function kT(t,e,n,i){const r=ST(t,n,i),o=ST(e,n,i);return r.isOn&&!o.isOn?-1:!r.isOn&&o.isOn?1:o.value-r.value}function MT(t,e,n){return t.sort((t,i)=>kT(t[1],i[1],e,n))}function TT(t){return t.entities?.current??t.entities?.power??""}class IT{constructor(t){this._expandedUuids=new Set,this._searchQuery="",this._container=null,this._clickHandler=null,this._inputHandler=null,this._graphSettingsHandler=null,this._hass=null,this._topology=null,this._config=null,this._monitoringStatus=null,this._viewName=null,this._columns=1,this._foldUnobserve=null,this._ctrl=t}setColumns(t){const e=Math.max(1,Math.min(3,Math.floor(t)));this._columns=e}setInitialExpansion(t){this._expandedUuids=new Set(t)}setInitialSearchQuery(t){this._searchQuery=t}setViewName(t){this._viewName=t}renderActivityView(t,e,n,i,r,o){this._unbindEvents(),this._hass=e,this._topology=n,this._config=i,this._monitoringStatus=r;const a=MT(Object.entries(n.circuits),e,i);let s=o+_T(this._searchQuery);s+=`
`;for(const[t,n]of a){const o=we(r,TT(n)),a=CT(n,e),l=this._expandedUuids.has(t);s+=`
`,s+=bT(t,n,e,i,o,a,l),l&&(s+=xT(t,n,e,0,o)),s+="
"}s+="
",s+="",t.innerHTML=s;const l=t.querySelector("span-side-panel");l&&(l.hass=e,l.errorStore=this._ctrl.errorStore),this._bindEvents(t),this._searchQuery&&this._applyFilter(t),this._ctrl.updateDOM(t),this._attachFoldObserver(t)}renderAreaView(t,e,i,r,o,a){this._unbindEvents(),this._hass=e,this._topology=i,this._config=r,this._monitoringStatus=o;const s=n("list.unassigned_area"),l=new Map;for(const[t,e]of Object.entries(i.circuits)){const n=e.area??s,i=l.get(n);i?i.push([t,e]):l.set(n,[[t,e]])}const c=[...l.keys()].sort((t,e)=>t===s?1:e===s?-1:t.localeCompare(e));let u=a+_T(this._searchQuery);u+=`
`;for(const t of c){const n=l.get(t);if(!n)continue;const i=MT(n,e,r);u+=wT(t);for(const[t,n]of i){const i=we(o,TT(n)),a=CT(n,e),s=this._expandedUuids.has(t);u+=`
`,u+=bT(t,n,e,r,i,a,s),s&&(u+=xT(t,n,e,0,i)),u+="
"}}u+="
",u+="",t.innerHTML=u;const h=t.querySelector("span-side-panel");h&&(h.hass=e,h.errorStore=this._ctrl.errorStore),this._bindEvents(t),this._searchQuery&&this._applyFilter(t),this._ctrl.updateDOM(t),this._attachFoldObserver(t)}updateCollapsedRows(t,e,i,r){const o=ye(r),a="current"===o.entityRole,s=t.querySelectorAll(".list-row[data-row-uuid]");for(const t of s){const s=t.dataset.rowUuid;if(!s)continue;const l=i.circuits[s];if(!l)continue;const{isOn:c,value:u}=ST(l,e,r),h=t.querySelector(".list-power-value");if(h)if(c)if(a)h.innerHTML=`${o.format(u)}A`;else{const t=l.entities?.power,n=t?e.states[t]:null,i=n&&parseFloat(n.state)||0;h.innerHTML=`${pe(i)}${de(i)}`}else h.innerHTML="";const d=t.querySelector(".toggle-pill");if(d){d.classList.toggle("toggle-on",c),d.classList.toggle("toggle-off",!c);const t=d.querySelector(".toggle-label");t&&(t.textContent=n(c?"grid.on":"grid.off"))}const p=t.querySelector(".list-status-badge");p&&(p.textContent=c?"ON":"OFF",p.classList.toggle("list-status-on",c),p.classList.toggle("list-status-off",!c)),t.classList.toggle("circuit-off",!c)}!function(t,e,n,i){const r=t.querySelector(".list-view");if(r)for(const t of function(t,e){let n={anchor:null,units:[]};const i=[n];for(const r of[...t.children])if(r.classList.contains("area-header"))n={anchor:r,units:[]},i.push(n);else if(r.classList.contains("list-cell")){const t=r.dataset.cellUuid,i=t?e.circuits[t]:void 0;t&&i&&n.units.push({cell:r,uuid:t,circuit:i})}return i}(r,n)){if(t.units.length<2)continue;const n=[...t.units].sort((t,n)=>kT(t.circuit,n.circuit,e,i));if(!n.some((e,n)=>e.uuid!==t.units[n].uuid))continue;let o=t.anchor;for(const t of n)o?o.after(t.cell):r.prepend(t.cell),o=t.cell}}(t,e,i,r)}stop(){this._unbindEvents(),null===this._viewName&&(this._expandedUuids.clear(),this._searchQuery=""),this._hass=null,this._topology=null,this._config=null,this._monitoringStatus=null}_dispatchFavoritesViewState(){if(!this._viewName||!this._container)return;const t={view:this._viewName,expanded:[...this._expandedUuids],searchQuery:this._searchQuery};this._container.dispatchEvent(new CustomEvent("favorites-view-state-changed",{detail:t,bubbles:!0,composed:!0}))}_bindEvents(t){this._container=t,this._clickHandler=e=>{const n=e.target;if(!n)return;const i=n.closest(".list-expand-toggle");if(i){const t=i.dataset.expandUuid;return void(t&&this._toggleExpand(t))}if(n.closest(".gear-icon"))return void this._ctrl.onGearClick(e,t);if(n.closest(".toggle-pill"))return void this._ctrl.onToggleClick(e,t);if(n.closest(".list-search-clear")){const e=t.querySelector(".list-search");return void(e&&(e.value="",e.dispatchEvent(new Event("input",{bubbles:!0}))))}const r=n.closest(".unit-btn");if(r){const e=r.dataset.unit;e&&t.dispatchEvent(new CustomEvent("unit-changed",{detail:e,bubbles:!0,composed:!0}))}},this._inputHandler=e=>{const n=e.target;n&&n.classList.contains("list-search")&&(this._searchQuery=n.value.toLowerCase(),this._applyFilter(t),this._dispatchFavoritesViewState())},this._graphSettingsHandler=()=>{this._ctrl.onGraphSettingsChanged(t).then(()=>{this._ctrl.updateDOM(t)}).catch(()=>{})},t.addEventListener("click",this._clickHandler),t.addEventListener("input",this._inputHandler),t.addEventListener("graph-settings-changed",this._graphSettingsHandler);const e=t.querySelector(".slide-confirm");e&&(this._ctrl.bindSlideConfirm(e,t),t.classList.add("switches-disabled"))}_unbindEvents(){this._container&&(this._clickHandler&&this._container.removeEventListener("click",this._clickHandler),this._inputHandler&&this._container.removeEventListener("input",this._inputHandler),this._graphSettingsHandler&&this._container.removeEventListener("graph-settings-changed",this._graphSettingsHandler)),this._foldUnobserve&&(this._foldUnobserve(),this._foldUnobserve=null),this._container=null,this._clickHandler=null,this._inputHandler=null,this._graphSettingsHandler=null}_attachFoldObserver(t){this._foldUnobserve&&(this._foldUnobserve(),this._foldUnobserve=null),this._foldUnobserve=cT(t,{rowSelector:".list-row",nameSelector:".list-circuit-name",foldClass:"is-folded"})}_applyFilter(t){const e=t.querySelector(".list-search-clear");e&&(e.style.display=this._searchQuery?"":"none");const n=t.querySelectorAll(".list-cell[data-cell-uuid]");for(const t of n){const e=t.querySelector(".list-circuit-name"),n=(e?.textContent?.toLowerCase()??"").includes(this._searchQuery);t.style.display=n?"":"none"}const i=t.querySelectorAll(".area-header");for(const t of i){let e=!1,n=t.nextElementSibling;for(;n&&!n.classList.contains("area-header");){if(n.classList.contains("list-cell")&&"none"!==n.style.display){e=!0;break}n=n.nextElementSibling}t.style.display=e?"":"none"}}_toggleExpand(t){if(!(this._container&&this._hass&&this._topology&&this._config))return;const e=nT(t),n=this._container.querySelector(`.list-cell[data-cell-uuid="${e}"]`);if(!n)return;const i=n.querySelector(`.list-row[data-row-uuid="${e}"]`),r=n.querySelector(`.list-expand-toggle[data-expand-uuid="${e}"]`);if(i){if(this._expandedUuids.has(t)){this._expandedUuids.delete(t);const o=n.querySelector(`.list-expanded-content[data-expanded-uuid="${e}"]`);o&&o.remove(),r&&r.classList.remove("expanded"),i.classList.remove("list-row-expanded")}else{this._expandedUuids.add(t);const e=this._topology.circuits[t];if(!e)return;const n=we(this._monitoringStatus,TT(e)),o=xT(t,e,this._hass,this._config,n);i.insertAdjacentHTML("afterend",o),r&&r.classList.add("expanded"),i.classList.add("list-row-expanded"),this._ctrl.updateDOM(this._container)}this._dispatchFavoritesViewState()}}}function DT(t,e){return`${t}|${e}`}class AT{async build(t,e,n,i){const r=new Map;for(const t of n)r.set(t.id,t);const o=i?new qt(i):null,a=[];for(const[n,i]of Object.entries(e)){if(!((i?.circuits?.length??0)>0||(i?.sub_devices?.length??0)>0))continue;const e=r.get(n);e&&a.push((async()=>{try{const i=await se(t,n,o);return{panelDeviceId:n,panel:e,topology:i.topology}}catch(t){return console.warn("SPAN Panel: favorites topology fetch failed",n,t),{panelDeviceId:n,panel:e,topology:null}}})())}const s=(await Promise.all(a)).filter(t=>null!==t.topology),l=s.length>1,c={},u={},h={},d=new Set,p=[];for(const{panelDeviceId:t,panel:n,topology:i}of s){if(!i)continue;const r=n.config_entries?.[0]??null;r&&d.add(r);const o=n.name_by_user??n.name??i.device_name??"";p.push({panelDeviceId:t,panelName:o,topology:i});const a=e[t],s=a?.circuits??[],f=a?.sub_devices??[];for(const e of s){const n=i.circuits?.[e];if(!n)continue;const a=DT(t,e),s=l&&o?`${o} · ${n.name}`:n.name;c[a]={...n,name:s},h[a]={panelDeviceId:t,kind:"circuit",targetId:e,configEntryId:r}}for(const e of f){const n=i.sub_devices?.[e];if(!n)continue;const a=DT(t,e),s=l&&o&&n.name?`${o} · ${n.name}`:n.name??e;u[a]={...n,name:s},h[a]={panelDeviceId:t,kind:"sub_device",targetId:e,configEntryId:r}}}return{topology:{circuits:c,sub_devices:u,panel_entities:{},device_name:"",_favoriteRefs:h},entryIds:Array.from(d),perPanelStats:p}}}const PT="span_panel_favorites_view_state";function LT(t){try{localStorage.setItem(PT,JSON.stringify(t))}catch{}}var ET;const zT="favorites";let NT=ET=class extends Dt{constructor(){super(...arguments),this.narrow=!1,this._panels=[],this._selectedPanelId=null,this._activeTab="dashboard",this._discovered=!1,this._listColumns=Wt(),this._favorites={},this._favoritesViewState={expanded:{activity:[],area:[]}},this._favoritesPanelStats=[],this._dashboardTab=new hT,this._monitoringTab=new yT,this._listDashCtrl=new lT,this._listCtrl=new IT(this._listDashCtrl),this._favCache=new Yt,this._favCtrl=new AT,this._favoritesMonitoringTabs=new Map,this._errorStore=new Ht,this._watchedPanelId=null,this._discovering=!1,this._refreshSeq=0,this._areaUnsub=null,this._areaSubscribing=!1,this._onFavoritesChanged=null,this._deviceRegistryUnsub=null,this._pendingTabRender=!1,this._persistFavoritesViewStateTimer=null,this._tabRenderScheduler=function(t){let e=null,n=!1;return async function i(){if(e)return n=!0,void await e.catch(()=>{});const r=(async()=>{try{await t()}finally{e=null,n&&(n=!1,await i())}})();e=r,await r}}(async()=>this._renderTab()),this._beginRender=function(){let t=0;return()=>{t+=1;const e=t;return()=>t!==e}}()}get _root(){const t=this.shadowRoot;if(!t)throw new Error("span-panel: shadow root is not available");return t}connectedCallback(){super.connectedCallback(),this._dashboardTab.errorStore=this._errorStore,this._listDashCtrl.errorStore=this._errorStore,this._favCache.errorStore=this._errorStore,this._monitoringTab.errorStore=this._errorStore,this._onFavoritesChanged=()=>{this._refreshFavorites()},document.addEventListener(jt,this._onFavoritesChanged),this._subscribeDeviceRegistry()}disconnectedCallback(){this._dashboardTab.stop(),this._monitoringTab.stop(),this._listCtrl.stop(),this._listDashCtrl.stopIntervals();for(const t of this._favoritesMonitoringTabs.values())t.stop();this._favoritesMonitoringTabs.clear(),this._areaSubscribing=!1,this._areaUnsub&&(this._areaUnsub(),this._areaUnsub=null),this._onFavoritesChanged&&(document.removeEventListener(jt,this._onFavoritesChanged),this._onFavoritesChanged=null),this._unsubscribeDeviceRegistry(),this._persistFavoritesViewStateTimer&&(clearTimeout(this._persistFavoritesViewStateTimer),this._persistFavoritesViewStateTimer=null),this._errorStore.dispose(),super.disconnectedCallback()}firstUpdated(){this.hass&&!this._discovered&&this._discoverPanels()}updated(t){if(t.has("hass")){const e=t.get("hass");this._dashboardTab.hass=this.hass,this._listDashCtrl.hass=this.hass,this._errorStore.updateHass(this.hass),this._discovered?this._root.getElementById("tab-content")||this._scheduleTabRender():this._discoverPanels(),!e&&this.hass&&this._subscribeDeviceRegistry()}if(this._discovered&&(t.has("_discovered")||t.has("_activeTab")||t.has("_selectedPanelId")||t.has("_chartMetric")||t.has("_listColumns"))){if(this._isFavoritesView&&"dashboard"===this._activeTab)return void(this._activeTab="activity");this._scheduleTabRender()}if(t.has("_selectedPanelId")&&(this._selectedPanelId!==zT&&this._selectedPanelId?(this._updatePanelStatusWatch(),this._listDashCtrl.setFavoritesPerPanelInfo(null)):(this._errorStore.clearPanelStatusWatch(),this._watchedPanelId=null)),this._discovered&&(t.has("_panels")||t.has("_selectedPanelId"))){const t=this.shadowRoot?.getElementById("panel-select");t&&null!==this._selectedPanelId&&t.value!==this._selectedPanelId&&(t.value=this._selectedPanelId)}if(t.has("hass")&&this._discovered&&("activity"===this._activeTab||"area"===this._activeTab)){const t=this._root.getElementById("tab-content"),e=this._listDashCtrl.topology;if(t&&e){this._listCtrl.updateCollapsedRows(t,this.hass,e,this._buildDashboardConfig());const n=t.querySelector("span-side-panel");n&&(n.hass=this.hass,n.errorStore=this._errorStore)}}}setConfig(t){}render(){var i,r,o;if(i=this.hass?.language,t=i&&e[i]?i:"en",!this.hass)return ut` +
+
+
Span Panel
+
+
+
+
${n("card.connecting")}
+
+ `;if(!this._discovered){const t=this._errorStore.hasPersistent("discovery-failed");return ut` +
+
+ +
Span Panel
+
+
+
+ + ${t?pt:ut`
${n("card.connecting")}
`} +
+ `}return ut` +
+
+ +
+ + + +
${$t((r=this._buildTabList(),o=this._activeTab,`
${r.map(t=>``).join("")}
`))}
+
+
+
+ +
+ +
+
+
+
+ `}_onPanelChange(t){const e=t.target;this._selectedPanelId=e.value,localStorage.setItem("span_panel_selected",e.value),this._isFavoritesView&&"dashboard"===this._activeTab&&(this._activeTab="activity"),this._areaSubscribing=!1,this._areaUnsub&&(this._areaUnsub(),this._areaUnsub=null)}get _isFavoritesView(){return this._selectedPanelId===zT}_onTabClick(t){const e=t.target.closest(".shared-tab");if(!e)return;const n=e.dataset.tab;n&&n!==this._activeTab&&(this._activeTab=n,this._isFavoritesView&&"dashboard"!==n&&(this._favoritesViewState.activeTab=n,LT(this._favoritesViewState)))}_onTabContentClick(t){const e=t.target.closest(".unit-btn");if(e){const t=e.dataset.unit;if(!t||t===this._chartMetric)return;return this._chartMetric=t,void localStorage.setItem("span_panel_metric",t)}}_onSidePanelClosed(){if("dashboard"===this._activeTab){const t=this._dashboardTab._ctrl;t.monitoringCache.invalidate(),t.graphSettingsCache.invalidate()}this._listDashCtrl.monitoringMultiCache.invalidate(),this._pendingTabRender&&(this._pendingTabRender=!1,this._scheduleTabRender())}_onUnitChanged(t){const e=t.detail;e&&e!==this._chartMetric&&(this._chartMetric=e,localStorage.setItem("span_panel_metric",e))}_onListColumnsChanged(t){const e=t.detail;"number"!=typeof e||1!==e&&2!==e&&3!==e||e===this._listColumns||(this._listColumns=e,Ut(e))}_onGraphSettingsChanged(){if("dashboard"===this._activeTab){const t=this._root.getElementById("tab-content");if(t){this._dashboardTab._ctrl.onGraphSettingsChanged(t)}}}_onNavigateTab(t){const e=t.detail;e&&(this._activeTab=e)}_onFavoritesViewStateChangedEvent(t){if(!this._isFavoritesView)return;const e=t.detail;if(!e)return;const n=this._favoritesViewState;n.activeTab=e.view;const i=this._listDashCtrl.topology,r=i?.circuits;r&&Object.keys(r).length>0?n.expanded[e.view]=e.expanded.filter(t=>t in r):n.expanded[e.view]=e.expanded,n.searchQuery=e.searchQuery,this._persistFavoritesViewStateTimer&&clearTimeout(this._persistFavoritesViewStateTimer),this._persistFavoritesViewStateTimer=setTimeout(()=>{this._persistFavoritesViewStateTimer=null,LT(n)},250)}_subscribeDeviceRegistry(){!this._deviceRegistryUnsub&&this.hass?.connection&&(this._deviceRegistryUnsub=this.hass.connection.subscribeEvents(()=>this._refreshPanels(),"device_registry_updated"))}_unsubscribeDeviceRegistry(){this._deviceRegistryUnsub&&(this._deviceRegistryUnsub.then(t=>t()),this._deviceRegistryUnsub=null)}async _refreshPanels(){if(!this.hass||!this._discovered)return;const t=(await this.hass.callWS({type:"config/device_registry/list"})).filter(t=>t.identifiers?.some(t=>t[0]===s)&&!t.via_device_id),e=this._panels.filter(t=>t.id!==zT),n=new Map(e.map(t=>[t.id,t])),i=new Set(t.map(t=>t.id)),r=n.size!==i.size||[...n.keys()].some(t=>!i.has(t)),o=!r&&t.some(t=>{const e=n.get(t.id);return!!e&&(e.name!==t.name||e.name_by_user!==t.name_by_user)});if((r||o)&&(this._panels=this._buildPanelList(t,this._favorites),!this._panels.some(t=>t.id===this._selectedPanelId)&&this._panels.length>0)){const e=t[0];e&&(this._selectedPanelId=e.id,localStorage.setItem("span_panel_selected",this._selectedPanelId))}}async _updatePanelStatusWatch(){if(!this.hass||!this._selectedPanelId)return;if(this._selectedPanelId===zT)return;if(this._watchedPanelId===this._selectedPanelId)return;const t=this._selectedPanelId;this._watchedPanelId=t;try{const e=new qt(this._errorStore),n=await se(this.hass,t,e);if(this._selectedPanelId!==t)return;const i=n.topology?.panel_entities?.panel_status;i&&(this._errorStore.watchPanelStatus(i),this._errorStore.updateHass(this.hass))}catch(e){console.warn("SPAN Panel: unable to fetch topology for panel status watching",e),this._watchedPanelId===t&&(this._watchedPanelId=null)}}async _discoverPanels(){if(!this._discovering&&this.hass){this._discovering=!0;try{let t;try{const e=new qt(this._errorStore);t=(await e.callWS(this.hass,{type:"config/device_registry/list"},{errorId:"fetch:topology"})).filter(t=>t.identifiers?.some(t=>t[0]===s)&&!t.via_device_id)}catch(t){return console.error("SPAN Panel: device discovery failed",t),void this._errorStore.add({key:"discovery-failed",level:"error",message:n("error.discovery_failed"),persistent:!0,retryFn:()=>{this._errorStore.remove("discovery-failed"),this._discoverPanels()}})}this._favorites=await this._loadFavorites(),this._panels=this._buildPanelList(t,this._favorites),this._favoritesViewState=function(){try{const t=localStorage.getItem(PT);if(!t)return{expanded:{activity:[],area:[]}};const e=JSON.parse(t);if(!e||"object"!=typeof e)return{expanded:{activity:[],area:[]}};const n=e.expanded??{activity:[],area:[]};return{activeTab:e.activeTab,expanded:{activity:Array.isArray(n.activity)?n.activity:[],area:Array.isArray(n.area)?n.area:[]},searchQuery:"string"==typeof e.searchQuery?e.searchQuery:void 0}}catch{return{expanded:{activity:[],area:[]}}}}(),this._discovered=!0;const e=localStorage.getItem("span_panel_selected");if(e&&this._panels.some(t=>t.id===e)?this._selectedPanelId=e:t.length>0&&(this._selectedPanelId=t[0].id),this._selectedPanelId===zT){const t=this._favoritesViewState.activeTab;"activity"===t||"area"===t||"monitoring"===t?this._activeTab=t:"dashboard"===this._activeTab&&(this._activeTab="activity")}this._chartMetric=localStorage.getItem("span_panel_metric")||"power"}finally{this._discovering=!1}}}_buildPanelList(t,e){if(!Zt(e))return t;return[{id:zT,name:n("panel.favorites"),model:"__favorites__"},...t]}async _loadFavorites(){return this.hass?this._favCache.fetch(this.hass):{}}async _refreshFavorites(){const t=++this._refreshSeq;this._favCache.invalidate();const e=await this._loadFavorites();if(t!==this._refreshSeq)return;const n=this._selectedPanelId===zT;this._favorites=e;const i=this._panels.filter(t=>t.id!==zT);if(this._panels=this._buildPanelList(i,e),n&&!Zt(e)){!function(){try{localStorage.removeItem(PT)}catch{}}(),this._favoritesViewState={expanded:{activity:[],area:[]}};const t=i[0];t?(this._selectedPanelId=t.id,localStorage.setItem("span_panel_selected",t.id)):this._selectedPanelId=null}else this._isFavoritesView?this._scheduleTabRender():this._applyPanelFavorites()}_buildTabList(){const t=[];return this._isFavoritesView||t.push({id:"dashboard",label:n("tab.by_panel"),icon:"mdi:view-dashboard"}),t.push({id:"activity",label:n("tab.by_activity"),icon:"mdi:sort-descending"},{id:"area",label:n("tab.by_area"),icon:"mdi:home-group"},{id:"monitoring",label:n("tab.monitoring"),icon:"mdi:monitor-eye"}),t}_buildFavoritesSummaryHTML(){return function(t){return`\n
\n \n
\n ${Bt(n("header.enable_switches"))}\n
\n \n
\n
\n
\n ${le()}\n
\n \n \n
\n
\n
\n `}("current"===(this._chartMetric||"power"))}_buildFavoritesPanelStatsGridHTML(t,e){if(0===t.length)return"";return`
${t.map(t=>`\n
\n
${Bt(t.panelName||t.topology.device_name||"")}
\n ${ce(t.topology,e,t.panelDeviceId)}\n
\n `).join("")}
`}_updateFavoritesPanelStats(t,e){if(this.hass&&0!==this._favoritesPanelStats.length)for(const n of this._favoritesPanelStats){const i=t.querySelector(`.panel-stats[data-stats-panel-id="${nT(n.panelDeviceId)}"]`);i&&iT(i,this.hass,n.topology,e,0)}}_buildDashboardConfig(){return{chart_metric:this._chartMetric,history_minutes:5,show_panel:!0,show_battery:!0,show_evse:!0}}async _scheduleTabRender(){await this.updateComplete,this._sidePanelOpen()?this._pendingTabRender=!0:await this._tabRenderScheduler()}_sidePanelOpen(){const t=this.shadowRoot?.getElementById("tab-content");return!!t?.querySelector("span-side-panel[open]")}async _renderTab(){const t=this._beginRender();this._dashboardTab.stop(),this._monitoringTab.stop(),this._listCtrl.stop(),this._listDashCtrl.stopIntervals();for(const t of this._favoritesMonitoringTabs.values())t.stop();this._favoritesMonitoringTabs.clear(),this._favoritesPanelStats=[];const e=this._root.getElementById("tab-content");if(e)if(this._isFavoritesView)await this._renderFavoritesTab(e,t);else switch(this._listDashCtrl.clearFavoriteRefs(),this._listCtrl.setViewName(null),this._applyPanelFavorites(),this._activeTab){case"dashboard":{e.innerHTML="";const t=this._buildDashboardConfig(),n=this._panels.find(t=>t.id===this._selectedPanelId),i=n?.config_entries?.[0]??null;await this._dashboardTab.render(e,this.hass,this._selectedPanelId??"",t,i);break}case"activity":{e.innerHTML="";const n=this._panels.find(t=>t.id===this._selectedPanelId),i=n?.config_entries?.[0]??null;try{const n=new qt(this._errorStore),r=await se(this.hass,this._selectedPanelId??void 0,n);if(t())return;const o=this._buildDashboardConfig();if(this._listDashCtrl.init(r.topology,o,this.hass,i),this._listDashCtrl.powerHistory.clear(),await this._listDashCtrl.monitoringCache.fetch(this.hass,i),t())return;if(await this._listDashCtrl.fetchAndBuildHorizonMaps(),t())return;const a=r.topology?ue(r.topology,o):"";if(this._listCtrl.setColumns(this._listColumns),this._listCtrl.renderActivityView(e,this.hass,r.topology,o,this._listDashCtrl.monitoringCache.status,a),await this._listDashCtrl.loadHistory(),t())return;this._listDashCtrl.updateDOM(e),this._listDashCtrl.startIntervals(e)}catch(n){if(t())return;const i=document.createElement("p");i.style.color="var(--error-color)",i.textContent=n.message,e.appendChild(i)}break}case"area":{e.innerHTML="";const i=this._panels.find(t=>t.id===this._selectedPanelId),r=i?.config_entries?.[0]??null;try{const i=new qt(this._errorStore),o=await se(this.hass,this._selectedPanelId??void 0,i);if(t())return;const a=this._buildDashboardConfig();if(this._listDashCtrl.init(o.topology,a,this.hass,r),this._listDashCtrl.powerHistory.clear(),await this._listDashCtrl.monitoringCache.fetch(this.hass,r),t())return;if(await this._listDashCtrl.fetchAndBuildHorizonMaps(),t())return;const s=o.topology?ue(o.topology,a):"";if(this._listCtrl.setColumns(this._listColumns),this._listCtrl.renderAreaView(e,this.hass,o.topology,a,this._listDashCtrl.monitoringCache.status,s),await this._listDashCtrl.loadHistory(),t())return;this._listDashCtrl.updateDOM(e),this._listDashCtrl.startIntervals(e),this._areaUnsub||this._areaSubscribing||(this._areaSubscribing=!0,async function(t,e,i,r){if(!t.connection)return()=>{};const o=async()=>{try{const n=new Map;for(const[t,i]of Object.entries(e.circuits))n.set(t,i.area);await ae(t,e);for(const[t,r]of Object.entries(e.circuits))if(r.area!==n.get(t))return void i()}catch(t){console.warn("[span-panel] area registry update failed:",t),r?.add({key:"fetch:areas",level:"warning",message:n("error.areas_failed"),persistent:!1})}},[a,s]=await Promise.all([t.connection.subscribeEvents(o,"entity_registry_updated"),t.connection.subscribeEvents(o,"area_registry_updated")]);return()=>{a(),s()}}(this.hass,o.topology,()=>{"area"===this._activeTab&&this._scheduleTabRender()},this._errorStore).then(t=>{this._areaSubscribing?this._areaUnsub=t:t()}).catch(t=>{this._areaSubscribing=!1,console.warn("SPAN Panel: area subscription failed",t),this._errorStore.add({key:"subscribe:area",level:"warning",message:n("error.areas_failed"),persistent:!1})}))}catch(t){const n=document.createElement("p");n.style.color="var(--error-color)",n.textContent=t instanceof Error?t.message:String(t),e.appendChild(n)}break}case"monitoring":{e.innerHTML="";const t=this._panels.find(t=>t.id===this._selectedPanelId),n=t?.config_entries?.[0]??null;await this._monitoringTab.render(e,this.hass,n??void 0);break}}}async _renderFavoritesTab(t,e){if(t.innerHTML="",!this.hass)return;const i=this._panels.filter(t=>t.id!==zT),r=await this._favCtrl.build(this.hass,this._favorites,i,this._errorStore);if(e())return;const o=r.perPanelStats.map(t=>{const e=t.topology.panel_entities?.panel_status;return"string"==typeof e?{entityId:e,panelName:t.panelName}:null}).filter(t=>null!==t);this._errorStore.watchPanelStatuses(o),this._errorStore.updateHass(this.hass);const a=new Map;for(const t of r.perPanelStats){const e=i.find(e=>e.id===t.panelDeviceId);a.set(t.panelDeviceId,{panelName:t.panelName,topology:t.topology,configEntryId:e?.config_entries?.[0]??null})}this._listDashCtrl.setFavoritesPerPanelInfo(a);const s=r.topology,l=r.entryIds[0]??null,c=Object.keys(s.circuits).length>0,u=Object.keys(s.sub_devices??{}).length>0;if(!c&&!u){const e=document.createElement("p");return e.style.color="var(--secondary-text-color)",e.style.padding="24px",e.textContent=n("list.no_results"),void t.appendChild(e)}if(this._listDashCtrl.setFavoriteRefs(s._favoriteRefs),this._listDashCtrl.setPanelFavorites(null),"monitoring"===this._activeTab)return this._listCtrl.setViewName(null),void await this._renderFavoritesMonitoring(t,r.entryIds,i);const h=this._activeTab,d=new Set(Object.keys(s.circuits)),p=this._favoritesViewState.expanded[h].filter(t=>d.has(t));this._listCtrl.setViewName(h),this._listCtrl.setInitialExpansion(p),this._listCtrl.setInitialSearchQuery(this._favoritesViewState.searchQuery??""),this._listCtrl.setColumns(this._listColumns);const f=this._buildDashboardConfig();if(this._listDashCtrl.init(s,f,this.hass,l),this._listDashCtrl.powerHistory.clear(),await this._listDashCtrl.fetchAndBuildHorizonMaps(),e())return;const g=await this._listDashCtrl.fetchMergedMonitoringStatus(r.entryIds);if(!e()){this._favoritesPanelStats=r.perPanelStats;try{if(await this._listDashCtrl.loadHistory(),e())return;const n=this._buildFavoritesSummaryHTML(),i=this._buildFavoritesPanelStatsGridHTML(r.perPanelStats,f),o=n+i+(u?`
\n
${Ne(s,this.hass,f)}
\n
`:"");"activity"===h?this._listCtrl.renderActivityView(t,this.hass,s,f,g,o):this._listCtrl.renderAreaView(t,this.hass,s,f,g,o),this._updateFavoritesPanelStats(t,f),this._listDashCtrl.setupResizeObserver(t,t),this._listDashCtrl.startIntervals(t,()=>{this._updateFavoritesPanelStats(t,f)})}catch(n){if(e())return;const i=document.createElement("p");i.style.color="var(--error-color)",i.textContent=n.message,t.appendChild(i)}}}async _renderFavoritesMonitoring(t,e,n){if(!this.hass)return;const i=document.createElement("div");i.className="favorites-monitoring-stack",t.appendChild(i);const r=new Map;for(const t of n){const e=t.config_entries?.[0];e&&r.set(e,t)}const o=new Map;for(const t of e){const e=r.get(t),n=document.createElement("div");n.className="favorites-monitoring-block",n.style.marginBottom="24px";const a=document.createElement("h2");a.style.margin="8px 0 12px",a.style.fontSize="1em",a.textContent=e?.name_by_user??e?.name??t,n.appendChild(a);const s=document.createElement("div");n.appendChild(s),i.appendChild(n);const l=new yT;l.errorStore=this._errorStore,o.set(t,l);try{await l.render(s,this.hass,t)}catch(e){console.warn("SPAN Panel: favorites monitoring render failed",t,e);const n=document.createElement("p");n.style.color="var(--error-color)",n.textContent=e.message??String(e),s.appendChild(n)}}this._favoritesMonitoringTabs=o}_applyPanelFavorites(){if(!this._selectedPanelId||this._isFavoritesView)return this._listDashCtrl.setPanelFavorites(null),void this._dashboardTab.setPanelFavorites(null);const t=this._favorites[this._selectedPanelId],e={panelDeviceId:this._selectedPanelId,circuitUuids:new Set(t?.circuits??[]),subDeviceIds:new Set(t?.sub_devices??[])};this._listDashCtrl.setPanelFavorites(e),this._dashboardTab.setPanelFavorites(e)}};NT._shellStyles=M` + :host { + color: var(--primary-text-color); + } + .header { + background-color: var(--app-header-background-color); + color: var(--app-header-text-color, white); + border-bottom: var(--app-header-border-bottom, none); + } + .toolbar { + height: var(--header-height); + display: flex; + align-items: center; + font-size: 20px; + padding: 0 16px; + font-weight: 400; + box-sizing: border-box; + } + .main-title { + margin: 0 0 0 24px; + line-height: 20px; + flex-grow: 1; + display: flex; + align-items: center; + gap: 16px; + min-width: 0; + } + .panel-selector select { + color: inherit; + font-size: inherit; + font-weight: inherit; + cursor: pointer; + padding: 4px 8px; + border: 1px solid rgba(255, 255, 255, 0.3); + border-radius: 6px; + background-color: rgba(255, 255, 255, 0.1); + } + .panel-selector select option { + background: var(--card-background-color, #333); + color: var(--primary-text-color); + } + .panel-tabs { + display: flex; + gap: 0; + overflow-x: auto; + scrollbar-width: none; + } + .panel-tabs::-webkit-scrollbar { + display: none; + } + .panel-tab { + padding: 8px 20px; + cursor: pointer; + font-size: 0.9em; + font-weight: 500; + color: var(--app-header-text-color, white); + opacity: 0.7; + border-bottom: 2px solid transparent; + background: none; + border-top: none; + border-left: none; + border-right: none; + } + .panel-tab.active { + opacity: 1; + border-bottom-color: var(--app-header-text-color, white); + } + .view { + padding: 16px; + } + .view-content { + width: 100%; + } + .tab-content { + min-height: 400px; + } + .shared-tab-bar { + display: flex; + gap: 0; + } + .shared-tab { + padding: 8px 20px; + cursor: pointer; + font-size: 0.9em; + font-weight: 500; + color: var(--app-header-text-color, white); + opacity: 0.7; + border-bottom: 2px solid transparent; + background: none; + border-top: none; + border-left: none; + border-right: none; + } + .shared-tab.active { + opacity: 1; + border-bottom-color: var(--app-header-text-color, white); + } + `,NT.styles=[ET._shellStyles,k(uT)],_([Et({attribute:!1})],NT.prototype,"hass",void 0),_([Et({type:Boolean,reflect:!0})],NT.prototype,"narrow",void 0),_([zt()],NT.prototype,"_panels",void 0),_([zt()],NT.prototype,"_selectedPanelId",void 0),_([zt()],NT.prototype,"_activeTab",void 0),_([zt()],NT.prototype,"_discovered",void 0),_([zt()],NT.prototype,"_chartMetric",void 0),_([zt()],NT.prototype,"_listColumns",void 0),_([zt()],NT.prototype,"_favorites",void 0),NT=ET=_([(t=>(e,n)=>{void 0!==n?n.addInitializer(()=>{customElements.define(t,e)}):customElements.define(t,e)})("span-panel")],NT),console.warn("%c SPAN-PANEL %c v0.9.4 ","background: var(--primary-color, #4dd9af); color: #000; font-weight: 700; padding: 2px 6px; border-radius: 4px 0 0 4px;","background: var(--secondary-background-color, #333); color: var(--primary-text-color, #fff); padding: 2px 6px; border-radius: 0 4px 4px 0;"); diff --git a/custom_components/span_panel/grace_period.py b/custom_components/span_panel/grace_period.py new file mode 100644 index 00000000..aa499174 --- /dev/null +++ b/custom_components/span_panel/grace_period.py @@ -0,0 +1,195 @@ +"""Grace period calculation helpers and persistence for Span energy sensors.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import UTC, date, datetime, timedelta +from decimal import Decimal +import logging +from typing import Any, Self + +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.core import State +from homeassistant.helpers.restore_state import ExtraStoredData + +# Matches SensorEntity.native_value: the broadest concrete union that the +# HA sensor platform can hand us. We accept it verbatim so sensor_base can +# pass `self._attr_native_value` through without extra narrowing. +type NativeSensorValue = str | int | float | date | Decimal | None + +_LOGGER = logging.getLogger(__name__) + + +def _parse_numeric_state(state: State | None) -> tuple[float | None, datetime | None]: + """Extract a numeric value and naive timestamp from a restored HA state. + + Returns (None, None) when the state is unknown/unavailable or not numeric. + """ + + if state is None: + return None, None + + if state.state in (STATE_UNKNOWN, STATE_UNAVAILABLE, None): + return None, None + + try: + value = float(state.state) + except (TypeError, ValueError): + return None, None + + # Ensure last_changed is a UTC-aware datetime to match our tracking + last_changed: datetime | None = None + if state.last_changed is not None: + last_changed = ( + state.last_changed.replace(tzinfo=UTC) + if state.last_changed.tzinfo is None + else state.last_changed.astimezone(UTC) + ) + return value, last_changed + + +@dataclass +class SpanEnergyExtraStoredData(ExtraStoredData): + """Extra stored data for Span energy sensors with grace period tracking. + + This data is persisted across Home Assistant restarts to maintain + grace period state for energy sensors, preventing statistics spikes + when the panel is offline at startup. + """ + + native_value: float | None + native_unit_of_measurement: str | None + last_valid_state: float | None + last_valid_changed: str | None # ISO format datetime string + energy_offset: float | None = None + last_panel_reading: float | None = None + last_dip_delta: float | None = None + + def as_dict(self) -> dict[str, Any]: + """Return a dict representation of the extra data.""" + return { + "native_value": self.native_value, + "native_unit_of_measurement": self.native_unit_of_measurement, + "last_valid_state": self.last_valid_state, + "last_valid_changed": self.last_valid_changed, + "energy_offset": self.energy_offset, + "last_panel_reading": self.last_panel_reading, + "last_dip_delta": self.last_dip_delta, + } + + @classmethod + def from_dict(cls, restored: dict[str, Any]) -> Self | None: + """Initialize extra stored data from a dict. + + Args: + restored: Dictionary containing the stored data + + Returns: + SpanEnergyExtraStoredData instance or None if restoration fails + + """ + try: + return cls( + native_value=restored.get("native_value"), + native_unit_of_measurement=restored.get("native_unit_of_measurement"), + last_valid_state=restored.get("last_valid_state"), + last_valid_changed=restored.get("last_valid_changed"), + energy_offset=restored.get("energy_offset"), + last_panel_reading=restored.get("last_panel_reading"), + last_dip_delta=restored.get("last_dip_delta"), + ) + except (AttributeError, KeyError, TypeError): + return None + + +def coerce_grace_period_minutes(raw_value: int | float | str | None) -> int: + """Ensure grace period minutes is a non-negative integer. + + Args: + raw_value: The raw config value for grace period minutes. + + Returns: + Validated integer (defaults to 15 if invalid, clamps to 0 minimum). + + """ + if raw_value is None: + return 15 + try: + minutes = int(raw_value) + except (TypeError, ValueError): + minutes = 15 + + if minutes < 0: + minutes = 0 + + return minutes + + +def handle_offline_grace_period( + last_valid_state: float | None, + last_valid_changed: datetime | None, + current_native_value: NativeSensorValue, + grace_minutes: int, +) -> tuple[NativeSensorValue, float | None, datetime | None]: + """Handle grace period logic when panel is offline. + + Args: + last_valid_state: The last known good numeric state. + last_valid_changed: When the last valid state was recorded. + current_native_value: The current native value on the entity. + grace_minutes: Already-coerced grace period in minutes. + + Returns: + Tuple of (new_native_value, updated_last_valid_state, updated_last_valid_changed). + + """ + # If we don't yet have a tracked valid state, fall back to the current + # native value (e.g., restored state) to avoid returning None during a + # brief offline period immediately after startup. + if last_valid_state is None and isinstance(current_native_value, int | float): + last_valid_state = float(current_native_value) + last_valid_changed = last_valid_changed or datetime.now(tz=UTC) + + if last_valid_state is None: + # No previous valid state, set to None (HA reports unknown) + return None, None, last_valid_changed + + if last_valid_changed is None: + last_valid_changed = datetime.now(tz=UTC) + + try: + raw_delta = datetime.now(tz=UTC) - last_valid_changed + # Clamp to zero to handle backward clock jumps (DST / NTP sync) that would + # otherwise produce a negative delta and silently extend the grace window + # indefinitely. + time_since_last_valid = max(raw_delta, timedelta(0)) + grace_period_duration = timedelta(minutes=grace_minutes) + except Exception as err: # noqa: BLE001 # pragma: no cover - defensive + _LOGGER.debug("Grace period calculation failed: %s", err) + return last_valid_state, last_valid_state, last_valid_changed + + if time_since_last_valid <= grace_period_duration: + # Still within grace period - use last valid state + return last_valid_state, last_valid_state, last_valid_changed + + # Grace period expired - set to None (makes sensor unknown) + return None, last_valid_state, last_valid_changed + + +def initialize_from_last_state( + last_state: State | None, +) -> tuple[float | None, datetime | None]: + """Seed grace tracking from HA's last stored state when extra data is missing. + + Args: + last_state: The HA state object from async_get_last_state(). + + Returns: + Tuple of (last_valid_state, last_valid_changed) or (None, None). + + """ + restored_value, restored_changed = _parse_numeric_state(last_state) + if restored_value is None: + return None, None + + return restored_value, restored_changed or datetime.now(tz=UTC) diff --git a/custom_components/span_panel/graph_horizon.py b/custom_components/span_panel/graph_horizon.py new file mode 100644 index 00000000..5090ddc7 --- /dev/null +++ b/custom_components/span_panel/graph_horizon.py @@ -0,0 +1,148 @@ +"""Graph time horizon manager for SPAN Panel circuit charts. + +Manages global default and per-circuit override horizons with HA Storage +persistence. Mirrors the CurrentMonitor storage pattern. +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any + +from homeassistant.helpers.storage import Store + +from .const import DEFAULT_GRAPH_HORIZON, VALID_GRAPH_HORIZONS + +if TYPE_CHECKING: + from homeassistant.config_entries import ConfigEntry + from homeassistant.core import HomeAssistant + +_LOGGER = logging.getLogger(__name__) + +_STORAGE_VERSION = 1 +_STORAGE_KEY_PREFIX = "span_panel_graph_horizon" + + +class GraphHorizonManager: + """Manages graph time horizon settings with per-circuit overrides.""" + + def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + """Initialize the GraphHorizonManager.""" + self._hass = hass + self._entry = entry + self._global_horizon: str = DEFAULT_GRAPH_HORIZON + self._circuit_overrides: dict[str, str] = {} + self._subdevice_overrides: dict[str, str] = {} + self._store: Store[dict[str, Any]] = Store( + hass, + _STORAGE_VERSION, + f"{_STORAGE_KEY_PREFIX}.{entry.entry_id}", + ) + + def get_global_horizon(self) -> str: + """Return the current global default horizon.""" + return self._global_horizon + + def set_global_horizon(self, horizon: str) -> None: + """Set the global default horizon and prune matching overrides.""" + _validate_horizon(horizon) + self._global_horizon = horizon + self._circuit_overrides = { + cid: h for cid, h in self._circuit_overrides.items() if h != horizon + } + self._subdevice_overrides = { + sid: h for sid, h in self._subdevice_overrides.items() if h != horizon + } + self._hass.async_create_task(self.async_save()) + + def get_effective_horizon(self, circuit_id: str) -> str: + """Return the override horizon for a circuit, or the global default.""" + return self._circuit_overrides.get(circuit_id, self._global_horizon) + + def set_circuit_horizon(self, circuit_id: str, horizon: str) -> None: + """Set a per-circuit horizon override.""" + _validate_horizon(horizon) + if horizon == self._global_horizon: + self._circuit_overrides.pop(circuit_id, None) + else: + self._circuit_overrides[circuit_id] = horizon + self._hass.async_create_task(self.async_save()) + + def clear_circuit_horizon(self, circuit_id: str) -> None: + """Remove a per-circuit override, reverting to global.""" + self._circuit_overrides.pop(circuit_id, None) + self._hass.async_create_task(self.async_save()) + + def get_effective_subdevice_horizon(self, subdevice_id: str) -> str: + """Return the override horizon for a sub-device, or the global default.""" + return self._subdevice_overrides.get(subdevice_id, self._global_horizon) + + def set_subdevice_horizon(self, subdevice_id: str, horizon: str) -> None: + """Set a per-sub-device horizon override.""" + _validate_horizon(horizon) + if horizon == self._global_horizon: + self._subdevice_overrides.pop(subdevice_id, None) + else: + self._subdevice_overrides[subdevice_id] = horizon + self._hass.async_create_task(self.async_save()) + + def clear_subdevice_horizon(self, subdevice_id: str) -> None: + """Remove a per-sub-device override, reverting to global.""" + self._subdevice_overrides.pop(subdevice_id, None) + self._hass.async_create_task(self.async_save()) + + def get_all_settings(self) -> dict[str, Any]: + """Return full state for frontend consumption.""" + circuits: dict[str, dict[str, Any]] = {} + for circuit_id, horizon in self._circuit_overrides.items(): + circuits[circuit_id] = { + "horizon": horizon, + "has_override": True, + } + sub_devices: dict[str, dict[str, Any]] = {} + for subdevice_id, horizon in self._subdevice_overrides.items(): + sub_devices[subdevice_id] = { + "horizon": horizon, + "has_override": True, + } + return { + "global_horizon": self._global_horizon, + "circuits": circuits, + "sub_devices": sub_devices, + } + + async def async_load(self) -> None: + """Load settings from HA Storage.""" + data = await self._store.async_load() + if data is None: + return + stored_horizon = data.get("global_horizon", DEFAULT_GRAPH_HORIZON) + if stored_horizon in VALID_GRAPH_HORIZONS: + self._global_horizon = stored_horizon + self._circuit_overrides = { + cid: h + for cid, h in data.get("circuit_overrides", {}).items() + if h in VALID_GRAPH_HORIZONS + } + self._subdevice_overrides = { + sid: h + for sid, h in data.get("subdevice_overrides", {}).items() + if h in VALID_GRAPH_HORIZONS + } + + async def async_save(self) -> None: + """Persist settings to HA Storage.""" + await self._store.async_save( + { + "global_horizon": self._global_horizon, + "circuit_overrides": self._circuit_overrides, + "subdevice_overrides": self._subdevice_overrides, + } + ) + + +def _validate_horizon(horizon: str) -> None: + """Raise ValueError if horizon is not a valid preset.""" + if horizon not in VALID_GRAPH_HORIZONS: + msg = f"Invalid graph horizon '{horizon}'. Must be one of {VALID_GRAPH_HORIZONS}" + raise ValueError(msg) diff --git a/custom_components/span_panel/helpers.py b/custom_components/span_panel/helpers.py index d49dc7a1..2620c82b 100644 --- a/custom_components/span_panel/helpers.py +++ b/custom_components/span_panel/helpers.py @@ -3,1286 +3,106 @@ from __future__ import annotations import logging -import re -from typing import TYPE_CHECKING, Any from homeassistant.components.persistent_notification import async_create -from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er -from homeassistant.util import slugify - -from .const import DOMAIN, USE_CIRCUIT_NUMBERS, USE_DEVICE_PREFIX -from .span_panel import SpanPanel -from .util import panel_to_device_info - -if TYPE_CHECKING: - from .coordinator import SpanPanelCoordinator - from .span_panel_circuit import SpanPanelCircuit +from homeassistant.helpers import ( + entity_registry as er, # noqa: F401 — re-exported for patch compatibility +) +from span_panel_api import SpanCircuitSnapshot, SpanPanelSnapshot + +from .entity_resolver import ( # noqa: F401 + build_bess_unique_id_for_entry, + build_binary_sensor_unique_id_for_entry, + build_evse_unique_id_for_entry, + build_select_unique_id_for_entry, + build_switch_unique_id_for_entry, + construct_circuit_unique_id_for_entry, + construct_multi_circuit_entity_id, + construct_panel_unique_id_for_entry, + construct_single_circuit_entity_id, + construct_synthetic_unique_id_for_entry, + construct_unmapped_entity_id, + construct_unmapped_friendly_name, + get_device_identifier_for_entry, + get_unmapped_circuit_entity_id, + resolve_evse_display_suffix, +) +from .id_builder import ( # noqa: F401 + ALL_SUFFIX_MAPPINGS, + CIRCUIT_SUFFIX_MAPPING, + PANEL_ENTITY_SUFFIX_MAPPING, + PANEL_SUFFIX_MAPPING, + build_bess_unique_id, + build_binary_sensor_unique_id, + build_circuit_unique_id, + build_evse_unique_id, + build_panel_unique_id, + build_select_unique_id, + build_switch_unique_id, + construct_binary_sensor_unique_id, + construct_circuit_unique_id, + construct_panel_unique_id, + construct_select_unique_id, + construct_switch_unique_id, + construct_synthetic_unique_id, + get_panel_entity_suffix, + get_suffix_from_sensor_key, + get_user_friendly_suffix, + is_panel_level_sensor_key, +) + +__all__ = [ + "ALL_SUFFIX_MAPPINGS", + "CIRCUIT_SUFFIX_MAPPING", + "PANEL_ENTITY_SUFFIX_MAPPING", + "PANEL_SUFFIX_MAPPING", + "async_create_span_notification", + "build_bess_unique_id", + "build_bess_unique_id_for_entry", + "build_binary_sensor_unique_id", + "build_binary_sensor_unique_id_for_entry", + "build_circuit_unique_id", + "build_evse_unique_id", + "build_evse_unique_id_for_entry", + "build_panel_unique_id", + "build_select_unique_id", + "build_select_unique_id_for_entry", + "build_switch_unique_id", + "build_switch_unique_id_for_entry", + "construct_binary_sensor_unique_id", + "construct_circuit_identifier_from_tabs", + "construct_circuit_unique_id", + "construct_circuit_unique_id_for_entry", + "construct_multi_circuit_entity_id", + "construct_panel_unique_id", + "construct_panel_unique_id_for_entry", + "construct_select_unique_id", + "construct_single_circuit_entity_id", + "construct_switch_unique_id", + "construct_synthetic_unique_id", + "construct_synthetic_unique_id_for_entry", + "construct_tabs_attribute", + "construct_unmapped_entity_id", + "construct_unmapped_friendly_name", + "construct_voltage_attribute", + "detect_capabilities", + "er", + "get_device_identifier_for_entry", + "get_panel_entity_suffix", + "get_suffix_from_sensor_key", + "get_unmapped_circuit_entity_id", + "get_user_friendly_suffix", + "has_bess", + "has_evse", + "has_power_flows", + "has_pv", + "is_panel_level_sensor_key", + "resolve_evse_display_suffix", +] _LOGGER = logging.getLogger(__name__) -# Global suffix mappings for API description keys to user-friendly/entity suffixes -# These mappings drive consistent unique_id/entity_id suffixes across all sensors, -# including Net Energy and import/export flows, and are used for reverse lookups. - -# Circuit sensor API field mappings (used by get_user_friendly_suffix) -# Includes power, produced/consumed, net energy, and import/export energy -CIRCUIT_SUFFIX_MAPPING = { - "instantPowerW": "power", - "producedEnergyWh": "energy_produced", - "consumedEnergyWh": "energy_consumed", - "netEnergyWh": "energy_net", - "importedEnergyWh": "energy_imported", - "exportedEnergyWh": "energy_exported", - "circuit_priority": "priority", -} - -# Panel sensor API field mappings (used by get_user_friendly_suffix) -# Includes main meter/feedthrough produced, consumed, and net energy -PANEL_SUFFIX_MAPPING = { - "instantGridPowerW": "grid_power", # Descriptive to differentiate from other power types - "feedthroughPowerW": "feed_through_power", - "mainMeterEnergyProducedWh": "main_meter_energy_produced", # Consistent naming - "mainMeterEnergyConsumedWh": "main_meter_energy_consumed", # Consistent naming - "mainMeterNetEnergyWh": "main_meter_energy_net", # Consistent naming - "feedthroughEnergyProducedWh": "feed_through_energy_produced", # Consistent naming - "feedthroughEnergyConsumedWh": "feed_through_energy_consumed", # Consistent naming - "feedthroughNetEnergyWh": "feed_through_energy_net", # Consistent naming - "batteryPercentage": "battery_percentage", - "dsmState": "dsm_state", -} - -# Panel entity suffix mappings (used by get_panel_entity_suffix) -# These are the actual entity_id/unique_id suffixes used for panel sensors -# (e.g., "main_meter_net_energy" / "feed_through_net_energy"). -PANEL_ENTITY_SUFFIX_MAPPING = { - "instantGridPowerW": "current_power", - "feedthroughPowerW": "feed_through_power", - "mainMeterEnergyProducedWh": "main_meter_produced_energy", - "mainMeterEnergyConsumedWh": "main_meter_consumed_energy", - "mainMeterNetEnergyWh": "main_meter_net_energy", - "feedthroughEnergyProducedWh": "feed_through_produced_energy", - "feedthroughEnergyConsumedWh": "feed_through_consumed_energy", - "feedthroughNetEnergyWh": "feed_through_net_energy", - "batteryPercentage": "battery_level", - "dsmState": "dsm_state", -} - -# Combined mapping for general suffix lookup -ALL_SUFFIX_MAPPINGS = {**CIRCUIT_SUFFIX_MAPPING, **PANEL_SUFFIX_MAPPING} - - -def get_api_description_key_from_suffix(suffix: str) -> str | None: - """Reverse map from user-friendly suffix back to API description key. - - This is used for migration when we need to extract the original API description key - from an entity_id suffix to call the helper functions correctly. - - Args: - suffix: User-friendly suffix extracted from entity_id (e.g., "power", "energy_produced") - - Returns: - API description key (e.g., "instantPowerW", "producedEnergyWh") or None if not found - - Examples: - get_api_description_key_from_suffix("power") → "instantPowerW" - get_api_description_key_from_suffix("energy_produced") → "producedEnergyWh" - get_api_description_key_from_suffix("current_power") → "instantGridPowerW" - - """ - # Create reverse mapping from all suffix mappings - reverse_mapping = {} - - # Add circuit suffix mappings - for api_key, user_suffix in CIRCUIT_SUFFIX_MAPPING.items(): - reverse_mapping[user_suffix] = api_key - - # Add panel suffix mappings - for api_key, user_suffix in PANEL_SUFFIX_MAPPING.items(): - reverse_mapping[user_suffix] = api_key - - # Add panel entity suffix mappings (these take precedence for panel sensors) - for api_key, entity_suffix in PANEL_ENTITY_SUFFIX_MAPPING.items(): - reverse_mapping[entity_suffix] = api_key - - return reverse_mapping.get(suffix) - - -def get_suffix_from_sensor_key(sensor_key: str) -> str: - """Extract the suffix from a sensor key for use with entity ID helpers. - - Args: - sensor_key: Sensor key like "span_abc123_solar_inverter_power" or "span_abc123_house_total_consumption" - - Returns: - User-friendly suffix like "power" or "consumption" - - Examples: - get_suffix_from_sensor_key("span_abc123_solar_inverter_power") → "power" - get_suffix_from_sensor_key("span_abc123_solar_inverter_energy_produced") → "energy_produced" - get_suffix_from_sensor_key("span_abc123_house_total_consumption") → "consumption" - - """ - # Remove device prefix (span_{serial}_) from sensor key - # Sensor keys follow pattern: span_{serial}_{actual_sensor_name} - parts = sensor_key.split("_") - if len(parts) >= 3 and parts[0] == "span": - # Reconstruct the sensor name without the device prefix - sensor_name = "_".join(parts[2:]) - else: - # Fallback if pattern doesn't match expected format - sensor_name = sensor_key - - # For solar sensors, the suffix is the last part after "solar_inverter_" - if sensor_name.startswith("solar_inverter_"): - return sensor_name.replace("solar_inverter_", "") - - # For other sensors, the suffix is typically the last part or last few parts - # Look for well-established suffix patterns - established_suffixes = [ - "energy_produced", - "energy_consumed", - "energy_net", - "current_power", - "grid_power", - "total_power", - "instant_power", - "consumption", - "production", - "power", - "energy", - ] - - # Check if the sensor name ends with any established suffix - for suffix in established_suffixes: - if sensor_name.endswith(suffix): - return suffix - - # If no established pattern matches, return the last part after the last underscore - name_parts = sensor_name.split("_") - return name_parts[-1] if name_parts else sensor_name - - -def is_solar_sensor_key(sensor_key: str) -> bool: - """Check if a sensor key represents a solar sensor. - - Args: - sensor_key: Sensor key to check (e.g., "span_abc123_solar_inverter_power") - - Returns: - True if this is a solar sensor key - - Examples: - is_solar_sensor_key("span_abc123_solar_inverter_power") → True - is_solar_sensor_key("span_abc123_house_total_consumption") → False - - """ - # Remove device prefix to get the actual sensor name - parts = sensor_key.split("_") - if len(parts) >= 3 and parts[0] == "span": - sensor_name = "_".join(parts[2:]) - else: - sensor_name = sensor_key - - return sensor_name.startswith("solar_inverter_") or "solar" in sensor_name.lower() - - -def is_panel_level_sensor_key(sensor_key: str) -> bool: - """Check if a sensor key represents a panel-level sensor. - - Panel-level sensors have the form: span_{device_identifier}_{sensor_type} - Circuit sensors have the form: span_{device_identifier}_{circuit_id}_{sensor_type} - - Args: - sensor_key: Sensor key to check (e.g., "span_sp3-simulation-001_current_power" or - "span_sp3-simulation-001_12ce227695cd44338864b0ef2ec4168b_power") - - Returns: - True if this is a panel-level sensor (no circuit ID) - - Examples: - is_panel_level_sensor_key("span_sp3-simulation-001_current_power") → True - is_panel_level_sensor_key("span_sp3-simulation-001_12ce227695cd44338864b0ef2ec4168b_power") → False - - """ - - # Must start with "span_" - if not sensor_key.startswith("span_"): - return False - - # Look for UUID pattern (32 hex characters) anywhere in the string after "span_" - # Circuit IDs in SPAN are typically formatted as 32 lowercase hex characters without dashes - uuid_pattern = re.compile(r"_[a-f0-9]{32}_") - - # If we find a UUID pattern, this is a circuit sensor - if uuid_pattern.search(sensor_key): - return False - else: - # No UUID pattern found, this is a panel-level sensor - return True - - -def extract_solar_info_from_sensor_key( - sensor_key: str, sensor_config: dict[str, Any] -) -> dict[str, Any] | None: - """Extract solar sensor information from sensor key and config. - - Args: - sensor_key: Solar sensor key like "span_abc123_solar_inverter_instant_power" - sensor_config: Sensor configuration dictionary - - Returns: - Dictionary with solar info: {"friendly_name": str, "leg1": int, "leg2": int} - - Examples: - extract_solar_info_from_sensor_key("span_abc123_solar_inverter_instant_power", config) - → {"friendly_name": "Solar Inverter", "leg1": 30, "leg2": 32} - - """ - - if not is_solar_sensor_key(sensor_key): - return None - - # Extract friendly name from sensor name, removing the suffix - name = sensor_config.get("name", "") - if name: - # Remove common suffixes from the name to get the base friendly name - for suffix in [" Instant Power", " Energy Produced", " Energy Consumed", " Power"]: - if name.endswith(suffix): - name = name[: -len(suffix)] - break - friendly_name = name - else: - friendly_name = "Solar Inverter" - - # Extract circuit numbers from variables that reference backing entities - leg1 = 0 - leg2 = 0 - variables = sensor_config.get("variables", {}) - - # Look for patterns like "sensor.span_panel_solar_east_power" or "sensor.span_panel_circuit_30_power" - for _var_name, entity_id in variables.items(): - if isinstance(entity_id, str) and "circuit_" in entity_id: - # Extract circuit number from entity_id like "sensor.span_panel_circuit_30_power" - parts = entity_id.split("_") - for i, part in enumerate(parts): - if part == "circuit" and i + 1 < len(parts): - try: - circuit_num = int(parts[i + 1]) - if leg1 == 0: - leg1 = circuit_num - elif leg2 == 0: - leg2 = circuit_num - break - except ValueError: - continue - - return { - "friendly_name": friendly_name, - "leg1": leg1, - "leg2": leg2, - } - - -def construct_panel_synthetic_entity_id( - coordinator: SpanPanelCoordinator, - span_panel: SpanPanel, - platform: str, - suffix: str, - device_name: str, - unique_id: str | None = None, -) -> str | None: - """Construct entity ID for synthetic panel-level sensors with device prefix logic. - - Args: - coordinator: The coordinator instance - span_panel: The span panel data - platform: Platform name ("sensor", etc.) - suffix: Entity-specific suffix ("current_power", etc.) - device_name: Device name for the panel - unique_id: The unique ID for this entity (None to skip registry lookup) - - Returns: - Constructed entity ID string or None if device info unavailable - - """ - # Check registry first only if unique_id is provided - if unique_id is not None: - entity_registry = er.async_get(coordinator.hass) - existing_entity_id = entity_registry.async_get_entity_id(platform, DOMAIN, unique_id) - if existing_entity_id: - return existing_entity_id - else: - # FATAL ERROR: Expected unique_id not found in registry - raise ValueError( - f"REGISTRY LOOKUP ERROR: Expected unique_id '{unique_id}' not found in registry. " - f"This indicates a migration or configuration mismatch." - ) - - config_entry = coordinator.config_entry - if not device_name: - return None - use_device_prefix = config_entry.options.get(USE_DEVICE_PREFIX, True) - parts = [] - if use_device_prefix: - # Sanitize device name for entity ID use - sanitized_device_name = slugify(device_name) - parts.append(sanitized_device_name) - parts.append(suffix) - entity_id = f"{platform}.{'_'.join(parts)}" - return entity_id - - -def construct_240v_synthetic_entity_id( - coordinator: SpanPanelCoordinator, - span_panel: SpanPanel, - platform: str, - suffix: str, - friendly_name: str, - tab1: int = 0, - tab2: int = 0, - unique_id: str | None = None, -) -> str | None: - """Construct entity ID for synthetic 240V circuits using tab numbers. - - Args: - coordinator: The coordinator instance - span_panel: The span panel data - platform: Platform name ("sensor", "switch", "select") - suffix: Entity-specific suffix ("power", "energy_produced", etc.) - friendly_name: Descriptive name for this synthetic circuit - tab1: First tab number (0 if not used) - tab2: Second tab number (0 if not used) - unique_id: The unique ID for this entity (None to skip registry lookup) - - Returns: - Constructed entity ID string or None if device info unavailable - - """ - # Validate that we have exactly 2 tabs for 240V circuits - if tab1 <= 0 or tab2 <= 0: - raise ValueError( - f"240V synthetic entity requires exactly 2 tabs, got tab1={tab1}, tab2={tab2}" - ) - - # Build tab numbers list - tab_numbers = [tab1, tab2] - - # Use the multi-circuit helper - return construct_multi_circuit_entity_id( - coordinator=coordinator, - span_panel=span_panel, - platform=platform, - suffix=suffix, - circuit_numbers=tab_numbers, - friendly_name=friendly_name, - unique_id=unique_id, - ) - - -def construct_120v_synthetic_entity_id( - coordinator: SpanPanelCoordinator, - span_panel: SpanPanel, - platform: str, - suffix: str, - friendly_name: str, - tab: int = 0, - unique_id: str | None = None, -) -> str | None: - """Construct entity ID for synthetic 120V circuits using tab number. - - Args: - coordinator: The coordinator instance - span_panel: The span panel data - platform: Platform name ("sensor", "switch", "select") - suffix: Entity-specific suffix ("power", "energy_produced", etc.) - friendly_name: Descriptive name for this synthetic circuit - tab: Tab number - unique_id: The unique ID for this entity (None to skip registry lookup) - - Returns: - Constructed entity ID string or None if device info unavailable - - """ - # Validate that we have exactly 1 tab for 120V circuits - if tab <= 0: - raise ValueError(f"120V synthetic entity requires exactly 1 tab, got tab={tab}") - - # Use the multi-circuit helper with only one tab - return construct_multi_circuit_entity_id( - coordinator=coordinator, - span_panel=span_panel, - platform=platform, - suffix=suffix, - circuit_numbers=[tab], - friendly_name=friendly_name, - unique_id=unique_id, - ) - - -def get_circuit_number(circuit: SpanPanelCircuit) -> int | str: - """Extract circuit number (tab position) from circuit object. - - Args: - circuit: SpanPanelCircuit object - - Returns: - Circuit number (tab position) or circuit_id if no tabs - - """ - return circuit.tabs[0] if circuit.tabs else circuit.circuit_id - - -def get_friendly_name_from_registry( - hass: HomeAssistant, unique_id: str | None, default_name: str, platform: str = "sensor" -) -> str: - """Check entity registry for user's customized friendly name. - - If a user has customized the friendly name of an entity in Home Assistant, - this function will return the user's custom name instead of the default one. - This prevents the integration from overriding user customizations. - - Args: - hass: Home Assistant instance - unique_id: The unique ID to look up in the registry (None to skip registry check) - default_name: The default friendly name to use if not found in registry - platform: Platform name ("sensor", "switch", "binary_sensor", "select") - - Returns: - The user's custom friendly name from registry if found, otherwise the default name - - """ - # If no unique_id provided, return default name immediately - if unique_id is None: - return default_name - - entity_registry = er.async_get(hass) - - # First get the entity_id using the unique_id - existing_entity_id = entity_registry.async_get_entity_id(platform, DOMAIN, unique_id) - - if existing_entity_id: - # Now get the full entity entry using the entity_id - entity_entry = entity_registry.entities.get(existing_entity_id) - - if entity_entry and entity_entry.name: - _LOGGER.debug( - "Found custom friendly name in registry: unique_id=%s -> name=%s", - unique_id, - entity_entry.name, - ) - return entity_entry.name - - return default_name - - -def construct_entity_id( - coordinator: SpanPanelCoordinator, - span_panel: SpanPanel, - platform: str, - circuit_name: str, - circuit_number: int | str, - suffix: str, - unique_id: str | None = None, -) -> str | None: - """Construct entity ID based on integration configuration flags. - - Used by switch, binary_sensor, and select entities. - This function handles entity naming for individual circuit entities based on the - USE_CIRCUIT_NUMBERS and USE_DEVICE_PREFIX configuration flags. It also checks - the entity registry to respect user customizations when unique_id is provided. - - Args: - coordinator: The coordinator instance - span_panel: The span panel data - platform: Platform name ("sensor", "switch", "select") - circuit_name: Human-readable circuit name - circuit_number: Circuit number/identifier - suffix: Entity-specific suffix ("power", "energy_produced", etc.) - unique_id: The unique ID for this entity (None to skip registry lookup) - - Returns: - Constructed entity ID string or None if device info unavailable - - """ - # Check registry first only if unique_id is provided - if unique_id is not None: - entity_registry = er.async_get(coordinator.hass) - existing_entity_id = entity_registry.async_get_entity_id(platform, DOMAIN, unique_id) - - if existing_entity_id: - return existing_entity_id - - # Construct default entity_id - config_entry = coordinator.config_entry - - # Get device name from config entry data - device_name = config_entry.data.get("device_name", config_entry.title) - if not device_name: - return None - - # Default to False so legacy entries without the flag use friendly names - use_circuit_numbers = config_entry.options.get(USE_CIRCUIT_NUMBERS, False) - use_device_prefix = config_entry.options.get(USE_DEVICE_PREFIX, True) - - # Build entity ID components - parts = [] - - if use_device_prefix: - # Sanitize device name for entity ID use - sanitized_device_name = slugify(device_name) - parts.append(sanitized_device_name) - - if use_circuit_numbers: - parts.append(f"circuit_{circuit_number}") - else: - circuit_name_slug = slugify(circuit_name) - parts.append(circuit_name_slug) - - # Only add suffix if it's different from the last word in the circuit name - # This prevents duplication like "current_power_power" - if suffix: - circuit_name_words = circuit_name.lower().split() - last_word = circuit_name_words[-1] if circuit_name_words else "" - - # Convert last word to same format as suffix for comparison - last_word_normalized = last_word.replace(" ", "_") - - # Only add suffix if it's not the same as the last word in the name - if suffix != last_word_normalized: - parts.append(suffix) - - entity_id = f"{platform}.{'_'.join(parts)}" - return entity_id - - -def get_user_friendly_suffix(description_key: str) -> str: - """Convert API description keys to user-friendly suffixes for consistent naming.""" - # If we have a direct mapping, use it - if description_key in ALL_SUFFIX_MAPPINGS: - return ALL_SUFFIX_MAPPINGS[description_key] - - # Otherwise, sanitize by converting dots to underscores and making lowercase - return description_key.replace(".", "_").lower() - - -def build_circuit_unique_id(serial: str, circuit_id: str, description_key: str) -> str: - """Build unique ID for circuit sensors using consistent pattern (pure function). - - Args: - serial: Panel serial number - circuit_id: Circuit ID from panel API (UUID or tab number) - description_key: Sensor description key (e.g., "instantPowerW") - - Returns: - Unique ID like "span_{serial}_{circuit_id}_{consistent_suffix}" - - """ - consistent_suffix = get_user_friendly_suffix(description_key) - return f"span_{serial.lower()}_{circuit_id}_{consistent_suffix}" - - -def get_panel_entity_suffix(description_key: str) -> str: - """Convert panel API description keys to entity ID suffixes for unique ID consistency. - - This ensures panel unique IDs use the same suffix as entity IDs for consistency. - """ - # If we have a direct mapping, use it - if description_key in PANEL_ENTITY_SUFFIX_MAPPING: - return PANEL_ENTITY_SUFFIX_MAPPING[description_key] - - # Otherwise, fall back to the general suffix mapping - return get_user_friendly_suffix(description_key) - - -def build_panel_unique_id(serial: str, description_key: str) -> str: - """Build unique ID for panel-level sensors using entity ID suffix pattern (pure function). - - Args: - serial: Panel serial number - description_key: Sensor description key (e.g., "instantGridPowerW") - - Returns: - Unique ID like "span_{serial}_{entity_suffix}" (matches entity ID suffix) - - """ - entity_suffix = get_panel_entity_suffix(description_key) - return f"span_{serial.lower()}_{entity_suffix}" - - -def build_switch_unique_id(serial: str, circuit_id: str) -> str: - """Build unique ID for switch entities using consistent pattern (pure function). - - Args: - serial: Panel serial number - circuit_id: Circuit ID from panel API - - Returns: - Unique ID like "span_{serial}_relay_{circuit_id}" - - """ - return f"span_{serial}_relay_{circuit_id}" - - -def build_binary_sensor_unique_id(serial: str, description_key: str) -> str: - """Build unique ID for binary sensor entities using consistent pattern (pure function). - - Args: - serial: Panel serial number - description_key: Sensor description key (e.g., "doorState") - - Returns: - Unique ID like "span_{serial}_{description_key}" - - """ - return f"span_{serial}_{description_key}" - - -def build_select_unique_id(serial: str, select_id: str) -> str: - """Build unique ID for select entities using consistent pattern (pure function). - - Args: - serial: Panel serial number - select_id: Select entity identifier - - Returns: - Unique ID like "span_{serial}_select_{select_id}" - - """ - return f"span_{serial}_select_{select_id}" - - -def construct_synthetic_unique_id(serial: str, sensor_name: str) -> str: - """Build unique ID for synthetic sensors using consistent pattern (pure function). - - Args: - serial: Panel serial number - sensor_name: Complete sensor name with suffix (e.g., "solar_inverter_power") - - Returns: - Unique ID like "span_{serial}_{sensor_name}" - - """ - return f"span_{serial.lower()}_{sensor_name}" - - -def construct_sensor_set_id(device_identifier: str) -> str: - """Build sensor set ID for synthetic sensors using consistent pattern (pure function). - - Args: - device_identifier: Device identifier (serial number for real panels, slugified name for simulators) - - Returns: - Sensor set ID like "{device_identifier}_sensors" - - """ - return f"{device_identifier}_sensors" - - -def generate_unique_simulator_serial_number(hass: HomeAssistant) -> str: - """Generate a unique simulator serial number in the format sim-nnn. - - Args: - hass: Home Assistant instance - - Returns: - Unique serial number in format sim-nnn (e.g., sim-001, sim-002, etc.) - - """ - - # Get all existing span panel config entries - existing_entries = hass.config_entries.async_entries(DOMAIN) - - # Find existing simulator serial numbers - existing_serials = set() - for entry in existing_entries: - if entry.data.get("simulation_mode", False): - # Check both simulator_serial_number and CONF_HOST fields - # simulator_serial_number is the newer field - serial = entry.data.get("simulator_serial_number") - if serial and serial.startswith("sim-"): - existing_serials.add(serial) - - # CONF_HOST may contain the serial for existing simulator configurations - host_serial = entry.data.get(CONF_HOST) - if host_serial and host_serial.startswith("sim-"): - existing_serials.add(host_serial) - - # Find the next available number - counter = 1 - while f"sim-{counter:03d}" in existing_serials: - counter += 1 - - return f"sim-{counter:03d}" - - -def _get_device_identifier_for_unique_ids( - coordinator: SpanPanelCoordinator, - span_panel: SpanPanel, - device_name: str | None = None, -) -> str: - """Compute per-entry device identifier for unique_ids. - - - Live panels: use true serial number - - Simulator entries: use serial number from span panel status (which should be sim-nnn format) - """ - is_simulator = bool(coordinator.config_entry.data.get("simulation_mode", False)) - if is_simulator: - # For simulators, use the serial number from the span panel status - # This should be in the format sim-nnn (e.g., sim-001, sim-002, etc.) - return span_panel.status.serial_number - return span_panel.status.serial_number - - -def construct_panel_unique_id_for_entry( - coordinator: SpanPanelCoordinator, - span_panel: SpanPanel, - description_key: str, - device_name: str | None = None, -) -> str: - """Build panel unique_id using per-entry identifier (handles simulators).""" - identifier = _get_device_identifier_for_unique_ids(coordinator, span_panel, device_name) - return build_panel_unique_id(identifier, description_key) - - -def construct_circuit_unique_id_for_entry( - coordinator: SpanPanelCoordinator, - span_panel: SpanPanel, - circuit_id: str, - description_key: str, - device_name: str | None = None, -) -> str: - """Build circuit unique_id using per-entry identifier (handles simulators).""" - identifier = _get_device_identifier_for_unique_ids(coordinator, span_panel, device_name) - return build_circuit_unique_id(identifier, circuit_id, description_key) - - -def build_switch_unique_id_for_entry( - coordinator: SpanPanelCoordinator, - span_panel: SpanPanel, - circuit_id: str, - device_name: str | None = None, -) -> str: - """Build switch unique_id using per-entry identifier (handles simulators).""" - identifier = _get_device_identifier_for_unique_ids(coordinator, span_panel, device_name) - return build_switch_unique_id(identifier, circuit_id) - - -def build_select_unique_id_for_entry( - coordinator: SpanPanelCoordinator, - span_panel: SpanPanel, - select_id: str, - device_name: str | None = None, -) -> str: - """Build select unique_id using per-entry identifier (handles simulators).""" - identifier = _get_device_identifier_for_unique_ids(coordinator, span_panel, device_name) - return build_select_unique_id(identifier, select_id) - - -def build_binary_sensor_unique_id_for_entry( - coordinator: SpanPanelCoordinator, - span_panel: SpanPanel, - description_key: str, - device_name: str | None = None, -) -> str: - """Build binary_sensor unique_id using per-entry identifier (handles simulators).""" - identifier = _get_device_identifier_for_unique_ids(coordinator, span_panel, device_name) - return build_binary_sensor_unique_id(identifier, description_key) - - -def construct_synthetic_unique_id_for_entry( - coordinator: SpanPanelCoordinator, - span_panel: SpanPanel, - sensor_name: str, - device_name: str | None = None, -) -> str: - """Build synthetic sensor unique_id using per-entry identifier (handles simulators).""" - identifier = _get_device_identifier_for_unique_ids(coordinator, span_panel, device_name) - return construct_synthetic_unique_id(identifier, sensor_name) - - -def get_device_identifier_for_entry( - coordinator: SpanPanelCoordinator, - span_panel: SpanPanel, - device_name: str | None = None, -) -> str: - """Public helper to get the per-entry device identifier used in unique_ids and storage.""" - return _get_device_identifier_for_unique_ids(coordinator, span_panel, device_name) - - -def construct_circuit_unique_id( - span_panel: SpanPanel, circuit_id: str, description_key: str -) -> str: - """Construct unique ID for circuit sensors using consistent pattern. - - Args: - span_panel: The span panel data - circuit_id: Circuit ID from panel API (UUID or tab number) - description_key: Sensor description key (e.g., "instantPowerW") - - Returns: - Unique ID like "span_{serial}_{circuit_id}_{consistent_suffix}" - - Examples: - span_abc123_0dad2f16cd514812ae1807b0457d473e_power - span_abc123_circuit_15_energy_produced - - """ - return build_circuit_unique_id(span_panel.status.serial_number, circuit_id, description_key) - - -def construct_panel_unique_id(span_panel: SpanPanel, description_key: str) -> str: - """Construct unique ID for panel-level sensors using consistent pattern. - - Args: - span_panel: The span panel data - description_key: Sensor description key (e.g., "instantGridPowerW") - - Returns: - Unique ID like "span_{serial}_{consistent_suffix}" (uses descriptive consistent names) - - Examples: - span_abc123_grid_power - span_abc123_feed_through_power - span_abc123_dsm_state - - """ - return build_panel_unique_id(span_panel.status.serial_number, description_key) - - -def construct_switch_unique_id(span_panel: SpanPanel, circuit_id: str) -> str: - """Construct unique ID for switch entities using consistent pattern. - - Args: - span_panel: The span panel data - circuit_id: Circuit ID from panel API - - Returns: - Unique ID like "span_{serial}_relay_{circuit_id}" - - Examples: - span_abc123_relay_0dad2f16cd514812ae1807b0457d473e - - """ - return build_switch_unique_id(span_panel.status.serial_number, circuit_id) - - -def construct_binary_sensor_unique_id(span_panel: SpanPanel, description_key: str) -> str: - """Construct unique ID for binary sensor entities using consistent pattern. - - Args: - span_panel: The span panel data - description_key: Sensor description key (e.g., "doorState") - - Returns: - Unique ID like "span_{serial}_{description_key}" - - Examples: - span_abc123_doorState - span_abc123_eth0Link - - """ - return build_binary_sensor_unique_id(span_panel.status.serial_number, description_key) - - -def construct_select_unique_id(span_panel: SpanPanel, select_id: str) -> str: - """Construct unique ID for select entities using consistent pattern. - - Args: - span_panel: The span panel data - select_id: Select entity identifier - - Returns: - Unique ID like "span_{serial}_select_{select_id}" - - Examples: - span_abc123_select_priority_mode - - """ - return build_select_unique_id(span_panel.status.serial_number, select_id) - - -def construct_multi_circuit_entity_id( - coordinator: SpanPanelCoordinator, - span_panel: SpanPanel, - platform: str, - suffix: str, - circuit_numbers: list[int], - friendly_name: str | None = None, - unique_id: str | None = None, -) -> str | None: - """Construct entity ID for multi-circuit sensors (like solar inverters). - - Args: - coordinator: The coordinator instance - span_panel: The span panel data - platform: Platform name ("sensor", "switch", "select") - suffix: Entity-specific suffix ("power", "energy_produced", etc.) - circuit_numbers: List of circuit numbers this sensor combines - friendly_name: Descriptive name for this sensor (required if unique_id is None) - unique_id: The unique ID for this entity (None to skip registry lookup) - - Returns: - Constructed entity ID string or None if device info unavailable - - """ - # Check registry first only if unique_id is provided - if unique_id is not None: - entity_registry = er.async_get(coordinator.hass) - existing_entity_id = entity_registry.async_get_entity_id(platform, DOMAIN, unique_id) - - _LOGGER.debug( - "Multi-circuit helper registry lookup (switches/selects) - unique_id=%s, found_entity_id=%s", - unique_id, - existing_entity_id, - ) - - if existing_entity_id: - return existing_entity_id - else: - # During migration, unique_id lookup should always succeed - raise ValueError( - f"Registry lookup failed for unique_id '{unique_id}' during migration. Entity should exist in registry." - ) - else: - _LOGGER.debug( - "Multi-circuit helper (switches/selects) - no unique_id provided, skipping registry lookup" - ) - - # Get device name from config entry data - device_name = coordinator.config_entry.data.get("device_name", coordinator.config_entry.title) - if not device_name: - return None - - use_circuit_numbers = coordinator.config_entry.options.get(USE_CIRCUIT_NUMBERS, False) - - # If no unique_id provided, friendly_name is required when not using circuit numbers - if unique_id is None and not use_circuit_numbers and not friendly_name: - _LOGGER.error( - "friendly_name is required when unique_id is None and not using circuit numbers for multi-circuit entity" - ) - return None - - if use_circuit_numbers: - # Use circuit number pattern: sensor.span_panel_circuit_30_32_power - if circuit_numbers: - sorted_circuits = sorted([num for num in circuit_numbers if num > 0]) - else: - sorted_circuits = [] - if sorted_circuits: - if len(sorted_circuits) == 1: - circuit_part = f"circuit_{sorted_circuits[0]}" - else: - circuit_list = "_".join(str(num) for num in sorted_circuits) - circuit_part = f"circuit_{circuit_list}" - else: - raise ValueError( - f"Circuit-based naming is enabled but no valid circuit numbers provided. " - f"Got circuit_numbers={circuit_numbers}. Multi-circuit entities require valid circuit numbers when USE_CIRCUIT_NUMBERS is True." - ) - else: - # Use friendly name pattern: sensor.span_panel_solar_inverter_power - circuit_part = slugify(friendly_name) - - # Build the entity ID - use_device_prefix = coordinator.config_entry.options.get(USE_DEVICE_PREFIX, False) - parts = [] - - if use_device_prefix: - if device_name: - # Sanitize device name for entity ID use - sanitized_device_name = slugify(device_name) - parts.append(sanitized_device_name) - - parts.append(circuit_part) - - # Add suffix if not already in circuit_part - if suffix and not circuit_part.endswith(f"_{suffix}"): - parts.append(suffix) - - return f"{platform}.{'_'.join(parts)}" - - -def construct_single_circuit_entity_id( - coordinator: SpanPanelCoordinator, - span_panel: SpanPanel, - platform: str, - suffix: str, - circuit_data: SpanPanelCircuit, - unique_id: str | None = None, - device_name: str | None = None, -) -> str | None: - """Construct entity ID for single-circuit sensors. - - Args: - coordinator: The coordinator instance - span_panel: The span panel data - platform: Platform name ("sensor", "switch", "select") - suffix: Entity-specific suffix ("power", "energy_produced", etc.) - circuit_data: Circuit data object - unique_id: The unique ID for this entity (None to skip registry lookup) - device_name: Device name for entity ID construction (None to use from config entry) - - Returns: - Constructed entity ID string or None if device info unavailable - - """ - # Check registry first only if unique_id is provided - if unique_id is not None: - entity_registry = er.async_get(coordinator.hass) - existing_entity_id = entity_registry.async_get_entity_id(platform, DOMAIN, unique_id) - - _LOGGER.debug( - "Circuit helper registry lookup - unique_id=%s, found_entity_id=%s", - unique_id, - existing_entity_id, - ) - - if existing_entity_id: - return existing_entity_id - else: - # FATAL ERROR: Expected unique_id not found in registry - raise ValueError( - f"REGISTRY LOOKUP ERROR: Expected unique_id '{unique_id}' not found in registry. " - f"This indicates a migration or configuration mismatch." - ) - else: - _LOGGER.debug("Circuit helper - no unique_id provided, skipping registry lookup") - - # Get device info - device_info = panel_to_device_info(span_panel, device_name) - if not device_info or not device_info.get("name"): - return None - - use_circuit_numbers = coordinator.config_entry.options.get(USE_CIRCUIT_NUMBERS, False) - - if use_circuit_numbers: - # Check if this is a 240V circuit (2 tabs) or 120V circuit (1 tab) - if circuit_data.tabs and len(circuit_data.tabs) == 2: - # 240V circuit - use both tab numbers - sorted_tabs = sorted(circuit_data.tabs) - circuit_part = f"circuit_{sorted_tabs[0]}_{sorted_tabs[1]}" - elif circuit_data.tabs and len(circuit_data.tabs) == 1: - # 120V circuit - use single tab number - circuit_part = f"circuit_{circuit_data.tabs[0]}" - else: - # Fallback to original logic for circuits without tabs or with invalid tab count - circuit_number = get_circuit_number(circuit_data) - if circuit_number: - circuit_part = f"circuit_{circuit_number}" - else: - circuit_part = "circuit_unknown" - else: - # Use friendly name pattern: sensor.span_panel_solar_east_power - if circuit_data.name: - circuit_part = slugify(circuit_data.name) - else: - circuit_part = "single_circuit" - - # Build the entity ID (only for non-voltage-specific cases) - use_device_prefix = coordinator.config_entry.options.get(USE_DEVICE_PREFIX, False) - parts = [] - - if use_device_prefix: - device_name = device_info.get("name") - if device_name: - # Sanitize device name for entity ID use - sanitized_device_name = slugify(device_name) - parts.append(sanitized_device_name) - - parts.append(circuit_part) - - # Add suffix if not already in circuit_part - if suffix and not circuit_part.endswith(f"_{suffix}"): - parts.append(suffix) - - return f"{platform}.{'_'.join(parts)}" - - -def construct_panel_entity_id( - coordinator: SpanPanelCoordinator, - span_panel: SpanPanel, - platform: str, - suffix: str, - device_name: str, - unique_id: str | None = None, - use_device_prefix: bool | None = None, -) -> str | None: - """Construct entity ID for panel-level sensors based on integration configuration flags. - - This function handles entity naming for panel-level entities based on the - USE_DEVICE_PREFIX configuration flag. It also checks the entity registry - to respect user customizations when unique_id is provided. - - Args: - coordinator: The coordinator instance - span_panel: The span panel data - platform: Platform name ("sensor", "switch", "select") - suffix: Entity-specific suffix ("current_power", "feed_through_power", etc.) - device_name: Device name for the panel - unique_id: The unique ID for this entity (None to skip registry lookup) - use_device_prefix: Whether to include device name prefix in entity ID (None to use config option) - - Returns: - Constructed entity ID string or None if device info unavailable - - """ - # Check registry first only if unique_id is provided - if unique_id is not None: - entity_registry = er.async_get(coordinator.hass) - existing_entity_id = entity_registry.async_get_entity_id(platform, DOMAIN, unique_id) - - # Debug logging for panel entity registry lookup - _LOGGER.debug( - f"Panel helper registry lookup - unique_id={unique_id}, found_entity_id={existing_entity_id}" - ) - - if existing_entity_id: - return existing_entity_id - - # Construct default entity_id - config_entry = coordinator.config_entry - - if not device_name: - return None - - if use_device_prefix is None: - use_device_prefix = config_entry.options.get(USE_DEVICE_PREFIX, True) - - # Build entity ID components - parts = [] - - if use_device_prefix: - # Sanitize device name for entity ID use - sanitized_device_name = slugify(device_name) - parts.append(sanitized_device_name) - - parts.append(suffix) - - entity_id = f"{platform}.{'_'.join(parts)}" - return entity_id - - -def construct_unmapped_unique_id( - span_panel: SpanPanel, circuit_number: int | str, suffix: str -) -> str: - """Construct unique ID for unmapped circuit sensors.""" - # Always use consistent unique ID pattern for unmapped circuits - # Format: span_{serial}_unmapped_tab_{circuit_number}_{suffix} - return f"span_{span_panel.status.serial_number}_unmapped_tab_{circuit_number}_{suffix}" - - -def construct_unmapped_entity_id( - span_panel: SpanPanel, circuit_id: str, suffix: str, device_name: str | None = None -) -> str: - """Construct entity ID for unmapped tab with consistent modern naming. - - Args: - span_panel: The span panel data - circuit_id: Circuit ID (e.g., "unmapped_tab_32") - suffix: Sensor suffix (e.g., "power", "energy_produced") - device_name: The device name to use for entity ID construction - - Returns: - Entity ID string like "sensor.span_panel_unmapped_tab_32_power" - - """ - # Always use device prefix for unmapped entities - # circuit_id is "unmapped_tab_32", add device prefix and suffix to create - # "sensor.span_panel_unmapped_tab_32_power" - device_info = panel_to_device_info(span_panel, device_name) - device_name_raw = device_info.get("name") - _LOGGER.debug( - "construct_unmapped_entity_id: circuit_id=%s, suffix=%s, device_name_raw=%s", - circuit_id, - suffix, - device_name_raw, - ) - if device_name_raw: - # Sanitize device name for entity ID use - sanitized_device_name = slugify(device_name_raw) - result = f"sensor.{sanitized_device_name}_{circuit_id}_{suffix}" - _LOGGER.debug("construct_unmapped_entity_id result with device: %s", result) - return result - else: - result = f"sensor.{circuit_id}_{suffix}" - _LOGGER.debug("construct_unmapped_entity_id result without device: %s", result) - return result - - -def get_unmapped_circuit_entity_id( - span_panel: SpanPanel, tab_number: int, suffix: str, device_name: str | None = None -) -> str | None: - """Get entity ID for an unmapped circuit based on tab number. - - This helper function constructs the entity ID for native unmapped circuit sensors - that should already exist in Home Assistant. It's useful for synthetic sensors - that need to reference these native entities in formulas. - - Args: - span_panel: The span panel data - tab_number: The tab number (e.g., 30, 32) - suffix: The sensor suffix (e.g., "power", "energy_produced", "energy_consumed") - device_name: The device name to use for entity ID construction - - Returns: - Entity ID string like "sensor.span_panel_unmapped_tab_30_power" - or None if the circuit doesn't exist - - Examples: - get_unmapped_circuit_entity_id(span_panel, 30, "power") - # Returns: "sensor.span_panel_unmapped_tab_30_power" - - get_unmapped_circuit_entity_id(span_panel, 32, "energy_produced") - # Returns: "sensor.span_panel_unmapped_tab_32_energy_produced" - - """ - circuit_id = f"unmapped_tab_{tab_number}" - - # Verify the circuit exists in the panel data - if circuit_id not in span_panel.circuits: - _LOGGER.debug("Unmapped circuit %s not found in circuits list", circuit_id) - return None - - result_entity_id = construct_unmapped_entity_id(span_panel, circuit_id, suffix, device_name) - _LOGGER.debug("Generated unmapped entity ID: %s", result_entity_id) - return result_entity_id - - -def construct_unmapped_friendly_name( - circuit_number: int | str, sensor_description_name: str -) -> str: - """Construct friendly name for unmapped circuit sensors.""" - # Format: "Unmapped Tab 32 Consumed Energy" - return f"Unmapped Tab {circuit_number} {sensor_description_name}" - - -def construct_panel_friendly_name(description_name: Any) -> str: - """Construct friendly name for panel-level sensors. - - Args: - description_name: The sensor description name (can be str, None, or UndefinedType) - - Returns: - Friendly name string - - """ - return str(description_name) if description_name else "" - - -def construct_status_friendly_name(description_name: Any) -> str: - """Construct friendly name for status sensors. - - Args: - description_name: The sensor description name (can be str, None, or UndefinedType) - - Returns: - Friendly name string - - """ - return str(description_name) if description_name else "" - async def async_create_span_notification( hass: HomeAssistant, @@ -1317,33 +137,34 @@ async def async_create_span_notification( ) -def construct_unmapped_circuit_id(circuit_number: int | str) -> str: - """Construct circuit ID for unmapped circuits. +def construct_circuit_identifier_from_tabs(tabs: list[int], circuit_id: str = "") -> str: + """Build a human-readable circuit identifier from tab positions. - This returns just the circuit ID part (e.g., "unmapped_tab_30"), not a full entity ID. - Used for API circuit references and internal circuit identification. + Used as a fallback when a circuit has no panel-assigned name. Args: - circuit_number: The tab number (e.g., 30, 32) + tabs: List of tab numbers (1 for 120V, 2 for 240V dipole) + circuit_id: Fallback identifier when tabs are unavailable Returns: - Circuit ID string like "unmapped_tab_30" - - Examples: - construct_unmapped_circuit_id(30) -> "unmapped_tab_30" - construct_unmapped_circuit_id(32) -> "unmapped_tab_32" + String like "Circuit 30 32" for 240V or "Circuit 15" for 120V """ - return f"unmapped_tab_{circuit_number}" + if tabs and len(tabs) == 2: + sorted_tabs = sorted(tabs) + return f"Circuit {sorted_tabs[0]} {sorted_tabs[1]}" + if tabs and len(tabs) == 1: + return f"Circuit {tabs[0]}" + return f"Circuit {circuit_id}" -def construct_tabs_attribute(circuit: SpanPanelCircuit) -> str | None: +def construct_tabs_attribute(circuit: SpanCircuitSnapshot) -> str | None: """Construct tabs attribute string from circuit data. For US electrical systems, circuits can only have 1 tab (120V) or 2 tabs (240V). Args: - circuit: SpanPanelCircuit object with tabs information + circuit: SpanCircuitSnapshot object with tabs information Returns: Tabs attribute string like "tabs [30:32]" for 240V or "tabs [28]" for 120V, @@ -1364,127 +185,90 @@ def construct_tabs_attribute(circuit: SpanPanelCircuit) -> str | None: if len(sorted_tabs) == 1: # Single tab (120V) return f"tabs [{sorted_tabs[0]}]" - elif len(sorted_tabs) == 2: + if len(sorted_tabs) == 2: # Two tabs (240V) - format as range return f"tabs [{sorted_tabs[0]}:{sorted_tabs[1]}]" - else: - # More than 2 tabs is not valid for US electrical system - _LOGGER.warning( - "Circuit %s has %d tabs, which is not valid for US electrical system (expected 1 or 2)", - circuit.circuit_id, - len(sorted_tabs), - ) - return None + # More than 2 tabs is not valid for US electrical system + _LOGGER.warning( + "Circuit %s has %d tabs, which is not valid for US electrical system (expected 1 or 2)", + circuit.circuit_id, + len(sorted_tabs), + ) + return None -def parse_tabs_attribute(tabs_attr: str) -> list[int] | None: - """Parse tabs attribute string back to list of tab numbers. +def construct_voltage_attribute(circuit: SpanCircuitSnapshot) -> int | None: + """Construct voltage attribute for a circuit based on tab count. - For US electrical systems, only 1 tab (120V) or 2 tabs (240V) are valid. + For US electrical systems, circuits can only have 1 tab (120V) or 2 tabs (240V). Args: - tabs_attr: Tabs attribute string like "tabs [30:32]" or "tabs [28]" + circuit: SpanCircuitSnapshot object with tabs information Returns: - List of tab numbers, or None if parsing fails or invalid for US electrical system + Voltage in volts (120 for single tab, 240 for double tab), or None if no tabs information Examples: - "tabs [28]" -> [28] (120V) - "tabs [30:32]" -> [30, 32] (240V) - - """ - if not tabs_attr or not tabs_attr.startswith("tabs ["): - return None - - try: - # Extract content between brackets - content = tabs_attr[6:-1] # Remove "tabs [" and "]" - - if ":" in content: - # Range format: "30:32" (240V) - start, end = map(int, content.split(":")) - return [start, end] - else: - # Single tab: "28" (120V) - return [int(content)] - - except (ValueError, IndexError) as e: - _LOGGER.warning("Failed to parse tabs attribute '%s': %s", tabs_attr, e) - return None - - -def get_circuit_voltage_type(circuit: SpanPanelCircuit) -> str: - """Determine the voltage type of a circuit based on its tabs. - - For US electrical systems, circuits can only be 120V (1 tab) or 240V (2 tabs). - - Args: - circuit: SpanPanelCircuit object - - Returns: - Voltage type: "120V" for single tab, "240V" for two tabs, "unknown" otherwise + Single tab (120V): 120 + Two tabs (240V): 240 + No tabs: None """ if not circuit.tabs: - return "unknown" + return None if len(circuit.tabs) == 1: - return "120V" - elif len(circuit.tabs) == 2: - return "240V" - else: - # More than 2 tabs is not valid for US electrical system - _LOGGER.warning( - "Circuit %s has %d tabs, which is not valid for US electrical system (expected 1 or 2)", - circuit.circuit_id, - len(circuit.tabs), - ) - return "unknown" + return 120 + if len(circuit.tabs) == 2: + return 240 + # More than 2 tabs is not valid for US electrical system + _LOGGER.warning( + "Circuit %s has %d tabs, which is not valid for US electrical system (expected 1 or 2)", + circuit.circuit_id, + len(circuit.tabs), + ) + return None -def get_panel_voltage_attribute() -> int: - """Get voltage attribute for panel-level sensors. +def has_bess(snapshot: SpanPanelSnapshot) -> bool: + """Detect whether a BESS (battery energy storage system) is commissioned. - US residential electrical panels are standardized as 240V split-phase systems. - Panel-level sensors (like main meter energy) represent aggregate measurements - at the full panel voltage. + Only soe_percentage is a reliable signal — the power-flows node publishes + battery=0.0 even on panels without a commissioned BESS. + """ + return snapshot.battery.soe_percentage is not None - Returns: - Panel voltage in volts (always 240 for US residential panels) - """ - return 240 +def has_pv(snapshot: SpanPanelSnapshot) -> bool: + """Detect whether PV (solar) is commissioned.""" + return snapshot.power_flow_pv is not None or any( + c.device_type == "pv" for c in snapshot.circuits.values() + ) -def construct_voltage_attribute(circuit: SpanPanelCircuit) -> int | None: - """Construct voltage attribute for a circuit based on tab count. +def has_power_flows(snapshot: SpanPanelSnapshot) -> bool: + """Detect whether the power-flows node is publishing data.""" + return snapshot.power_flow_site is not None - For US electrical systems, circuits can only have 1 tab (120V) or 2 tabs (240V). - Args: - circuit: SpanPanelCircuit object with tabs information +def has_evse(snapshot: SpanPanelSnapshot) -> bool: + """Detect whether an EVSE (EV charger) is commissioned.""" + return len(snapshot.evse) > 0 - Returns: - Voltage in volts (120 for single tab, 240 for double tab), or None if no tabs information - Examples: - Single tab (120V): 120 - Two tabs (240V): 240 - No tabs: None +def detect_capabilities(snapshot: SpanPanelSnapshot) -> frozenset[str]: + """Derive the set of optional capabilities present in the snapshot. + Used by the coordinator to detect when new hardware (BESS, PV, EVSE) appears + and trigger a reload so new sensors are created. """ - if not circuit.tabs: - return None - - if len(circuit.tabs) == 1: - return 120 - elif len(circuit.tabs) == 2: - return 240 - else: - # More than 2 tabs is not valid for US electrical system - _LOGGER.warning( - "Circuit %s has %d tabs, which is not valid for US electrical system (expected 1 or 2)", - circuit.circuit_id, - len(circuit.tabs), - ) - return None + caps: set[str] = set() + if has_bess(snapshot): + caps.add("bess") + if has_pv(snapshot): + caps.add("pv") + if has_power_flows(snapshot): + caps.add("power_flows") + if has_evse(snapshot): + caps.add("evse") + return frozenset(caps) diff --git a/custom_components/span_panel/icons.json b/custom_components/span_panel/icons.json new file mode 100644 index 00000000..4a7e9445 --- /dev/null +++ b/custom_components/span_panel/icons.json @@ -0,0 +1,209 @@ +{ + "entity": { + "binary_sensor": { + "bess_connected": { + "default": "mdi:battery-check" + }, + "door_state": { + "default": "mdi:door-closed", + "state": { + "off": "mdi:door-closed", + "on": "mdi:door-open" + } + }, + "ethernet_link": { + "default": "mdi:ethernet" + }, + "evse_charging": { + "default": "mdi:ev-station" + }, + "evse_ev_connected": { + "default": "mdi:ev-plug-type2" + }, + "grid_islandable": { + "default": "mdi:island" + }, + "panel_status": { + "default": "mdi:lan-connect", + "state": { + "off": "mdi:lan-disconnect", + "on": "mdi:lan-connect" + } + }, + "wifi_link": { + "default": "mdi:wifi" + } + }, + "button": { + "gfe_override": { + "default": "mdi:transmission-tower" + } + }, + "select": { + "circuit_priority": { + "default": "mdi:priority-high" + } + }, + "sensor": { + "battery_level": { + "default": "mdi:battery" + }, + "battery_power": { + "default": "mdi:battery-charging" + }, + "bess_firmware_version": { + "default": "mdi:chip" + }, + "bess_model": { + "default": "mdi:battery-outline" + }, + "bess_nameplate_capacity": { + "default": "mdi:battery-high" + }, + "bess_serial_number": { + "default": "mdi:identifier" + }, + "bess_soe_kwh": { + "default": "mdi:battery-charging" + }, + "bess_vendor": { + "default": "mdi:domain" + }, + "current_run_config": { + "default": "mdi:cog", + "state": { + "panel_backup": "mdi:battery-arrow-up", + "panel_off_grid": "mdi:cog-off", + "panel_on_grid": "mdi:cog" + } + }, + "downstream_l1_current": { + "default": "mdi:current-ac" + }, + "downstream_l2_current": { + "default": "mdi:current-ac" + }, + "dsm_grid_state": { + "default": "mdi:transmission-tower", + "state": { + "dsm_off_grid": "mdi:transmission-tower-off", + "dsm_on_grid": "mdi:transmission-tower" + } + }, + "dsm_state": { + "default": "mdi:transmission-tower", + "state": { + "dsm_off_grid": "mdi:transmission-tower-off", + "dsm_on_grid": "mdi:transmission-tower" + } + }, + "evse_advertised_current": { + "default": "mdi:current-ac" + }, + "evse_lock_state": { + "default": "mdi:lock", + "state": { + "locked": "mdi:lock", + "unlocked": "mdi:lock-open" + } + }, + "evse_status": { + "default": "mdi:ev-station", + "state": { + "available": "mdi:ev-plug-type2", + "charging": "mdi:ev-station", + "faulted": "mdi:alert-circle" + } + }, + "feedthrough_consumed_energy": { + "default": "mdi:transmission-tower-import" + }, + "feedthrough_net_energy": { + "default": "mdi:swap-horizontal-bold" + }, + "feedthrough_power": { + "default": "mdi:flash" + }, + "feedthrough_produced_energy": { + "default": "mdi:transmission-tower-export" + }, + "grid_forming_entity": { + "default": "mdi:lightning-bolt-circle", + "state": { + "battery": "mdi:battery", + "generator": "mdi:engine", + "grid": "mdi:transmission-tower", + "none": "mdi:lightning-bolt-circle", + "pv": "mdi:solar-power" + } + }, + "grid_power_flow": { + "default": "mdi:transmission-tower" + }, + "instant_grid_power": { + "default": "mdi:flash" + }, + "l1_voltage": { + "default": "mdi:sine-wave" + }, + "l2_voltage": { + "default": "mdi:sine-wave" + }, + "main_breaker_rating": { + "default": "mdi:fuse" + }, + "main_meter_consumed_energy": { + "default": "mdi:meter-electric" + }, + "main_meter_net_energy": { + "default": "mdi:swap-horizontal-bold" + }, + "main_meter_produced_energy": { + "default": "mdi:meter-electric-outline" + }, + "main_relay_state": { + "default": "mdi:electric-switch", + "state": { + "closed": "mdi:electric-switch-closed", + "open": "mdi:electric-switch" + } + }, + "pv_nameplate_capacity": { + "default": "mdi:solar-power" + }, + "pv_power": { + "default": "mdi:solar-power" + }, + "pv_product": { + "default": "mdi:solar-panel" + }, + "pv_vendor": { + "default": "mdi:domain" + }, + "site_power": { + "default": "mdi:home-lightning-bolt" + }, + "software_version": { + "default": "mdi:information" + }, + "upstream_l1_current": { + "default": "mdi:current-ac" + }, + "upstream_l2_current": { + "default": "mdi:current-ac" + }, + "vendor_cloud": { + "default": "mdi:cloud", + "state": { + "connected": "mdi:cloud-check", + "unconnected": "mdi:cloud-off-outline" + } + } + } + }, + "services": { + "export_circuit_manifest": { + "service": "mdi:file-export-outline" + } + } +} diff --git a/custom_components/span_panel/id_builder.py b/custom_components/span_panel/id_builder.py new file mode 100644 index 00000000..b790883c --- /dev/null +++ b/custom_components/span_panel/id_builder.py @@ -0,0 +1,428 @@ +"""Pure ID construction and suffix mapping functions for Span Panel integration. + +This module contains functions that build unique IDs and map suffixes. +It has NO dependency on Home Assistant, coordinator, or entity registry -- +only logging, re, and span_panel_api types. +""" + +from __future__ import annotations + +import logging +import re + +from span_panel_api import SpanPanelSnapshot + +_LOGGER = logging.getLogger(__name__) + +# Global suffix mappings for API description keys to user-friendly/entity suffixes +# These mappings drive consistent unique_id/entity_id suffixes across all sensors, +# including Net Energy and import/export flows, and are used for reverse lookups. + +# Circuit sensor API field mappings (used by get_user_friendly_suffix) +# Includes power, produced/consumed, net energy, and import/export energy +CIRCUIT_SUFFIX_MAPPING = { + "instantPowerW": "power", + "producedEnergyWh": "energy_produced", + "consumedEnergyWh": "energy_consumed", + "netEnergyWh": "energy_net", + "importedEnergyWh": "energy_imported", + "exportedEnergyWh": "energy_exported", + "circuit_priority": "priority", + "current": "current", + "breaker_rating": "breaker_rating", +} + +# Panel sensor API field mappings (used by get_user_friendly_suffix) +# Includes main meter/feedthrough produced, consumed, and net energy +PANEL_SUFFIX_MAPPING = { + "instantGridPowerW": "grid_power", # Descriptive to differentiate from other power types + "feedthroughPowerW": "feed_through_power", + "batteryPowerW": "battery_power", + "pvPowerW": "pv_power", + "gridPowerFlowW": "grid_power_flow", + "sitePowerW": "site_power", + "mainMeterEnergyProducedWh": "main_meter_energy_produced", # Consistent naming + "mainMeterEnergyConsumedWh": "main_meter_energy_consumed", # Consistent naming + "mainMeterNetEnergyWh": "main_meter_energy_net", # Consistent naming + "feedthroughEnergyProducedWh": "feed_through_energy_produced", # Consistent naming + "feedthroughEnergyConsumedWh": "feed_through_energy_consumed", # Consistent naming + "feedthroughNetEnergyWh": "feed_through_energy_net", # Consistent naming + "batteryPercentage": "battery_percentage", +} + +# Panel entity suffix mappings (used by get_panel_entity_suffix) +# These are the actual entity_id/unique_id suffixes used for panel sensors +# (e.g., "main_meter_net_energy" / "feed_through_net_energy"). +PANEL_ENTITY_SUFFIX_MAPPING = { + "instantGridPowerW": "current_power", + "feedthroughPowerW": "feed_through_power", + "batteryPowerW": "battery_power", + "pvPowerW": "pv_power", + "gridPowerFlowW": "grid_power_flow", + "sitePowerW": "site_power", + "mainMeterEnergyProducedWh": "main_meter_produced_energy", + "mainMeterEnergyConsumedWh": "main_meter_consumed_energy", + "mainMeterNetEnergyWh": "main_meter_net_energy", + "feedthroughEnergyProducedWh": "feed_through_produced_energy", + "feedthroughEnergyConsumedWh": "feed_through_consumed_energy", + "feedthroughNetEnergyWh": "feed_through_net_energy", + "batteryPercentage": "battery_level", +} + +# Combined mapping for general suffix lookup +ALL_SUFFIX_MAPPINGS = {**CIRCUIT_SUFFIX_MAPPING, **PANEL_SUFFIX_MAPPING} + + +def get_suffix_from_sensor_key(sensor_key: str) -> str: + """Extract the suffix from a sensor key for use with entity ID helpers. + + Args: + sensor_key: Sensor key like "span_abc123_solar_inverter_power" or "span_abc123_house_total_consumption" + + Returns: + User-friendly suffix like "power" or "consumption" + + Examples: + get_suffix_from_sensor_key("span_abc123_solar_inverter_power") → "power" + get_suffix_from_sensor_key("span_abc123_solar_inverter_energy_produced") → "energy_produced" + get_suffix_from_sensor_key("span_abc123_house_total_consumption") → "consumption" + + """ + # Remove device prefix (span_{serial}_) from sensor key + # Sensor keys follow pattern: span_{serial}_{actual_sensor_name} + parts = sensor_key.split("_") + if len(parts) >= 3 and parts[0] == "span": + # Reconstruct the sensor name without the device prefix + sensor_name = "_".join(parts[2:]) + else: + # Fallback if pattern doesn't match expected format + sensor_name = sensor_key + + # For solar sensors, the suffix is the last part after "solar_inverter_" + if sensor_name.startswith("solar_inverter_"): + return sensor_name.replace("solar_inverter_", "") + + # For other sensors, the suffix is typically the last part or last few parts + # Look for well-established suffix patterns + established_suffixes = [ + "energy_produced", + "energy_consumed", + "energy_net", + "current_power", + "grid_power", + "total_power", + "instant_power", + "consumption", + "production", + "power", + "energy", + ] + + # Check if the sensor name ends with any established suffix + for suffix in established_suffixes: + if sensor_name.endswith(suffix): + return suffix + + # If no established pattern matches, return the last part after the last underscore + name_parts = sensor_name.split("_") + return name_parts[-1] if name_parts else sensor_name + + +def is_panel_level_sensor_key(sensor_key: str) -> bool: + """Check if a sensor key represents a panel-level sensor. + + Panel-level sensors have the form: span_{device_identifier}_{sensor_type} + Circuit sensors have the form: span_{device_identifier}_{circuit_id}_{sensor_type} + + Args: + sensor_key: Sensor key to check (e.g., "span_span12345678_current_power" or + "span_span12345678_12ce227695cd44338864b0ef2ec4168b_instantPowerW"). + + Returns: + True if this is a panel-level sensor (no circuit ID) + + Examples: + is_panel_level_sensor_key("span_span12345678_current_power") → True + is_panel_level_sensor_key( + "span_span12345678_12ce227695cd44338864b0ef2ec4168b_instantPowerW" + ) → False + + """ + + # Must start with "span_" + if not sensor_key.startswith("span_"): + return False + + # Look for UUID pattern (32 hex characters) anywhere in the string after "span_" + # Circuit IDs in SPAN are typically formatted as 32 lowercase hex characters without dashes + uuid_pattern = re.compile(r"_[a-f0-9]{32}_") + + # If we find a UUID pattern, this is a circuit sensor + if uuid_pattern.search(sensor_key): + return False + # No UUID pattern found, this is a panel-level sensor + return True + + +def get_user_friendly_suffix(description_key: str) -> str: + """Convert API description keys to user-friendly suffixes for consistent naming.""" + # If we have a direct mapping, use it + if description_key in ALL_SUFFIX_MAPPINGS: + return ALL_SUFFIX_MAPPINGS[description_key] + + # Otherwise, sanitize by converting dots to underscores and making lowercase + return description_key.replace(".", "_").lower() + + +def get_panel_entity_suffix(description_key: str) -> str: + """Convert panel API description keys to entity ID suffixes for unique ID consistency. + + This ensures panel unique IDs use the same suffix as entity IDs for consistency. + """ + # If we have a direct mapping, use it + if description_key in PANEL_ENTITY_SUFFIX_MAPPING: + return PANEL_ENTITY_SUFFIX_MAPPING[description_key] + + # Otherwise, fall back to the general suffix mapping + return get_user_friendly_suffix(description_key) + + +def extract_circuit_uuid_from_unique_id(unique_id: str) -> str | None: + """Return the 32-char hex circuit UUID embedded in a SPAN entity unique_id. + + SPAN entity unique_ids follow ``span_{serial}_{circuit_uuid}_{suffix}``. + The circuit UUID is a 32-char lowercase hex segment. Skips ``parts[0]`` + (``span``) and ``parts[1]`` (the serial — never a circuit UUID) so a + serial that happens to be 32 hex chars cannot shadow the circuit id. + + Returns ``None`` for unique_ids with no circuit UUID segment (e.g. + panel-level sensors). + """ + if not unique_id: + return None + parts = unique_id.split("_") + for part in parts[2:]: + if len(part) == 32 and all(c in "0123456789abcdef" for c in part): + return part + return None + + +# --------------------------------------------------------------------------- +# build_*_unique_id — DO NOT "normalise" without a migration path. +# +# Historically, build_circuit_unique_id, build_panel_unique_id, and +# construct_synthetic_unique_id lower-case the serial; build_switch_unique_id, +# build_binary_sensor_unique_id, build_select_unique_id, build_bess_unique_id, +# and build_evse_unique_id do NOT. On panels with a mixed-case serial this +# means a single install has some unique_ids with the serial lower-cased and +# others with the serial preserved as-is. +# +# This is an inconsistency, but it is benign: HA's entity registry keys on +# string equality, not case-folded equality, so every deployed entity still +# matches itself on every restart. Unifying the casing would silently rewrite +# what these functions return and orphan every existing switch, binary_sensor, +# select, BESS, and EVSE entity on any live install whose serial contains +# upper-case characters. The registry would then recreate those entities under +# the new unique_ids with default names — breaking dashboards, automations, +# and statistics history. +# +# If you ever truly need to align these, do it together with a config-entry +# migration that walks the registry and renames stored unique_ids to match +# the new convention. Do not change a single one of these return strings in +# isolation. +# --------------------------------------------------------------------------- + + +def build_circuit_unique_id(serial: str, circuit_id: str, description_key: str) -> str: + """Build unique ID for circuit sensors using consistent pattern (pure function). + + Args: + serial: Panel serial number + circuit_id: Circuit ID from panel API (UUID or tab number) + description_key: Sensor description key (e.g., "instantPowerW") + + Returns: + Unique ID like "span_{serial}_{circuit_id}_{consistent_suffix}" + + """ + consistent_suffix = get_user_friendly_suffix(description_key) + return f"span_{serial.lower()}_{circuit_id}_{consistent_suffix}" + + +def build_panel_unique_id(serial: str, description_key: str) -> str: + """Build unique ID for panel-level sensors using entity ID suffix pattern (pure function). + + Args: + serial: Panel serial number + description_key: Sensor description key (e.g., "instantGridPowerW") + + Returns: + Unique ID like "span_{serial}_{entity_suffix}" (matches entity ID suffix) + + """ + entity_suffix = get_panel_entity_suffix(description_key) + return f"span_{serial.lower()}_{entity_suffix}" + + +def build_switch_unique_id(serial: str, circuit_id: str) -> str: + """Build unique ID for switch entities using consistent pattern (pure function). + + Args: + serial: Panel serial number + circuit_id: Circuit ID from panel API + + Returns: + Unique ID like "span_{serial}_relay_{circuit_id}" + + """ + return f"span_{serial}_relay_{circuit_id}" + + +def build_binary_sensor_unique_id(serial: str, description_key: str) -> str: + """Build unique ID for binary sensor entities using consistent pattern (pure function). + + Args: + serial: Panel serial number + description_key: Sensor description key (e.g., "doorState") + + Returns: + Unique ID like "span_{serial}_{description_key}" + + """ + return f"span_{serial}_{description_key}" + + +def build_select_unique_id(serial: str, select_id: str) -> str: + """Build unique ID for select entities using consistent pattern (pure function). + + Args: + serial: Panel serial number + select_id: Select entity identifier + + Returns: + Unique ID like "span_{serial}_select_{select_id}" + + """ + return f"span_{serial}_select_{select_id}" + + +def build_bess_unique_id(serial: str, description_key: str) -> str: + """Build unique ID for BESS sensor entities (pure function). + + Returns: "span_{serial}_bess_{description_key}" + """ + return f"span_{serial}_bess_{description_key}" + + +def build_evse_unique_id(serial: str, evse_id: str, description_key: str) -> str: + """Build unique ID for EVSE sensor/binary_sensor entities (pure function). + + Returns: "span_{serial}_evse_{evse_id}_{description_key}" + """ + return f"span_{serial}_evse_{evse_id}_{description_key}" + + +def construct_synthetic_unique_id(serial: str, sensor_name: str) -> str: + """Build unique ID for synthetic sensors using consistent pattern (pure function). + + Args: + serial: Panel serial number + sensor_name: Complete sensor name with suffix (e.g., "solar_inverter_power") + + Returns: + Unique ID like "span_{serial}_{sensor_name}" + + """ + return f"span_{serial.lower()}_{sensor_name}" + + +def construct_circuit_unique_id( + snapshot: SpanPanelSnapshot, circuit_id: str, description_key: str +) -> str: + """Construct unique ID for circuit sensors using consistent pattern. + + Args: + snapshot: The panel snapshot data + circuit_id: Circuit ID from panel API (UUID or tab number) + description_key: Sensor description key (e.g., "instantPowerW") + + Returns: + Unique ID like "span_{serial}_{circuit_id}_{consistent_suffix}" + + Examples: + span_abc123_0dad2f16cd514812ae1807b0457d473e_power + span_abc123_circuit_15_energy_produced + + """ + return build_circuit_unique_id(snapshot.serial_number, circuit_id, description_key) + + +def construct_panel_unique_id(snapshot: SpanPanelSnapshot, description_key: str) -> str: + """Construct unique ID for panel-level sensors using consistent pattern. + + Args: + snapshot: The panel snapshot data + description_key: Sensor description key (e.g., "instantGridPowerW") + + Returns: + Unique ID like "span_{serial}_{consistent_suffix}" (uses descriptive consistent names) + + Examples: + span_abc123_grid_power + span_abc123_feed_through_power + span_abc123_dsm_state + + """ + return build_panel_unique_id(snapshot.serial_number, description_key) + + +def construct_switch_unique_id(snapshot: SpanPanelSnapshot, circuit_id: str) -> str: + """Construct unique ID for switch entities using consistent pattern. + + Args: + snapshot: The panel snapshot data + circuit_id: Circuit ID from panel API + + Returns: + Unique ID like "span_{serial}_relay_{circuit_id}" + + Examples: + span_abc123_relay_0dad2f16cd514812ae1807b0457d473e + + """ + return build_switch_unique_id(snapshot.serial_number, circuit_id) + + +def construct_binary_sensor_unique_id(snapshot: SpanPanelSnapshot, description_key: str) -> str: + """Construct unique ID for binary sensor entities using consistent pattern. + + Args: + snapshot: The panel snapshot data + description_key: Sensor description key (e.g., "doorState") + + Returns: + Unique ID like "span_{serial}_{description_key}" + + Examples: + span_abc123_doorState + span_abc123_eth0Link + + """ + return build_binary_sensor_unique_id(snapshot.serial_number, description_key) + + +def construct_select_unique_id(snapshot: SpanPanelSnapshot, select_id: str) -> str: + """Construct unique ID for select entities using consistent pattern. + + Args: + snapshot: The panel snapshot data + select_id: Select entity identifier + + Returns: + Unique ID like "span_{serial}_select_{select_id}" + + Examples: + span_abc123_select_priority_mode + + """ + return build_select_unique_id(snapshot.serial_number, select_id) diff --git a/custom_components/span_panel/manifest.json b/custom_components/span_panel/manifest.json index 02038ba9..022832c9 100644 --- a/custom_components/span_panel/manifest.json +++ b/custom_components/span_panel/manifest.json @@ -5,16 +5,35 @@ "@SpanPanel" ], "config_flow": true, + "dependencies": [ + "diagnostics", + "frontend", + "http", + "panel_custom", + "persistent_notification" + ], "documentation": "https://github.com/SpanPanel/span", - "iot_class": "local_polling", + "integration_type": "device", + "iot_class": "local_push", "issue_tracker": "https://github.com/SpanPanel/span/issues", + "loggers": [ + "custom_components.span_panel", + "span_panel_api" + ], + "quality_scale": "gold", "requirements": [ - "span-panel-api~=1.1.13" + "span-panel-api==2.6.4" ], - "version": "1.2.6", + "version": "2.0.8", "zeroconf": [ { "type": "_span._tcp.local." + }, + { + "type": "_ebus._tcp.local." + }, + { + "type": "_secure-mqtt._tcp.local." } ] } diff --git a/custom_components/span_panel/migration.py b/custom_components/span_panel/migration.py deleted file mode 100644 index b735deec..00000000 --- a/custom_components/span_panel/migration.py +++ /dev/null @@ -1,296 +0,0 @@ -"""Migration logic for SPAN Panel integration. - -Revised approach: -- Migration can be removed after 1.2.x releases since all keys will be normalized -- Normalize unique_ids in the entity registry to helper-format per config entry -- Set a per-entry migration flag for first normal boot to generate YAML and perform registry lookups -""" - -from __future__ import annotations - -import logging -from typing import Any # noqa: F401 (retained for future type hints) - -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er - -from .const import DOMAIN -from .helpers import ( - build_circuit_unique_id, - build_panel_unique_id, - construct_synthetic_unique_id, - get_panel_entity_suffix, -) - -_LOGGER = logging.getLogger(__name__) - - -async def _reconstruct_unique_id_from_entities( - hass: HomeAssistant, - entity_registry: er.EntityRegistry, - config_entry: ConfigEntry, -) -> str | None: - """Reconstruct missing unique_id from existing entity unique_ids.""" - entities = er.async_entries_for_config_entry(entity_registry, config_entry.entry_id) - - device_candidates = set() - - for entity in entities: - if entity.domain == "sensor" and entity.platform == DOMAIN: - # Parse existing entity unique_id: span_{device_identifier}_{remainder} - parts = entity.unique_id.split("_", 2) - if len(parts) >= 2 and parts[0] == "span": - device_id = parts[1] - if device_id and len(device_id) >= 8: # Basic validation for a serial number - device_candidates.add(device_id) - - # If there is only one device candidate, use it - # Multiple devices/panels cannot be recovered because we don't know which one to use - if len(device_candidates) == 1: - device_id = device_candidates.pop() - - # Check if this device is already configured in another entry - existing_entry = hass.config_entries.async_entry_for_domain_unique_id(DOMAIN, device_id) - if existing_entry and existing_entry.entry_id != config_entry.entry_id: - _LOGGER.warning( - "Device '%s' already configured in entry %s. " - "Reconstructing unique_id for entry %s anyway - user can clean up duplicates manually.", - device_id, - existing_entry.entry_id, - config_entry.entry_id, - ) - # Continue with reconstruction anyway - safer than trying to remove which could remove entities for both entries - return device_id - elif len(device_candidates) > 1: - _LOGGER.warning( - "Multiple device candidates found for config entry %s: %s - cannot reconstruct unique_id", - config_entry.entry_id, - device_candidates, - ) - return None - - return None - - -def _normalize_panel_description_key(raw_key: str) -> str: - """Normalize legacy/dotted panel keys to helper description keys. - - Examples: - mainMeterEnergy.producedEnergyWh -> mainMeterEnergyProducedWh - feedthroughEnergy.consumedEnergyWh -> feedthroughEnergyConsumedWh - - """ - - if "." in raw_key: - # Convert dotted to camel-cased, removing duplicate parts for energy sensors - if raw_key.endswith(".producedEnergyWh"): - base = raw_key.replace(".producedEnergyWh", "") - return f"{base}ProducedWh" - elif raw_key.endswith(".consumedEnergyWh"): - base = raw_key.replace(".consumedEnergyWh", "") - return f"{base}ConsumedWh" - else: - # General dot normalization for other cases - left, right = raw_key.split(".", 1) - return f"{left}{right[0].upper()}{right[1:]}" - return raw_key - - -def _compute_normalized_unique_id(raw_unique_id: str) -> str | None: - """Compute helper-format unique_id from an existing raw unique_id. - - This is a simplified version that extracts the device identifier from the unique_id. - For more control, use _compute_normalized_unique_id_with_device. - """ - try: - parts = raw_unique_id.split("_", 2) - if len(parts) < 3 or parts[0] != "span": - return None - device_identifier = parts[1] - return _compute_normalized_unique_id_with_device(raw_unique_id, device_identifier) - except Exception: - return None - - -def _compute_normalized_unique_id_with_device( - raw_unique_id: str, device_identifier: str -) -> str | None: - """Compute helper-format unique_id from an existing raw unique_id using provided device identifier. - - Handles both panel and circuit sensors. Case-insensitive for legacy circuit - API suffixes and correctly distinguishes circuit vs panel forms. - Returns None if the unique_id cannot be parsed. - """ - try: - parts = raw_unique_id.split("_", 2) - if len(parts) < 3 or parts[0] != "span": - return None - # Use the provided device_identifier instead of parsing from the unique_id - remainder = parts[2] - - # Check for solar sensor patterns (any solar sensor → canonical solar format) - if "solar" in remainder: - # Check for power vs energy - if "power" in remainder: - return construct_synthetic_unique_id(device_identifier, "solar_current_power") - if "energy" in remainder: - # Check for produced vs consumed - if "produced" in remainder: - return construct_synthetic_unique_id(device_identifier, "solar_produced_energy") - if "consumed" in remainder: - return construct_synthetic_unique_id(device_identifier, "solar_consumed_energy") - - # If remainder contains an underscore, treat as circuit: {circuit_id}_{api_field} - last_underscore = remainder.rfind("_") - if last_underscore > 0: - circuit_id = remainder[:last_underscore] - raw_api_field = remainder[last_underscore + 1 :] - # Normalize legacy variants in a case-insensitive way - api_key_lc = raw_api_field.replace(".", "").lower() - circuit_map: dict[str, str] = { - "instantpowerw": "instantPowerW", - "power": "instantPowerW", # tolerate already-normalized suffix - "producedenergywh": "producedEnergyWh", - "consumedenergywh": "consumedEnergyWh", - } - canonical_api = circuit_map.get(api_key_lc) - if canonical_api is None: - return None - return build_circuit_unique_id(device_identifier, circuit_id, canonical_api) - - # Check for native sensor keys (camelCase/legacy → snake_case mapping) - native_sensor_map = { - "currentRunConfig": "current_run_config", - "dsmGridState": "dsm_grid_state", - "dsmState": "dsm_state", - "mainRelayState": "main_relay_state", - "softwareVersion": "software_version", - "softwareVer": "software_version", # Legacy mapping - "batteryPercentage": "storage_battery_percentage", - } - - if remainder in native_sensor_map: - # Native sensor: use the snake_case key directly with build_panel_unique_id - snake_case_key = native_sensor_map[remainder] - return build_panel_unique_id(device_identifier, snake_case_key) - - # Panel case: normalize dotted/camel variants to API keys, then use helper - # First normalize dots to camelCase API format - normalized_api_key = _normalize_panel_description_key(remainder) - # Then get the entity suffix using the helper - entity_suffix = get_panel_entity_suffix(normalized_api_key) - # Finally construct the unique_id using the same helper as synthetic sensors - return construct_synthetic_unique_id(device_identifier, entity_suffix) - - except Exception: - return None - - -async def migrate_config_entry_sensors( - hass: HomeAssistant, - config_entry: ConfigEntry, -) -> bool: - """Migrate a single config entry to v2 by normalizing unique_ids and flagging migration. - - - Normalize unique_ids for span_panel sensor entities to helper-format - - Set a per-entry migration flag for first normal boot YAML generation - """ - - # Only migrate if version is less than 2 - if config_entry.version >= 2: - return True - - _LOGGER.info( - "MIGRATION: Normalizing unique_ids for entry %s (version %s) to helper format", - config_entry.entry_id, - config_entry.version, - ) - - try: - # Analyze existing entities for this config entry - entity_registry = er.async_get(hass) - entities = er.async_entries_for_config_entry(entity_registry, config_entry.entry_id) - - updated = 0 - skipped = 0 - # Get the correct device identifier from config entry - device_identifier = config_entry.unique_id - if not device_identifier: - # Try to reconstruct unique_id from existing entities - device_identifier = await _reconstruct_unique_id_from_entities( - hass, entity_registry, config_entry - ) - if device_identifier: - _LOGGER.info( - "Reconstructed missing unique_id for config entry %s: %s", - config_entry.entry_id, - device_identifier, - ) - # Update the config entry with reconstructed unique_id - hass.config_entries.async_update_entry(config_entry, unique_id=device_identifier) - else: - _LOGGER.warning( - "Config entry %s has no unique_id and cannot reconstruct - cleaning up invalid configuration", - config_entry.entry_id, - ) - return False - - _LOGGER.debug( - "MIGRATION: Using device_identifier=%s for entry %s (from config_entry.unique_id)", - device_identifier, - config_entry.entry_id, - ) - - for entity in entities: - if entity.domain != "sensor" or entity.platform != DOMAIN: - continue - raw_uid = entity.unique_id - new_uid = _compute_normalized_unique_id_with_device(raw_uid, device_identifier) - if not new_uid: - skipped += 1 - continue - if new_uid != raw_uid: - try: - entity_registry.async_update_entity(entity.entity_id, new_unique_id=new_uid) - updated += 1 - except Exception as e: - _LOGGER.warning("Failed to update unique_id for %s: %s", entity.entity_id, e) - - _LOGGER.info( - "MIGRATION: Normalized %d unique_ids (skipped %d) for entry %s", - updated, - skipped, - config_entry.entry_id, - ) - - # Set per-entry migration flag for first normal boot (transient and persisted) - hass.data.setdefault(DOMAIN, {}).setdefault(config_entry.entry_id, {})["migration_mode"] = ( - True - ) - try: - # Persist flag in options so it survives reboots - new_options = dict(config_entry.options) - new_options["migration_mode"] = True - hass.config_entries.async_update_entry(config_entry, options=new_options) - _LOGGER.info( - "MIGRATION: Set per-entry migration_mode option for entry %s", - config_entry.entry_id, - ) - except Exception as opt_err: - _LOGGER.warning( - "MIGRATION: Failed to persist migration_mode option for entry %s: %s", - config_entry.entry_id, - opt_err, - ) - - return True - - except Exception as e: - _LOGGER.error( - "Migration error for entry %s: %s", - config_entry.entry_id, - e, - exc_info=True, - ) - return False diff --git a/custom_components/span_panel/migration_utils.py b/custom_components/span_panel/migration_utils.py deleted file mode 100644 index 09b95bc5..00000000 --- a/custom_components/span_panel/migration_utils.py +++ /dev/null @@ -1,84 +0,0 @@ -"""Utilities for migration from v1 to v2 synthetic sensors.""" - -import logging -from typing import Any - - -def classify_sensor_from_unique_id(unique_id: str) -> tuple[str, str, str]: - """Classify sensor from unique ID for migration purposes. - - Args: - unique_id: The existing unique ID from the entity registry - - Returns: - (category, sensor_type, api_key) - category: 'generic' or 'solar' - sensor_type: 'power', 'energy_produced', 'energy_consumed' - api_key: the API field key for finding the right sensor definition - - """ - unique_id_lower = unique_id.lower() - - # Check if it's solar (includes both 'solar' and 'inverter' patterns) - if "solar" in unique_id_lower or "inverter" in unique_id_lower: - if "power" in unique_id_lower: - return "solar", "power", "solar_power" - elif "produced" in unique_id_lower or "energy" in unique_id_lower: - if "consumed" in unique_id_lower: - return "solar", "energy_consumed", "solar_energy_consumed" - else: - return "solar", "energy_produced", "solar_energy_produced" - - # Generic (non-solar): determine type by keywords only - if "power" in unique_id_lower: - return "generic", "power", "instantPowerW" - if "consumed" in unique_id_lower: - return "generic", "energy_consumed", "consumedEnergyWh" - if "produced" in unique_id_lower or "energy" in unique_id_lower: - # Default energy to produced when unclear - return "generic", "energy_produced", "producedEnergyWh" - - raise ValueError(f"Cannot classify sensor type from unique_id: {unique_id}") - - -def group_existing_sensors_by_category( - sensor_entities: list[dict[str, Any]], -) -> tuple[dict[str, str], dict[str, str]]: - """Group existing sensor entities by category for migration. - - Args: - sensor_entities: List of sensor entity dicts from entity registry - - Returns: - (generic_mappings, solar_mappings) - Each mapping is {unique_id: entity_id} - - """ - generic_mappings: dict[str, str] = {} - solar_mappings: dict[str, str] = {} - - for sensor_entity in sensor_entities: - unique_id = sensor_entity["unique_id"] - entity_id = sensor_entity["entity_id"] - - # Skip unmapped tab entities - these are native backing entities, not synthetic sensors - if "unmapped_tab_" in unique_id: - logger = logging.getLogger(__name__) - logger.debug("Skipping unmapped tab entity (native backing entity): %s", entity_id) - continue - - try: - category, sensor_type, api_key = classify_sensor_from_unique_id(unique_id) - - if category == "generic": - generic_mappings[unique_id] = entity_id - elif category == "solar": - solar_mappings[unique_id] = entity_id - - except ValueError as e: - # Log but don't fail - skip sensors we can't classify - logger = logging.getLogger(__name__) - logger.warning("Failed to classify sensor for migration: %s", e) - continue - - return generic_mappings, solar_mappings diff --git a/custom_components/span_panel/migrations.py b/custom_components/span_panel/migrations.py new file mode 100644 index 00000000..2e6f61e6 --- /dev/null +++ b/custom_components/span_panel/migrations.py @@ -0,0 +1,127 @@ +"""Config entry migration logic for the Span Panel integration.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .const import CONF_API_VERSION + +if TYPE_CHECKING: + from . import SpanPanelConfigEntry + +_LOGGER = logging.getLogger(__name__) + +# Must match the storage version produced by the latest supported entry format. +CURRENT_CONFIG_VERSION = 6 + + +async def async_migrate_entry(hass: HomeAssistant, config_entry: SpanPanelConfigEntry) -> bool: + """Migrate config entry through successive versions. + + Supports upgrades from v1.3.1+ (config version 2) through to the + current version 6. Each step mutates only the fields relevant to + that version boundary. + """ + if config_entry.version >= CURRENT_CONFIG_VERSION: + return True + + _LOGGER.debug( + "Migrating config entry %s from version %s to %s", + config_entry.entry_id, + config_entry.version, + CURRENT_CONFIG_VERSION, + ) + + # --- v2 → v3: add api_version field --- + if config_entry.version < 3: + updated_data = dict(config_entry.data) + + if updated_data.get("simulation_mode", False): + updated_data[CONF_API_VERSION] = "simulation" + else: + updated_data[CONF_API_VERSION] = "v1" + + hass.config_entries.async_update_entry( + config_entry, + data=updated_data, + options=config_entry.options, + title=config_entry.title, + version=3, + ) + _LOGGER.debug("Migrated config entry %s to version 3", config_entry.entry_id) + + # --- v3 → v4: solar migration flag + remove legacy solar/retry options --- + if config_entry.version < 4: + updated_options = dict(config_entry.options) + updated_data = dict(config_entry.data) + + # Check if user had solar configured under v1 options layout + solar_was_enabled = updated_options.pop("enable_solar_circuit", False) + updated_options.pop("leg1", None) + updated_options.pop("leg2", None) + + if solar_was_enabled: + # PV circuit UUID is only known at runtime (from MQTT data), + # so defer entity registry update to first coordinator refresh. + updated_data["solar_migration_pending"] = True + _LOGGER.info( + "Solar was configured — setting solar_migration_pending flag " + "for runtime entity registry migration" + ) + + # Remove v1 REST retry options (no longer applicable) + for key in ("api_retries", "api_retry_timeout", "api_retry_backoff_multiplier"): + updated_options.pop(key, None) + + hass.config_entries.async_update_entry( + config_entry, + data=updated_data, + options=updated_options, + version=4, + ) + _LOGGER.debug("Migrated config entry %s to version 4", config_entry.entry_id) + + # --- v4 → v5: remove wwanLink binary sensor --- + if config_entry.version < 5: + entity_registry = er.async_get(hass) + entities = er.async_entries_for_config_entry(entity_registry, config_entry.entry_id) + + removed = 0 + for entity in entities: + if entity.domain == "binary_sensor" and entity.unique_id.endswith("_wwanLink"): + entity_registry.async_remove(entity.entity_id) + _LOGGER.info("Removed deprecated wwanLink binary sensor: %s", entity.entity_id) + removed += 1 + + if removed: + _LOGGER.info("v4→v5 migration: removed %d deprecated entities", removed) + + hass.config_entries.async_update_entry( + config_entry, + version=5, + ) + _LOGGER.debug("Migrated config entry %s to version 5", config_entry.entry_id) + + # --- v5 → v6: bump version --- + if config_entry.version < 6: + if config_entry.data.get(CONF_API_VERSION) == "simulation" or config_entry.data.get( + "simulation_mode", False + ): + _LOGGER.warning( + "Config entry '%s' is a built-in simulation entry which is no " + "longer supported. Please remove it manually from Settings > " + "Devices & Services", + config_entry.title, + ) + + hass.config_entries.async_update_entry( + config_entry, + version=6, + ) + _LOGGER.debug("Migrated config entry %s to version 6", config_entry.entry_id) + + return True diff --git a/custom_components/span_panel/options.py b/custom_components/span_panel/options.py index 09380e79..232afd03 100644 --- a/custom_components/span_panel/options.py +++ b/custom_components/span_panel/options.py @@ -1,100 +1,16 @@ """Option configurations.""" -from datetime import datetime -from typing import Any - -from homeassistant.config_entries import ConfigEntry - -from .const import ( - CONF_API_RETRIES, - CONF_API_RETRY_BACKOFF_MULTIPLIER, - CONF_API_RETRY_TIMEOUT, - CONF_SIMULATION_START_TIME, - DEFAULT_API_RETRIES, - DEFAULT_API_RETRY_BACKOFF_MULTIPLIER, - DEFAULT_API_RETRY_TIMEOUT, - ENABLE_CIRCUIT_NET_ENERGY_SENSORS, - ENABLE_PANEL_NET_ENERGY_SENSORS, - ENABLE_SOLAR_NET_ENERGY_SENSORS, -) - -INVERTER_ENABLE = "enable_solar_circuit" -INVERTER_LEG1 = "leg1" -INVERTER_LEG2 = "leg2" -INVERTER_MAXLEG = 32 BATTERY_ENABLE = "enable_battery_percentage" POWER_DISPLAY_PRECISION = "power_display_precision" ENERGY_DISPLAY_PRECISION = "energy_display_precision" ENERGY_REPORTING_GRACE_PERIOD = "energy_reporting_grace_period" - - -class Options: - """Class representing the options like the solar inverter.""" - - # pylint: disable=R0903 - - def __init__(self, entry: ConfigEntry) -> None: - """Initialize the options.""" - self.enable_solar_sensors: bool = entry.options.get(INVERTER_ENABLE, False) - self.inverter_leg1: int = entry.options.get(INVERTER_LEG1, 0) - self.inverter_leg2: int = entry.options.get(INVERTER_LEG2, 0) - self.enable_battery_percentage: bool = entry.options.get(BATTERY_ENABLE, False) - self.power_display_precision: int = entry.options.get(POWER_DISPLAY_PRECISION, 0) - self.energy_display_precision: int = entry.options.get(ENERGY_DISPLAY_PRECISION, 2) - self.energy_reporting_grace_period: int = entry.options.get( - ENERGY_REPORTING_GRACE_PERIOD, 15 - ) - self.enable_panel_net_energy_sensors: bool = entry.options.get( - ENABLE_PANEL_NET_ENERGY_SENSORS, True - ) - self.enable_circuit_net_energy_sensors: bool = entry.options.get( - ENABLE_CIRCUIT_NET_ENERGY_SENSORS, True - ) - self.enable_solar_net_energy_sensors: bool = entry.options.get( - ENABLE_SOLAR_NET_ENERGY_SENSORS, True - ) - - # API retry configuration options - self.api_retries: int = int(entry.options.get(CONF_API_RETRIES, DEFAULT_API_RETRIES)) - self.api_retry_timeout: float = float( - entry.options.get(CONF_API_RETRY_TIMEOUT, str(DEFAULT_API_RETRY_TIMEOUT)) - ) - self.api_retry_backoff_multiplier: float = float( - entry.options.get( - CONF_API_RETRY_BACKOFF_MULTIPLIER, DEFAULT_API_RETRY_BACKOFF_MULTIPLIER - ) - ) - - # Simulation time configuration - simulation_start_time_str = entry.options.get(CONF_SIMULATION_START_TIME) - self.simulation_start_time: datetime | None = None - if simulation_start_time_str: - try: - self.simulation_start_time = datetime.fromisoformat(simulation_start_time_str) - except (ValueError, TypeError): - # If parsing fails, use None (current time) - self.simulation_start_time = None - - def get_options(self) -> dict[str, Any]: - """Return the current options as a dictionary.""" - options: dict[str, Any] = { - INVERTER_ENABLE: self.enable_solar_sensors, - INVERTER_LEG1: self.inverter_leg1, - INVERTER_LEG2: self.inverter_leg2, - BATTERY_ENABLE: self.enable_battery_percentage, - POWER_DISPLAY_PRECISION: self.power_display_precision, - ENERGY_DISPLAY_PRECISION: self.energy_display_precision, - ENERGY_REPORTING_GRACE_PERIOD: self.energy_reporting_grace_period, - ENABLE_PANEL_NET_ENERGY_SENSORS: self.enable_panel_net_energy_sensors, - ENABLE_CIRCUIT_NET_ENERGY_SENSORS: self.enable_circuit_net_energy_sensors, - ENABLE_SOLAR_NET_ENERGY_SENSORS: self.enable_solar_net_energy_sensors, - CONF_API_RETRIES: self.api_retries, - CONF_API_RETRY_TIMEOUT: self.api_retry_timeout, - CONF_API_RETRY_BACKOFF_MULTIPLIER: self.api_retry_backoff_multiplier, - } - - # Add simulation start time if set - if self.simulation_start_time is not None: - options[CONF_SIMULATION_START_TIME] = self.simulation_start_time.isoformat() - - return options +SNAPSHOT_UPDATE_INTERVAL = "snapshot_update_interval" + +CONTINUOUS_THRESHOLD_PCT = "continuous_threshold_pct" +SPIKE_THRESHOLD_PCT = "spike_threshold_pct" +WINDOW_DURATION_M = "window_duration_m" +COOLDOWN_DURATION_M = "cooldown_duration_m" +NOTIFY_TARGETS = "notify_targets" +NOTIFICATION_TITLE_TEMPLATE = "notification_title_template" +NOTIFICATION_MESSAGE_TEMPLATE = "notification_message_template" +NOTIFICATION_PRIORITY = "notification_priority" diff --git a/custom_components/span_panel/quality_scale.yaml b/custom_components/span_panel/quality_scale.yaml new file mode 100644 index 00000000..d4e56eeb --- /dev/null +++ b/custom_components/span_panel/quality_scale.yaml @@ -0,0 +1,113 @@ +rules: + # Bronze + action-setup: done + appropriate-polling: done + brands: + status: exempt + comment: > + Draft PR home-assistant/brands#10018 adds core_integrations/span_panel; + will flip to done when merged. + common-modules: done + config-flow: done + config-flow-test-coverage: done + dependency-transparency: done + docs-actions: + status: exempt + comment: > + Service and WebSocket documentation will be added in the + home-assistant.io integration page PR (#44265). + docs-high-level-description: + status: exempt + comment: Covered in the home-assistant.io integration page PR (#44265). + docs-installation-instructions: + status: exempt + comment: Covered in the home-assistant.io integration page PR (#44265). + docs-removal-instructions: + status: exempt + comment: Covered in the home-assistant.io integration page PR (#44265). + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: done + comment: > + export_circuit_manifest is response-only; relay/priority actions raise + translated HomeAssistantError via exception-translations. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: Covered in the home-assistant.io integration page PR (#44265). + docs-installation-parameters: + status: exempt + comment: Covered in the home-assistant.io integration page PR (#44265). + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: done + test-coverage: done + + # Gold + devices: done + diagnostics: done + discovery: + status: done + comment: Zeroconf discovery via _span._tcp.local., _ebus._tcp.local., _secure-mqtt._tcp.local. + discovery-update-info: + status: done + comment: Network info updated from zeroconf discovery data. + docs-data-update: + status: exempt + comment: Covered in the home-assistant.io integration page PR (#44265). + docs-examples: + status: exempt + comment: Covered in the home-assistant.io integration page PR (#44265). + docs-known-limitations: + status: exempt + comment: Covered in the home-assistant.io integration page PR (#44265). + docs-supported-devices: + status: exempt + comment: Covered in the home-assistant.io integration page PR (#44265). + docs-supported-functions: + status: exempt + comment: Covered in the home-assistant.io integration page PR (#44265). + docs-troubleshooting: + status: exempt + comment: Covered in the home-assistant.io integration page PR (#44265). + docs-use-cases: + status: exempt + comment: Covered in the home-assistant.io integration page PR (#44265). + dynamic-devices: + status: done + comment: > + BESS, PV, and EVSE sub-devices created dynamically when hardware is + commissioned; coordinator triggers reload on new capability detection. + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: done + icon-translations: done + reconfiguration-flow: done + repair-issues: + status: exempt + comment: No actionable repair scenarios identified for this integration. + stale-devices: done + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: + status: todo + comment: > + Integration passes mypy strict and py.typed is included in built + wheels. Hassfest rejects the editable dev install because the + RECORD does not list py.typed. Will pass in CI from PyPI install. + Flip to done and manifest to platinum after next span-panel-api + release. diff --git a/custom_components/span_panel/schema_expectations.py b/custom_components/span_panel/schema_expectations.py new file mode 100644 index 00000000..ed4d2050 --- /dev/null +++ b/custom_components/span_panel/schema_expectations.py @@ -0,0 +1,102 @@ +"""Sensor-to-snapshot-field mapping for schema validation. + +Maps integration sensor definition keys to snapshot field paths. This is the +integration's declaration of which snapshot fields it reads, expressed in +transport-agnostic terms. + +The integration does NOT know about Homie, MQTT, node types, or property IDs. +The ``span-panel-api`` library owns that knowledge and exposes field-level +metadata keyed by snapshot field paths. This module bridges from sensor +definitions (HA side) to field paths (library side). + +Field path convention: ``{snapshot_type}.{field_name}`` + - ``panel`` — SpanPanelSnapshot fields + - ``circuit`` — SpanCircuitSnapshot fields + - ``battery`` — SpanBatterySnapshot fields + - ``pv`` — SpanPVSnapshot fields + - ``evse`` — SpanEvseSnapshot fields + +Derived sensors (net energy, dsm_state, current_run_config) that compute +values from multiple fields have no single source field and are excluded. +""" + +from __future__ import annotations + +# --------------------------------------------------------------------------- +# Sensor definition key → snapshot field path +# +# Every sensor the integration creates that reads a single snapshot field +# should appear here. The sensor definition provides the HA unit; the +# library's field metadata provides the schema-declared unit. The validation +# module compares them. +# +# Entries are grouped by snapshot type for readability. +# --------------------------------------------------------------------------- + +SENSOR_FIELD_MAP: dict[str, str] = { + # --- Panel power sensors ------------------------------------------------- + "instantGridPowerW": "panel.instant_grid_power_w", + "feedthroughPowerW": "panel.feedthrough_power_w", + "batteryPowerW": "panel.power_flow_battery", + "pvPowerW": "panel.power_flow_pv", + "gridPowerFlowW": "panel.power_flow_grid", + "sitePowerW": "panel.power_flow_site", + # --- Panel energy sensors ------------------------------------------------ + "mainMeterEnergyProducedWh": "panel.main_meter_energy_produced_wh", + "mainMeterEnergyConsumedWh": "panel.main_meter_energy_consumed_wh", + "feedthroughEnergyProducedWh": "panel.feedthrough_energy_produced_wh", + "feedthroughEnergyConsumedWh": "panel.feedthrough_energy_consumed_wh", + # --- Panel diagnostic sensors -------------------------------------------- + "l1_voltage": "panel.l1_voltage", + "l2_voltage": "panel.l2_voltage", + "upstream_l1_current": "panel.upstream_l1_current_a", + "upstream_l2_current": "panel.upstream_l2_current_a", + "downstream_l1_current": "panel.downstream_l1_current_a", + "downstream_l2_current": "panel.downstream_l2_current_a", + "main_breaker_rating": "panel.main_breaker_rating_a", + # --- Panel status sensors (enum/string — no unit, but tracked) ----------- + "main_relay_state": "panel.main_relay_state", + "grid_forming_entity": "panel.dominant_power_source", + "vendor_cloud": "panel.vendor_cloud", + "software_version": "panel.firmware_version", + # --- Circuit sensors ----------------------------------------------------- + "circuit_power": "circuit.instant_power_w", + "circuit_energy_produced": "circuit.produced_energy_wh", + "circuit_energy_consumed": "circuit.consumed_energy_wh", + "circuit_current": "circuit.current_a", + "circuit_breaker_rating": "circuit.breaker_rating_a", + # --- Unmapped circuit sensors (same fields, different sensor keys) -------- + "instantPowerW": "circuit.instant_power_w", + "producedEnergyWh": "circuit.produced_energy_wh", + "consumedEnergyWh": "circuit.consumed_energy_wh", + # --- Battery sensors ----------------------------------------------------- + "storage_battery_percentage": "battery.soe_percentage", + "nameplate_capacity": "battery.nameplate_capacity_kwh", + "soe_kwh": "battery.soe_kwh", + # --- BESS metadata sensors ----------------------------------------------- + "vendor": "battery.vendor_name", + "model": "battery.product_name", + "serial_number": "battery.serial_number", + "firmware_version": "battery.software_version", + # --- PV metadata sensors ------------------------------------------------- + "pv_vendor": "pv.vendor_name", + "pv_product": "pv.product_name", + "pv_nameplate_capacity": "pv.nameplate_capacity_w", + # --- EVSE sensors -------------------------------------------------------- + "evse_status": "evse.status", + "evse_advertised_current": "evse.advertised_current_a", + "evse_lock_state": "evse.lock_state", +} + +# Derived sensors excluded from the map (computed from multiple fields): +# dsm_state — multi-signal heuristic +# dsm_grid_state — deprecated alias for dsm_state +# current_run_config — tri-state derivation +# mainMeterNetEnergyWh — consumed_wh - produced_wh +# feedthroughNetEnergyWh — consumed_wh - produced_wh +# circuit_energy_net — consumed_wh - produced_wh (or inverse for PV) + + +def all_referenced_field_paths() -> frozenset[str]: + """Return the set of all snapshot field paths referenced by any sensor.""" + return frozenset(SENSOR_FIELD_MAP.values()) diff --git a/custom_components/span_panel/schema_validation.py b/custom_components/span_panel/schema_validation.py new file mode 100644 index 00000000..34febe52 --- /dev/null +++ b/custom_components/span_panel/schema_validation.py @@ -0,0 +1,176 @@ +"""Schema validation — cross-check field metadata against sensor definitions. + +Compares the ``span-panel-api`` library's field metadata (schema-derived units +and datatypes keyed by snapshot field paths) against the integration's sensor +definitions. All Homie/MQTT knowledge stays in the library; this module only +sees snapshot field paths and HA sensor metadata. + +Schema drift detection (diffing schema versions between firmware updates) is +the library's responsibility. The integration only consumes the result. + +All output is log-only. No entity creation or sensor behavior changes. + +Phase 1 of the schema-driven changes plan. + +Usage: + Called from the coordinator after the first successful data refresh. + Requires ``span-panel-api`` to expose field metadata via the client protocol. + Until that library change lands, ``validate_field_metadata()`` is a safe no-op. +""" + +from __future__ import annotations + +import logging + +from homeassistant.components.sensor import SensorEntityDescription + +from .schema_expectations import SENSOR_FIELD_MAP, all_referenced_field_paths +from .sensor_definitions import ( + BATTERY_POWER_SENSOR, + BATTERY_SENSOR, + BESS_METADATA_SENSORS, + CIRCUIT_BREAKER_RATING_SENSOR, + CIRCUIT_CURRENT_SENSOR, + CIRCUIT_SENSORS, + DOWNSTREAM_L1_CURRENT_SENSOR, + DOWNSTREAM_L2_CURRENT_SENSOR, + EVSE_SENSORS, + GRID_POWER_FLOW_SENSOR, + L1_VOLTAGE_SENSOR, + L2_VOLTAGE_SENSOR, + MAIN_BREAKER_RATING_SENSOR, + PANEL_DATA_STATUS_SENSORS, + PANEL_ENERGY_SENSORS, + PANEL_POWER_SENSORS, + PV_METADATA_SENSORS, + PV_POWER_SENSOR, + SITE_POWER_SENSOR, + STATUS_SENSORS, + UNMAPPED_SENSORS, + UPSTREAM_L1_CURRENT_SENSOR, + UPSTREAM_L2_CURRENT_SENSOR, +) + +_LOGGER = logging.getLogger(__name__) + + +def _cross_check_units( + field_metadata: dict[str, dict[str, object]], + sensor_defs: dict[str, SensorEntityDescription], +) -> None: + """Compare library-reported units against sensor definition units. + + For each sensor in SENSOR_FIELD_MAP that has a ``native_unit_of_measurement``, + look up the corresponding field path in the library's metadata and compare + the declared unit. + """ + for sensor_key, field_path in SENSOR_FIELD_MAP.items(): + sensor_def = sensor_defs.get(sensor_key) + if sensor_def is None: + continue + + ha_unit = sensor_def.native_unit_of_measurement + if ha_unit is None: + # Sensor has no unit (enum, string) — nothing to cross-check + continue + + field_info = field_metadata.get(field_path) + if field_info is None: + _LOGGER.debug( + "Schema cross-check: sensor '%s' reads field '%s' but " + "library reports no metadata for it", + sensor_key, + field_path, + ) + continue + + schema_unit = field_info.get("unit") + if schema_unit is None: + _LOGGER.debug( + "Schema cross-check: field '%s' (sensor '%s') has no unit " + "in library metadata, integration expects '%s'", + field_path, + sensor_key, + ha_unit, + ) + elif str(schema_unit) != str(ha_unit): + _LOGGER.debug( + "Schema cross-check: field '%s' (sensor '%s') unit is '%s' " + "in library metadata, integration expects '%s'", + field_path, + sensor_key, + schema_unit, + ha_unit, + ) + + +def _report_unmapped_fields( + field_metadata: dict[str, dict[str, object]], +) -> None: + """Log fields in library metadata that no sensor definition references.""" + referenced = all_referenced_field_paths() + for field_path in sorted(set(field_metadata) - referenced): + _LOGGER.debug( + "Schema: field '%s' in library metadata is not mapped to any sensor", + field_path, + ) + + +def validate_field_metadata( + field_metadata: dict[str, dict[str, object]] | None, + sensor_defs: dict[str, SensorEntityDescription] | None = None, +) -> None: + """Run integration-side schema validation checks. + + Args: + field_metadata: The library's field metadata, keyed by snapshot field + path (e.g. ``"panel.instant_grid_power_w"``). Each value is a dict + with at least ``"unit"`` and ``"datatype"`` keys. None if the + library does not yet expose metadata. + sensor_defs: Dict of sensor_key → SensorEntityDescription for unit + cross-checking. None skips the cross-check. + + """ + if field_metadata is None: + _LOGGER.debug("Schema validation skipped — library does not expose field metadata") + return + + if sensor_defs is not None: + _cross_check_units(field_metadata, sensor_defs) + + _report_unmapped_fields(field_metadata) + + +def collect_sensor_definitions() -> dict[str, SensorEntityDescription]: + """Collect all sensor definitions into a dict keyed by sensor key. + + Only includes sensors that appear in SENSOR_FIELD_MAP (i.e. sensors + that read a single snapshot field and are eligible for cross-checking). + """ + all_defs: list[SensorEntityDescription] = [ + *PANEL_DATA_STATUS_SENSORS, + *STATUS_SENSORS, + *UNMAPPED_SENSORS, + BATTERY_SENSOR, + L1_VOLTAGE_SENSOR, + L2_VOLTAGE_SENSOR, + UPSTREAM_L1_CURRENT_SENSOR, + UPSTREAM_L2_CURRENT_SENSOR, + DOWNSTREAM_L1_CURRENT_SENSOR, + DOWNSTREAM_L2_CURRENT_SENSOR, + MAIN_BREAKER_RATING_SENSOR, + CIRCUIT_CURRENT_SENSOR, + CIRCUIT_BREAKER_RATING_SENSOR, + *BESS_METADATA_SENSORS, + *PV_METADATA_SENSORS, + *PANEL_POWER_SENSORS, + BATTERY_POWER_SENSOR, + PV_POWER_SENSOR, + GRID_POWER_FLOW_SENSOR, + SITE_POWER_SENSOR, + *PANEL_ENERGY_SENSORS, + *CIRCUIT_SENSORS, + *EVSE_SENSORS, + ] + mapped_keys = set(SENSOR_FIELD_MAP.keys()) + return {d.key: d for d in all_defs if d.key in mapped_keys} diff --git a/custom_components/span_panel/select.py b/custom_components/span_panel/select.py index be4e9133..30f54042 100644 --- a/custom_components/span_panel/select.py +++ b/custom_components/span_panel/select.py @@ -1,38 +1,49 @@ """Select entity for the Span Panel.""" -from collections.abc import Callable +from collections.abc import Callable, Mapping import logging from typing import Any, Final from homeassistant.components.select import SelectEntity, SelectEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceNotFound from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from span_panel_api import SpanCircuitSnapshot, SpanPanelSnapshot from span_panel_api.exceptions import SpanPanelServerError -from .const import ( - COORDINATOR, - DOMAIN, - USE_CIRCUIT_NUMBERS, - USE_DEVICE_PREFIX, - CircuitPriority, -) +from . import SpanPanelConfigEntry +from .const import DOMAIN, USE_CIRCUIT_NUMBERS, CircuitPriority from .coordinator import SpanPanelCoordinator +from .entity import SpanPanelEntity from .helpers import ( async_create_span_notification, build_select_unique_id_for_entry, + construct_circuit_identifier_from_tabs, + construct_single_circuit_entity_id, + construct_tabs_attribute, + construct_voltage_attribute, ) -from .span_panel import SpanPanel -from .span_panel_circuit import SpanPanelCircuit -from .util import panel_to_device_info -ICON = "mdi:chevron-down" +# Device types that use "Solar" as the fallback identifier when unnamed. +_SOLAR_DEVICE_TYPES: frozenset[str] = frozenset({"pv"}) + + +def _unnamed_select_fallback(circuit: SpanCircuitSnapshot, circuit_id: str) -> str: + """Return a descriptive identifier for an unnamed circuit select.""" + if getattr(circuit, "device_type", "circuit") in _SOLAR_DEVICE_TYPES: + return "Solar" + return construct_circuit_identifier_from_tabs(circuit.tabs, circuit_id) + _LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 1 + +# Sentinel value to distinguish "never synced" from "circuit name is None" +_NAME_UNSET: object = object() + class SpanPanelSelectEntityDescriptionWrapper: """Wrapper class for Span Panel Select entities.""" @@ -40,19 +51,23 @@ class SpanPanelSelectEntityDescriptionWrapper: # The wrapper is required because the SelectEntityDescription is frozen # and we need to pass in the entity_description to the constructor # Using keyword arguments gives a warning about unexpected arguments - # pylint: disable=R0903 + # pylint: disable=too-few-public-methods def __init__( self, key: str, name: str, - icon: str, - options_fn: Callable[[SpanPanelCircuit], list[str]] = lambda _: [], - current_option_fn: Callable[[SpanPanelCircuit], str | None] = lambda _: None, - select_option_fn: Callable[[SpanPanelCircuit, str], None] | None = None, + options_fn: Callable[[SpanCircuitSnapshot], list[str]] = lambda _: [], + current_option_fn: Callable[[SpanCircuitSnapshot], str | None] = lambda _: None, + select_option_fn: Callable[[SpanCircuitSnapshot, str], None] | None = None, ) -> None: """Initialize the select entity description wrapper.""" - self.entity_description = SelectEntityDescription(key=key, name=name, icon=icon) + self.entity_description = SelectEntityDescription( + key=key, + name=name, + translation_key=key, + entity_category=EntityCategory.CONFIG, + ) self.options_fn = options_fn self.current_option_fn = current_option_fn self.select_option_fn = select_option_fn @@ -61,17 +76,14 @@ def __init__( CIRCUIT_PRIORITY_DESCRIPTION: Final = SpanPanelSelectEntityDescriptionWrapper( key="circuit_priority", name="Circuit Priority", - icon=ICON, options_fn=lambda _: [e.value for e in CircuitPriority if e != CircuitPriority.UNKNOWN], current_option_fn=lambda circuit: CircuitPriority[circuit.priority].value, ) -class SpanPanelCircuitsSelect(CoordinatorEntity[SpanPanelCoordinator], SelectEntity): +class SpanPanelCircuitsSelect(SpanPanelEntity, SelectEntity): """Represent a select entity for Span Panel circuits.""" - _attr_has_entity_name = True - def __init__( self, coordinator: SpanPanelCoordinator, @@ -82,20 +94,20 @@ def __init__( ) -> None: """Initialize the select.""" super().__init__(coordinator) - span_panel: SpanPanel = coordinator.data + snapshot: SpanPanelSnapshot = coordinator.data - # Get the circuit from the span_panel to access its properties - circuit = span_panel.circuits.get(circuit_id) + circuit = snapshot.circuits.get(circuit_id) if not circuit: raise ValueError(f"Circuit {circuit_id} not found") self.entity_description = description.entity_description - self.description_wrapper = description # Keep reference to wrapper for custom functions + self.description_wrapper = description self.id = circuit_id self._device_name = device_name - self._attr_unique_id = self._construct_select_unique_id(coordinator, span_panel, self.id) - self._attr_device_info = panel_to_device_info(span_panel, device_name) + self._attr_unique_id = self._construct_select_unique_id(coordinator, snapshot, self.id) + + self._attr_device_info = self._build_device_info(coordinator, snapshot) # Check if entity already exists in registry entity_registry = er.async_get(coordinator.hass) @@ -103,71 +115,102 @@ def __init__( "select", DOMAIN, self._attr_unique_id ) - if existing_entity_id: - # Entity exists - always use panel name for sync - circuit_identifier = circuit.name - self._attr_name = f"{circuit_identifier} {description.entity_description.name}" - else: - # Initial install - use flag-based name for entity_id generation - use_circuit_numbers = coordinator.config_entry.options.get(USE_CIRCUIT_NUMBERS, False) + use_circuit_numbers = coordinator.config_entry.options.get(USE_CIRCUIT_NUMBERS, False) + desc_name = description.entity_description.name + if existing_entity_id: + # Entity exists - use circuit-based name when configured, else panel name if use_circuit_numbers: - # Use circuit number format: "Circuit 15 Priority" - if circuit.tabs and len(circuit.tabs) == 2: - sorted_tabs = sorted(circuit.tabs) - circuit_identifier = f"Circuit {sorted_tabs[0]} {sorted_tabs[1]}" - elif circuit.tabs and len(circuit.tabs) == 1: - circuit_identifier = f"Circuit {circuit.tabs[0]}" - else: - circuit_identifier = f"Circuit {circuit_id}" + circuit_identifier = construct_circuit_identifier_from_tabs( + circuit.tabs, circuit_id + ) + self._attr_name = f"{circuit_identifier} {desc_name}" + elif circuit.name: + self._attr_name = f"{circuit.name} {desc_name}" else: - # Use friendly name format: "Kitchen Outlets Priority" - circuit_identifier = name + fallback = _unnamed_select_fallback(circuit, circuit_id) + self._attr_name = f"{fallback} {desc_name}" + + # Sync the panel friendly name to the entity registry display name + # so the UI shows e.g. "Air Conditioner Circuit Priority" while the + # entity_id stays circuit-based. + if existing_entity_id and use_circuit_numbers and circuit.name: + entity_entry = entity_registry.async_get(existing_entity_id) + if entity_entry: + expected_name = f"{circuit.name} {desc_name}" + if entity_entry.name is None or entity_entry.name == expected_name: + entity_registry.async_update_entity(existing_entity_id, name=expected_name) - self._attr_name = f"{circuit_identifier} {description.entity_description.name}" + if not existing_entity_id: + # Initial install - use flag-based name for entity_id generation + if use_circuit_numbers: + circuit_identifier = construct_circuit_identifier_from_tabs( + circuit.tabs, circuit_id + ) + self._attr_name = f"{circuit_identifier} {desc_name}" + elif name: + self._attr_name = f"{name} {desc_name}" + else: + # v1 behavior: None lets HA handle default naming + self._attr_name = None + + # Explicitly set entity_id using construct_single_circuit_entity_id + # which correctly handles 240V two-tab circuits. + # Only pass unique_id for existing entities (registry lookup); + # for new entities pass None to get the constructed default. + constructed_id = construct_single_circuit_entity_id( + coordinator, + snapshot, + "select", + description.entity_description.key, + circuit, + unique_id=self._attr_unique_id if existing_entity_id else None, + ) + if constructed_id: + self.entity_id = constructed_id - circuit = self._get_circuit() self._attr_options = description.options_fn(circuit) self._attr_current_option = description.current_option_fn(circuit) - # Store initial circuit name for change detection in auto-sync of names - # Only set to None if entity doesn't exist in registry (true first time) + # Store initial circuit name for change detection in auto-sync if not existing_entity_id: - self._previous_circuit_name = None + self._previous_circuit_name: str | None | object = _NAME_UNSET _LOGGER.info("Select entity not in registry, will sync on first update") else: self._previous_circuit_name = circuit.name _LOGGER.info( - "Select entity exists in registry, previous name set to '%s'", circuit.name + "Select entity exists in registry, previous name set to '%s'", + circuit.name, ) - # Use standard coordinator pattern - entities will update automatically - # when coordinator data changes - - def _get_circuit(self) -> SpanPanelCircuit: - """Get the circuit for this entity.""" - circuit = self.coordinator.data.circuits[self.id] - if not isinstance(circuit, SpanPanelCircuit): - raise TypeError(f"Expected SpanPanelCircuit, got {type(circuit)}") - return circuit + def _get_circuit(self) -> SpanCircuitSnapshot | None: + """Get the circuit for this entity, or None if temporarily missing.""" + snapshot: SpanPanelSnapshot = self.coordinator.data + return snapshot.circuits.get(self.id) async def async_will_remove_from_hass(self) -> None: """Clean up when entity is removed.""" - # Call parent cleanup await super().async_will_remove_from_hass() async def async_select_option(self, option: str) -> None: """Change the selected option.""" _LOGGER.debug("Selecting option: %s", option) - span_panel: SpanPanel = self.coordinator.data + client = self.coordinator.client + if not hasattr(client, "set_circuit_priority"): + _LOGGER.warning("Client does not support priority control") + return + priority = CircuitPriority(option) - curr_circuit = self._get_circuit() try: - await span_panel.api.set_priority(curr_circuit, priority) + await client.set_circuit_priority(self.id, priority.name) await self.coordinator.async_request_refresh() except ServiceNotFound as snf: - _LOGGER.warning("Service not found when setting priority: %s", snf) + _LOGGER.warning( + "Service not found when setting priority: %s.%s", + snf.domain, + snf.service, + ) await async_create_span_notification( self.hass, message="The requested service is not available in the SPAN API.", @@ -192,7 +235,7 @@ async def async_select_option(self, option: str) -> None: def select_option(self, option: str) -> None: """Select an option synchronously.""" _LOGGER.debug("Selecting option synchronously: %s", option) - self.hass.async_add_executor_job(self.async_select_option, option) + self.hass.create_task(self.async_select_option(option)) @property def available(self) -> bool: @@ -204,45 +247,109 @@ def available(self) -> bool: return False return super().available + @property + def extra_state_attributes(self) -> Mapping[str, Any] | None: + """Return panel position attributes for this circuit.""" + if not self.coordinator.data: + return None + + circuit = self.coordinator.data.circuits.get(self.id) + if not circuit: + return None + + attributes: dict[str, Any] = {} + + tabs_result = construct_tabs_attribute(circuit) + if tabs_result is not None: + attributes["tabs"] = tabs_result + + voltage = construct_voltage_attribute(circuit) or 240 + attributes["voltage"] = voltage + + if circuit.priority_target is not None: + attributes["priority_target"] = circuit.priority_target + + return attributes or None + def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" - - span_panel: SpanPanel = self.coordinator.data - circuit = span_panel.circuits.get(self.id) + snapshot: SpanPanelSnapshot = self.coordinator.data + circuit = snapshot.circuits.get(self.id) if circuit: current_circuit_name = circuit.name + use_circuit_numbers = self.coordinator.config_entry.options.get( + USE_CIRCUIT_NUMBERS, False + ) + desc_name = self.description_wrapper.entity_description.name - # Only request reload if the circuit name has actually changed - if self._previous_circuit_name is None: - # First update - sync to panel name - _LOGGER.info( - "First update: syncing entity name to panel name '%s' for select, requesting reload", - current_circuit_name, - ) - # Update stored previous name for next comparison - self._previous_circuit_name = current_circuit_name - # Request integration reload to persist name change - self.coordinator.request_reload() - elif current_circuit_name != self._previous_circuit_name: - _LOGGER.info( - "Name change detected: previous='%s', current='%s' for select", - self._previous_circuit_name, - current_circuit_name, - ) - _LOGGER.info( - "Auto-sync detected circuit name change from '%s' to '%s' for select, requesting integration reload", - self._previous_circuit_name, - current_circuit_name, - ) + if use_circuit_numbers: + # Circuit-numbers mode: update registry display name, no reload + if self.entity_id and current_circuit_name: + entity_registry = er.async_get(self.hass) + entity_entry = entity_registry.async_get(self.entity_id) + if entity_entry: + # Compute old expected display BEFORE updating + # _previous_circuit_name + old_display = ( + f"{self._previous_circuit_name} {desc_name}" + if isinstance(self._previous_circuit_name, str) + else None + ) + new_display = f"{current_circuit_name} {desc_name}" + + # User override: registry name differs from both old + # and new expected display names + user_has_override = ( + entity_entry.name is not None + and entity_entry.name not in {old_display, new_display} + ) + + if not user_has_override and ( + self._previous_circuit_name is _NAME_UNSET + or current_circuit_name != self._previous_circuit_name + ): + entity_registry.async_update_entity(self.entity_id, name=new_display) - # Update stored previous name for next comparison self._previous_circuit_name = current_circuit_name - - # Request integration reload for next update cycle - self.coordinator.request_reload() + else: + # Friendly-names mode: existing reload behavior + user_has_override = False + if self.entity_id: + entity_registry = er.async_get(self.hass) + entity_entry = entity_registry.async_get(self.entity_id) + if entity_entry and entity_entry.name: + user_has_override = True + _LOGGER.debug( + "User has customized name for %s, skipping sync", + self.entity_id, + ) + + if user_has_override: + self._previous_circuit_name = current_circuit_name + elif self._previous_circuit_name is _NAME_UNSET: + _LOGGER.info( + "First update: syncing entity name to panel name '%s' for select, requesting reload", + current_circuit_name, + ) + self._previous_circuit_name = current_circuit_name + self.coordinator.request_reload() + elif current_circuit_name != self._previous_circuit_name: + _LOGGER.info( + "Auto-sync detected circuit name change from '%s' to '%s' for select, requesting integration reload", + self._previous_circuit_name, + current_circuit_name, + ) + self._previous_circuit_name = current_circuit_name + self.coordinator.request_reload() # Update options and current option based on coordinator data circuit = self._get_circuit() + if circuit is None: + _LOGGER.debug( + "Circuit %s temporarily missing from snapshot, skipping select update", + self.id, + ) + return self._attr_options = self.description_wrapper.options_fn(circuit) self._attr_current_option = self.description_wrapper.current_option_fn(circuit) super()._handle_coordinator_update() @@ -250,94 +357,48 @@ def _handle_coordinator_update(self) -> None: def _construct_select_unique_id( self, coordinator: SpanPanelCoordinator, - span_panel: SpanPanel, + snapshot: SpanPanelSnapshot, select_id: str, ) -> str: """Construct unique ID for select entities.""" - return build_select_unique_id_for_entry( - coordinator, span_panel, select_id, self._device_name - ) - - def _construct_select_entity_id( - self, - coordinator: SpanPanelCoordinator, - circuit_name: str, - circuit_number: int | str, - suffix: str, - unique_id: str | None = None, - ) -> str | None: - """Construct entity ID for select entities.""" - # Check registry first only if unique_id is provided - if unique_id is not None: - entity_registry = er.async_get(coordinator.hass) - existing_entity_id = entity_registry.async_get_entity_id("select", DOMAIN, unique_id) - - if existing_entity_id: - return existing_entity_id - - # Construct default entity_id - config_entry = coordinator.config_entry - - if not self._device_name: - return None - - # Default to False so legacy entries without the flag use friendly names - use_circuit_numbers = config_entry.options.get(USE_CIRCUIT_NUMBERS, False) - use_device_prefix = config_entry.options.get(USE_DEVICE_PREFIX, True) - - # Build entity ID components - parts = [] - - if use_device_prefix: - parts.append(self._device_name.lower().replace(" ", "_")) - - if use_circuit_numbers: - parts.append(f"circuit_{circuit_number}") - else: - circuit_name_slug = circuit_name.lower().replace(" ", "_") - parts.append(circuit_name_slug) - - # Only add suffix if it's different from the last word in the circuit name - if suffix: - circuit_name_words = circuit_name.lower().split() - last_word = circuit_name_words[-1] if circuit_name_words else "" - last_word_normalized = last_word.replace(" ", "_") - - if suffix != last_word_normalized: - parts.append(suffix) - - entity_id = f"select.{'_'.join(parts)}" - return entity_id + return build_select_unique_id_for_entry(coordinator, snapshot, select_id, self._device_name) async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + config_entry: SpanPanelConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up select entities for Span Panel.""" _LOGGER.debug("ASYNC SETUP ENTRY SELECT") - data: dict[str, Any] = hass.data[DOMAIN][config_entry.entry_id] - coordinator: SpanPanelCoordinator = data[COORDINATOR] - span_panel: SpanPanel = coordinator.data + coordinator = config_entry.runtime_data.coordinator + snapshot: SpanPanelSnapshot = coordinator.data # Get device name from config entry data device_name = config_entry.data.get("device_name", config_entry.title) entities: list[SpanPanelCircuitsSelect] = [] - for circuit_id, circuit_data in span_panel.circuits.items(): - if circuit_data.is_user_controllable: - entities.append( - SpanPanelCircuitsSelect( - coordinator, - CIRCUIT_PRIORITY_DESCRIPTION, - circuit_id, - circuit_data.name, - device_name, - ) + for circuit_id, circuit_data in snapshot.circuits.items(): + if not circuit_data.is_user_controllable: + continue + # PV/EVSE circuits only get selects if they have a physical breaker + # (relative_position == "DOWNSTREAM" means connected at a breaker slot) + if ( + circuit_data.device_type in ("pv", "evse") + and circuit_data.relative_position != "DOWNSTREAM" + ): + continue + entities.append( + SpanPanelCircuitsSelect( + coordinator, + CIRCUIT_PRIORITY_DESCRIPTION, + circuit_id, + circuit_data.name, + device_name, ) + ) async_add_entities(entities) diff --git a/custom_components/span_panel/sensor.py b/custom_components/span_panel/sensor.py index b63bb3c2..928c6b88 100644 --- a/custom_components/span_panel/sensor.py +++ b/custom_components/span_panel/sensor.py @@ -2,79 +2,416 @@ from __future__ import annotations +import logging + from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from span_panel_api import SpanPanelSnapshot -from .const import COORDINATOR, DOMAIN +from . import SpanPanelConfigEntry +from .const import ( + CONF_DEVICE_NAME, + ENABLE_CIRCUIT_NET_ENERGY_SENSORS, + ENABLE_PANEL_NET_ENERGY_SENSORS, + ENABLE_UNMAPPED_CIRCUIT_SENSORS, + USE_CIRCUIT_NUMBERS, +) from .coordinator import SpanPanelCoordinator -from .sensors import ( +from .helpers import ( + has_bess, + has_evse, + has_power_flows, + has_pv, + resolve_evse_display_suffix, +) +from .sensor_base import SpanEnergySensorBase, SpanSensorBase +from .sensor_circuit import ( SpanCircuitEnergySensor, SpanCircuitPowerSensor, - SpanEnergySensorBase, + SpanUnmappedCircuitSensor, +) +from .sensor_definitions import ( + BATTERY_POWER_SENSOR, + BATTERY_SENSOR, + BESS_METADATA_SENSORS, + CIRCUIT_BREAKER_RATING_SENSOR, + CIRCUIT_CURRENT_SENSOR, + CIRCUIT_SENSORS, + DOWNSTREAM_L1_CURRENT_SENSOR, + DOWNSTREAM_L2_CURRENT_SENSOR, + EVSE_SENSORS, + GRID_POWER_FLOW_SENSOR, + L1_VOLTAGE_SENSOR, + L2_VOLTAGE_SENSOR, + MAIN_BREAKER_RATING_SENSOR, + PANEL_DATA_STATUS_SENSORS, + PANEL_ENERGY_SENSORS, + PANEL_POWER_SENSORS, + PV_METADATA_SENSORS, + PV_POWER_SENSOR, + SITE_POWER_SENSOR, + STATUS_SENSORS, + UNMAPPED_SENSORS, + UPSTREAM_L1_CURRENT_SENSOR, + UPSTREAM_L2_CURRENT_SENSOR, +) +from .sensor_evse import SpanEvseSensor +from .sensor_panel import ( + SpanBessMetadataSensor, SpanPanelBattery, SpanPanelEnergySensor, SpanPanelPanelStatus, SpanPanelPowerSensor, SpanPanelStatus, - SpanSensorBase, - SpanSolarEnergySensor, - SpanSolarSensor, - SpanUnmappedCircuitSensor, - create_native_sensors, - enable_unmapped_tab_entities, + SpanPVMetadataSensor, ) -from .span_panel import SpanPanel +from .util import bess_device_info, evse_device_info # Export the sensor classes for backward compatibility with tests __all__ = [ - "SpanSensorBase", + "SpanBessMetadataSensor", + "SpanCircuitEnergySensor", + "SpanCircuitPowerSensor", "SpanEnergySensorBase", - "SpanPanelPanelStatus", - "SpanPanelStatus", + "SpanPVMetadataSensor", "SpanPanelBattery", - "SpanPanelPowerSensor", "SpanPanelEnergySensor", - "SpanCircuitPowerSensor", - "SpanCircuitEnergySensor", + "SpanPanelPanelStatus", + "SpanPanelPowerSensor", + "SpanPanelStatus", + "SpanSensorBase", "SpanUnmappedCircuitSensor", - "SpanSolarSensor", - "SpanSolarEnergySensor", ] -import logging - _LOGGER: logging.Logger = logging.getLogger(__name__) -ICON = "mdi:flash" +PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + config_entry: SpanPanelConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up sensor platform.""" try: - data = hass.data[DOMAIN][config_entry.entry_id] - coordinator: SpanPanelCoordinator = data[COORDINATOR] - span_panel: SpanPanel = coordinator.data + coordinator = config_entry.runtime_data.coordinator + snapshot: SpanPanelSnapshot = coordinator.data - # Create all native sensors (now includes panel, circuit, and solar sensors) - entities = create_native_sensors(coordinator, span_panel, config_entry) + # Create all native sensors (panel, circuit, and battery sensors) + entities = create_native_sensors(coordinator, snapshot, config_entry) # Add all native sensor entities async_add_entities(entities) - # Enable unmapped tab entities if they were disabled - enable_unmapped_tab_entities(hass, entities) - - # Migration detection moved to coordinator update cycle - # Force immediate coordinator refresh to ensure all sensors update right away await coordinator.async_request_refresh() _LOGGER.debug("Native sensor platform setup completed with %d entities", len(entities)) - except Exception as e: - _LOGGER.error("Error in async_setup_entry: %s", e, exc_info=True) + except Exception: + _LOGGER.exception("Error in async_setup_entry") raise + + +def create_panel_sensors( + coordinator: SpanPanelCoordinator, + snapshot: SpanPanelSnapshot, + config_entry: ConfigEntry, +) -> list[SpanPanelPanelStatus | SpanPanelStatus | SpanPanelPowerSensor | SpanPanelEnergySensor]: + """Create panel-level sensors.""" + entities: list[ + SpanPanelPanelStatus | SpanPanelStatus | SpanPanelPowerSensor | SpanPanelEnergySensor + ] = [ + SpanPanelPanelStatus(coordinator, description, snapshot) + for description in PANEL_DATA_STATUS_SENSORS + ] + + # Add panel power sensors + entities.extend( + SpanPanelPowerSensor(coordinator, description, snapshot) + for description in PANEL_POWER_SENSORS + ) + + # Add panel energy sensors + # Filter out net energy sensors if disabled + panel_net_energy_enabled = config_entry.options.get(ENABLE_PANEL_NET_ENERGY_SENSORS, True) + + for description in PANEL_ENERGY_SENSORS: + # Skip net energy sensors if disabled + is_net_energy_sensor = "net_energy" in description.key or "NetEnergy" in description.key + + if not panel_net_energy_enabled and is_net_energy_sensor: + continue + entities.append(SpanPanelEnergySensor(coordinator, description, snapshot)) + + # Add hardware status sensors (Door State, WiFi, Cellular, etc.) + entities.extend( + SpanPanelStatus(coordinator, description, snapshot) for description in STATUS_SENSORS + ) + + # Add v2 diagnostic sensors (conditionally created when data is available) + if snapshot.l1_voltage is not None: + entities.append(SpanPanelPanelStatus(coordinator, L1_VOLTAGE_SENSOR, snapshot)) + if snapshot.l2_voltage is not None: + entities.append(SpanPanelPanelStatus(coordinator, L2_VOLTAGE_SENSOR, snapshot)) + if snapshot.upstream_l1_current_a is not None: + entities.append(SpanPanelPanelStatus(coordinator, UPSTREAM_L1_CURRENT_SENSOR, snapshot)) + if snapshot.upstream_l2_current_a is not None: + entities.append(SpanPanelPanelStatus(coordinator, UPSTREAM_L2_CURRENT_SENSOR, snapshot)) + if snapshot.downstream_l1_current_a is not None: + entities.append(SpanPanelPanelStatus(coordinator, DOWNSTREAM_L1_CURRENT_SENSOR, snapshot)) + if snapshot.downstream_l2_current_a is not None: + entities.append(SpanPanelPanelStatus(coordinator, DOWNSTREAM_L2_CURRENT_SENSOR, snapshot)) + if snapshot.main_breaker_rating_a is not None: + entities.append(SpanPanelPanelStatus(coordinator, MAIN_BREAKER_RATING_SENSOR, snapshot)) + + return entities + + +def _build_evse_device_info_map( + coordinator: SpanPanelCoordinator, snapshot: SpanPanelSnapshot +) -> dict[str, DeviceInfo]: + """Build a mapping of EVSE feed circuit IDs to their EVSE DeviceInfo. + + Circuit sensors for EVSE feed circuits are assigned to the EVSE sub-device + instead of the panel device, keeping all charger-related entities together. + """ + if not snapshot.evse: + return {} + + panel_name = ( + coordinator.config_entry.data.get(CONF_DEVICE_NAME, coordinator.config_entry.title) + or "Span Panel" + ) + panel_identifier = snapshot.serial_number + + use_circuit_numbers = coordinator.config_entry.options.get(USE_CIRCUIT_NUMBERS, False) + + mapping: dict[str, DeviceInfo] = {} + for evse in snapshot.evse.values(): + display_suffix = resolve_evse_display_suffix(evse, snapshot, use_circuit_numbers) + info = evse_device_info(panel_identifier, evse, panel_name, display_suffix) + mapping[evse.feed_circuit_id] = info + + return mapping + + +def create_circuit_sensors( + coordinator: SpanPanelCoordinator, + snapshot: SpanPanelSnapshot, + config_entry: ConfigEntry, +) -> list[SpanCircuitPowerSensor | SpanCircuitEnergySensor]: + """Create circuit-level sensors for named circuits.""" + entities: list[SpanCircuitPowerSensor | SpanCircuitEnergySensor] = [] + + # Build EVSE device info so feed circuit sensors land on the charger device + evse_device_map = _build_evse_device_info_map(coordinator, snapshot) + + # Add circuit sensors for all named circuits + named_circuits = [cid for cid in snapshot.circuits if not cid.startswith("unmapped_tab_")] + circuit_net_energy_enabled = config_entry.options.get(ENABLE_CIRCUIT_NET_ENERGY_SENSORS, True) + + for circuit_id in named_circuits: + device_override = evse_device_map.get(circuit_id) + circuit_data = snapshot.circuits.get(circuit_id) + + for circuit_description in CIRCUIT_SENSORS: + # Skip net energy sensors if disabled + is_net_energy_sensor = ( + "net_energy" in circuit_description.key or "energy_net" in circuit_description.key + ) + + if not circuit_net_energy_enabled and is_net_energy_sensor: + continue + + if circuit_description.key == "circuit_power": + entities.append( + SpanCircuitPowerSensor( + coordinator, + circuit_description, + snapshot, + circuit_id, + device_info_override=device_override, + ) + ) + else: + entities.append( + SpanCircuitEnergySensor( + coordinator, + circuit_description, + snapshot, + circuit_id, + device_info_override=device_override, + ) + ) + + # Per-circuit current sensor (v2 only) + if circuit_data and circuit_data.current_a is not None: + entities.append( + SpanCircuitPowerSensor( + coordinator, + CIRCUIT_CURRENT_SENSOR, + snapshot, + circuit_id, + device_info_override=device_override, + ) + ) + + # Per-circuit breaker rating sensor (v2 only) + if circuit_data and circuit_data.breaker_rating_a is not None: + entities.append( + SpanCircuitPowerSensor( + coordinator, + CIRCUIT_BREAKER_RATING_SENSOR, + snapshot, + circuit_id, + device_info_override=device_override, + ) + ) + + return entities + + +def create_unmapped_circuit_sensors( + coordinator: SpanPanelCoordinator, snapshot: SpanPanelSnapshot +) -> list[SpanUnmappedCircuitSensor]: + """Create unmapped circuit sensors for synthetic calculations.""" + entities: list[SpanUnmappedCircuitSensor] = [] + + # Add unmapped circuit sensors (native sensors for synthetic calculations) + # These are invisible sensors that provide stable entity IDs for solar synthetics + unmapped_circuits = [cid for cid in snapshot.circuits if cid.startswith("unmapped_tab_")] + for circuit_id in unmapped_circuits: + entities.extend( + SpanUnmappedCircuitSensor(coordinator, unmapped_description, snapshot, circuit_id) + for unmapped_description in UNMAPPED_SENSORS + ) + + return entities + + +def _build_bess_device_info( + coordinator: SpanPanelCoordinator, snapshot: SpanPanelSnapshot +) -> DeviceInfo: + """Build BESS sub-device info, resolving the panel identifier.""" + panel_name = ( + coordinator.config_entry.data.get(CONF_DEVICE_NAME, coordinator.config_entry.title) + or "Span Panel" + ) + + return bess_device_info(snapshot.serial_number, snapshot.battery, panel_name) + + +def create_battery_sensors( + coordinator: SpanPanelCoordinator, snapshot: SpanPanelSnapshot +) -> list[SpanPanelBattery | SpanPanelPowerSensor | SpanBessMetadataSensor]: + """Create battery sensors when BESS is commissioned. + + Auto-detected from soe_percentage — only a commissioned BESS reports SoE. + All BESS sensors live on the BESS sub-device. + """ + if not has_bess(snapshot): + return [] + + bess_info = _build_bess_device_info(coordinator, snapshot) + + entities: list[SpanPanelBattery | SpanPanelPowerSensor | SpanBessMetadataSensor] = [ + SpanPanelPowerSensor( + coordinator, BATTERY_POWER_SENSOR, snapshot, device_info_override=bess_info + ), + SpanPanelBattery(coordinator, BATTERY_SENSOR, snapshot, device_info_override=bess_info), + ] + + # Add BESS metadata sensors + entities.extend( + SpanBessMetadataSensor(coordinator, desc, snapshot, bess_info) + for desc in BESS_METADATA_SENSORS + ) + + return entities + + +def create_power_flow_sensors( + coordinator: SpanPanelCoordinator, snapshot: SpanPanelSnapshot +) -> list[SpanPanelPowerSensor | SpanPVMetadataSensor]: + """Create power-flow sensors that are conditional on hardware presence. + + PV Power — only when PV is commissioned. + Site Power — only when the power-flows node is publishing. + PV metadata sensors — only when PV is commissioned. + """ + entities: list[SpanPanelPowerSensor | SpanPVMetadataSensor] = [] + + if has_pv(snapshot): + entities.append(SpanPanelPowerSensor(coordinator, PV_POWER_SENSOR, snapshot)) + + # PV metadata sensors on the main panel device + entities.extend( + SpanPVMetadataSensor(coordinator, desc, snapshot) for desc in PV_METADATA_SENSORS + ) + + if has_power_flows(snapshot): + entities.append(SpanPanelPowerSensor(coordinator, GRID_POWER_FLOW_SENSOR, snapshot)) + entities.append(SpanPanelPowerSensor(coordinator, SITE_POWER_SENSOR, snapshot)) + + return entities + + +def create_evse_sensors( + coordinator: SpanPanelCoordinator, snapshot: SpanPanelSnapshot +) -> list[SpanEvseSensor]: + """Create EVSE sensors for each commissioned charger.""" + if not has_evse(snapshot): + return [] + entities: list[SpanEvseSensor] = [] + for evse_id in snapshot.evse: + entities.extend( + SpanEvseSensor(coordinator, desc, snapshot, evse_id) for desc in EVSE_SENSORS + ) + return entities + + +def create_native_sensors( + coordinator: SpanPanelCoordinator, + snapshot: SpanPanelSnapshot, + config_entry: ConfigEntry, +) -> list[ + SpanPanelPanelStatus + | SpanPanelStatus + | SpanPanelPowerSensor + | SpanPanelEnergySensor + | SpanCircuitPowerSensor + | SpanCircuitEnergySensor + | SpanUnmappedCircuitSensor + | SpanPanelBattery + | SpanBessMetadataSensor + | SpanPVMetadataSensor + | SpanEvseSensor +]: + """Create all native sensors for the platform.""" + entities: list[ + SpanPanelPanelStatus + | SpanPanelStatus + | SpanPanelPowerSensor + | SpanPanelEnergySensor + | SpanCircuitPowerSensor + | SpanCircuitEnergySensor + | SpanUnmappedCircuitSensor + | SpanPanelBattery + | SpanBessMetadataSensor + | SpanPVMetadataSensor + | SpanEvseSensor + ] = [] + + # Create different sensor types + entities.extend(create_panel_sensors(coordinator, snapshot, config_entry)) + entities.extend(create_circuit_sensors(coordinator, snapshot, config_entry)) + if config_entry.options.get(ENABLE_UNMAPPED_CIRCUIT_SENSORS, False): + entities.extend(create_unmapped_circuit_sensors(coordinator, snapshot)) + entities.extend(create_battery_sensors(coordinator, snapshot)) + entities.extend(create_power_flow_sensors(coordinator, snapshot)) + entities.extend(create_evse_sensors(coordinator, snapshot)) + + return entities diff --git a/custom_components/span_panel/sensor_base.py b/custom_components/span_panel/sensor_base.py new file mode 100644 index 00000000..fddec4d7 --- /dev/null +++ b/custom_components/span_panel/sensor_base.py @@ -0,0 +1,751 @@ +"""Base sensor classes for Span Panel integration.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from collections.abc import Callable +from datetime import UTC, date, datetime, timedelta +from decimal import Decimal +import logging +from typing import Any, cast + +from homeassistant.components.sensor import ( + RestoreSensor, + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorExtraStoredData, + SensorStateClass, +) +from homeassistant.const import STATE_UNKNOWN +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.typing import StateType +from span_panel_api import SpanPanelSnapshot + +from .const import DOMAIN, ENABLE_ENERGY_DIP_COMPENSATION, USE_CIRCUIT_NUMBERS +from .coordinator import SpanPanelCoordinator +from .energy_dip import build_dip_attributes, process_energy_dip +from .entity import SpanPanelEntity +from .grace_period import ( # noqa: F401 + SpanEnergyExtraStoredData, + _parse_numeric_state, + coerce_grace_period_minutes, + handle_offline_grace_period, + initialize_from_last_state, +) +from .options import ENERGY_REPORTING_GRACE_PERIOD + +_LOGGER: logging.Logger = logging.getLogger(__name__) + +# Sentinel value to distinguish "never synced" from "circuit name is None" +_NAME_UNSET: object = object() + +# Keys from Span energy sensors' extra_state_attributes that we omit from the recorder +# (SpanEnergySensorBase: panel-wide and circuit energy entities). High-churn grace/dip +# diagnostics dominated DB growth (#197). tabs and voltage are merged in by circuit +# subclasses; they stay on the live entity for Developer tools and automations. +_ENERGY_SENSOR_UNRECORDED_ATTRIBUTES: frozenset[str] = frozenset( + { + "energy_offset", + "grace_period_remaining", + "last_dip_delta", + "last_valid_changed", + "last_valid_state", + "tabs", + "using_grace_period", + "voltage", + } +) + + +class SpanSensorBase[T: SensorEntityDescription, D](SpanPanelEntity, SensorEntity, ABC): + """Abstract base class for Span Panel sensors with overridable methods.""" + + _attr_has_entity_name = True + + def __init__( + self, + data_coordinator: SpanPanelCoordinator, + description: T, + snapshot: SpanPanelSnapshot, + ) -> None: + """Initialize Span Panel Sensor base entity.""" + super().__init__(data_coordinator, context=description) + self.entity_description = description + + if hasattr(description, "device_class"): + self._attr_device_class = description.device_class + + if hasattr(description, "options") and description.options: + self._attr_options = list(description.options) + + # Get device name from config entry data + self._device_name = data_coordinator.config_entry.data.get( + "device_name", data_coordinator.config_entry.title + ) + + self._attr_device_info = self._build_device_info(data_coordinator, snapshot) + + # Check if entity already exists in registry for name sync + if snapshot.serial_number and description.key: + self._attr_unique_id = self._generate_unique_id(snapshot, description) + + # Entities with translation_key get their name from translations/en.json. + # Only set _attr_name for entities without translation_key (e.g., + # circuit sensors whose names include the dynamic circuit name). + if not getattr(description, "translation_key", None): + entity_registry = er.async_get(data_coordinator.hass) + existing_entity_id = entity_registry.async_get_entity_id( + "sensor", DOMAIN, self._attr_unique_id + ) + + use_circuit_numbers = data_coordinator.config_entry.options.get( + USE_CIRCUIT_NUMBERS, False + ) + + if existing_entity_id: + if use_circuit_numbers: + # Circuit-numbers mode: keep circuit-based name for entity_id stability + self._attr_name = self._generate_friendly_name(snapshot, description) + else: + # Friendly-names mode: use panel name for sync + self._attr_name = self._generate_panel_name(snapshot, description) + else: + # Initial install - use flag-based name + self._attr_name = self._generate_friendly_name(snapshot, description) + + # Sync panel friendly name to registry display name in + # circuit-numbers mode so the UI shows e.g. + # "Kitchen Power" while entity_id stays circuit-based. + if existing_entity_id and use_circuit_numbers: + self._sync_friendly_name_to_registry( + snapshot, description, entity_registry, existing_entity_id + ) + + # Wire explicit entity_id via subclass helper + entity_id = self._construct_entity_id(snapshot, description, existing_entity_id) + if entity_id: + self.entity_id = entity_id + else: + # Fallback for entities without unique_id + self._attr_name = self._generate_friendly_name(snapshot, description) + + # Set entity registry defaults if they exist in the description + if hasattr(description, "entity_registry_enabled_default"): + self._attr_entity_registry_enabled_default = description.entity_registry_enabled_default + if hasattr(description, "entity_registry_visible_default"): + self._attr_entity_registry_visible_default = description.entity_registry_visible_default + + # Initialize name sync tracking + # Use sentinel to distinguish "never synced" from "circuit name is None" + if snapshot.serial_number and description.key and self._attr_unique_id: + entity_registry = er.async_get(data_coordinator.hass) + existing_entity_id = entity_registry.async_get_entity_id( + "sensor", DOMAIN, self._attr_unique_id + ) + if not existing_entity_id: + self._previous_circuit_name: str | None | object = _NAME_UNSET + # Entity exists, get current circuit name for comparison + elif hasattr(self, "circuit_id"): + circuit = snapshot.circuits.get(getattr(self, "circuit_id", "")) + self._previous_circuit_name = circuit.name if circuit else None + else: + self._previous_circuit_name = None + else: + self._previous_circuit_name = _NAME_UNSET + + # Use standard coordinator pattern - entities will update automatically + # when coordinator data changes + + @abstractmethod + def _generate_unique_id(self, snapshot: SpanPanelSnapshot, description: T) -> str: + """Generate unique ID for the sensor. + + Subclasses must implement this to define their unique ID strategy. + + Args: + snapshot: The panel snapshot data + description: The sensor description + + Returns: + Unique ID string + + """ + + @abstractmethod + def _generate_friendly_name(self, snapshot: SpanPanelSnapshot, description: T) -> str | None: + """Generate friendly name for the sensor. + + Subclasses must implement this to define their naming strategy. + + Args: + snapshot: The panel snapshot data + description: The sensor description + + Returns: + Friendly name string, or None to let HA use default behavior + + """ + + def _generate_panel_name(self, snapshot: SpanPanelSnapshot, description: T) -> str | None: + """Generate panel name for the sensor (always uses panel circuit name). + + This method is used for name sync - it always uses the panel circuit name + regardless of user preferences. + + Args: + snapshot: The panel snapshot data + description: The sensor description + + Returns: + Panel name string + + """ + # This should be implemented by subclasses that need name sync + # For now, fall back to friendly name + return self._generate_friendly_name(snapshot, description) + + def _sync_friendly_name_to_registry( + self, + snapshot: SpanPanelSnapshot, + description: T, + entity_registry: er.EntityRegistry, + existing_entity_id: str, + ) -> None: + """Sync panel circuit name to registry display name in circuit-numbers mode.""" + circuit = snapshot.circuits.get(getattr(self, "circuit_id", "")) + if not (circuit and circuit.name): + return + entity_entry = entity_registry.async_get(existing_entity_id) + if not entity_entry: + return + description_suffix = str(getattr(description, "name", None) or "Sensor") + expected_name = f"{circuit.name} {description_suffix}" + if entity_entry.name is None or entity_entry.name == expected_name: + entity_registry.async_update_entity(existing_entity_id, name=expected_name) + + def _construct_entity_id( + self, + snapshot: SpanPanelSnapshot, + description: T, + existing_entity_id: str | None = None, + ) -> str | None: + """Construct explicit entity_id for the sensor. + + Subclasses may override to use entity_id helpers from helpers.py. + Returns None to let HA auto-generate from _attr_name. + + Args: + snapshot: The panel snapshot data + description: The sensor description + existing_entity_id: The existing entity_id from registry, or None for new entities + + """ + return None + + def _sync_circuit_name(self) -> None: + """Sync circuit name changes: registry display in circuit-numbers mode, reload in friendly-names mode.""" + if not (hasattr(self, "circuit_id") and hasattr(self.coordinator.data, "circuits")): + return + + circuit = self.coordinator.data.circuits.get(getattr(self, "circuit_id", "")) + if not circuit: + return + + current_circuit_name = circuit.name + use_circuit_numbers = self.coordinator.config_entry.options.get(USE_CIRCUIT_NUMBERS, False) + + if use_circuit_numbers: + # Circuit-numbers mode: update registry display name, no reload + if self.entity_id: + entity_registry = er.async_get(self.hass) + entity_entry = entity_registry.async_get(self.entity_id) + if entity_entry: + description_suffix = str( + getattr(self.entity_description, "name", None) or "Sensor" + ) + old_display = ( + f"{self._previous_circuit_name} {description_suffix}" + if isinstance(self._previous_circuit_name, str) + else None + ) + new_display = f"{current_circuit_name} {description_suffix}" + + user_has_override = entity_entry.name is not None and entity_entry.name not in { + old_display, + new_display, + } + + if not user_has_override and ( + self._previous_circuit_name is _NAME_UNSET + or current_circuit_name != self._previous_circuit_name + ): + entity_registry.async_update_entity(self.entity_id, name=new_display) + self._previous_circuit_name = current_circuit_name + else: + # Friendly-names mode: existing reload behavior + user_has_override = False + if self.entity_id: + entity_registry = er.async_get(self.hass) + entity_entry = entity_registry.async_get(self.entity_id) + if entity_entry and entity_entry.name: + user_has_override = True + + if user_has_override: + self._previous_circuit_name = current_circuit_name + elif self._previous_circuit_name is _NAME_UNSET: + _LOGGER.info( + "First update: syncing sensor name to panel name '%s', requesting reload", + current_circuit_name, + ) + self._previous_circuit_name = current_circuit_name + self.coordinator.request_reload() + elif current_circuit_name != self._previous_circuit_name: + _LOGGER.info( + "Auto-sync detected circuit name change from '%s' to '%s' for sensor, requesting integration reload", + self._previous_circuit_name, + current_circuit_name, + ) + self._previous_circuit_name = current_circuit_name + self.coordinator.request_reload() + + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._sync_circuit_name() + self._update_native_value() + super()._handle_coordinator_update() + + @property + def available(self) -> bool: + """Return entity availability. + + Keep entities available during a panel_offline condition so sensors can show + their grace period state (last_valid_state) or None when grace period expires. + """ + try: + if getattr(self.coordinator, "panel_offline", False): + return True + except AttributeError as err: + # If coordinator is missing expected attribute, log and fall back + _LOGGER.debug("Availability check: missing coordinator attribute: %s", err) + except Exception as err: # noqa: BLE001 # pragma: no cover - defensive + # Any unexpected error shouldn't crash the availability check + _LOGGER.debug("Availability check: unexpected error: %s", err) + return super().available + + @property + def _expects_numeric(self) -> bool: + """Return True if HA expects this sensor to have a numeric value. + + HA raises ValueError when a sensor with a numeric device_class, + state_class, or native_unit_of_measurement reports a string state + like STATE_UNKNOWN. These sensors must use None instead. + """ + if getattr(self.entity_description, "state_class", None) is not None: + return True + if getattr(self.entity_description, "native_unit_of_measurement", None) is not None: + return True + dc = getattr(self.entity_description, "device_class", None) + if dc is not None and dc != SensorDeviceClass.ENUM: + return True + return False + + def _unknown_value(self) -> StateType: + """Return the appropriate 'unknown' value for this sensor.""" + return None if self._expects_numeric else STATE_UNKNOWN + + def _update_native_value(self) -> None: + """Update the native value of the sensor.""" + if self.coordinator.panel_offline: + self._handle_offline_state() + return + + self._handle_online_state() + + def _handle_offline_state(self) -> None: + """Handle sensor state when panel is offline.""" + _LOGGER.debug( + "STATUS_SENSOR_DEBUG: Panel is offline for %s", + self.entity_id or self._attr_unique_id, + ) + + device_class = getattr(self.entity_description, "device_class", None) + if device_class == SensorDeviceClass.POWER: + self._attr_native_value = 0.0 + elif device_class == SensorDeviceClass.ENERGY: + self._attr_native_value = None + else: + self._attr_native_value = self._unknown_value() + + def _handle_online_state(self) -> None: + """Handle sensor state when panel is online.""" + value_function: Callable[[D], float | int | str | None] | None = getattr( + self.entity_description, "value_fn", None + ) + if value_function is None: + _LOGGER.debug( + "STATUS_SENSOR_DEBUG: No value_function for %s", + self.entity_id or self._attr_unique_id, + ) + self._attr_native_value = self._unknown_value() + return + + try: + data_source: D = self.get_data_source(self.coordinator.data) + self._log_debug_info(data_source) + raw_value: float | int | str | None = value_function(data_source) + self._process_raw_value(raw_value) + except (AttributeError, KeyError, IndexError) as err: + _LOGGER.debug( + "Value lookup failed for %s (%s): %s", + self.entity_id or self._attr_unique_id, + getattr(self.entity_description, "key", "?"), + err, + ) + self._attr_native_value = self._unknown_value() + except Exception as err: # noqa: BLE001 # pragma: no cover - defensive + # Avoid noisy stack traces from value functions; fall back to unknown + _LOGGER.warning( + "Value function failed for %s (%s); reporting unknown", + self.entity_id or self._attr_unique_id, + err, + ) + self._attr_native_value = self._unknown_value() + + def _log_debug_info(self, data_source: D) -> None: + """Log debug information for circuit sensors.""" + # Only do debug logging if we have valid data and the panel is online + if ( + not self.coordinator.panel_offline + and hasattr(self, "id") + and hasattr(data_source, "instant_power_w") + ): + circuit_id = getattr(self, "id", STATE_UNKNOWN) + instant_power = getattr(data_source, "instant_power_w", None) + description_key = getattr(self.entity_description, "key", STATE_UNKNOWN) + _LOGGER.debug( + "CIRCUIT_POWER_DEBUG: Circuit %s, sensor %s, instant_power=%s, data_source type=%s", + circuit_id, + description_key, + instant_power, + type(data_source).__name__, + ) + + def _process_raw_value(self, raw_value: float | str | None) -> None: + """Process the raw value from the value function.""" + if raw_value is None: + self._attr_native_value = self._unknown_value() + elif isinstance(raw_value, float | int): + self._attr_native_value = float(raw_value) + else: + str_value = str(raw_value) + # For enum sensors, ensure the value is in the options list before + # setting it — HA raises ValueError if the state is not in options. + # Options are built dynamically from observed MQTT values. + # Values are normalized to lowercase to satisfy HA's translation + # key requirement ([a-z0-9-_]+). HA uses the state value directly + # as the translation key lookup. + if self._attr_device_class is SensorDeviceClass.ENUM: + str_value = str_value.lower() + if not hasattr(self, "_attr_options") or self._attr_options is None: + self._attr_options = [] + if str_value not in self._attr_options: + self._attr_options.append(str_value) + _LOGGER.debug( + "Added enum option '%s' for %s", + str_value, + self.entity_id or self._attr_unique_id, + ) + self._attr_native_value = str_value + + def get_data_source(self, snapshot: SpanPanelSnapshot) -> D: + """Get the data source for the sensor.""" + raise NotImplementedError("Subclasses must implement this method") + + +class SpanEnergySensorBase[T: SensorEntityDescription, D](SpanSensorBase[T, D], RestoreSensor, ABC): + """Base class for energy sensors that includes grace period tracking. + + This class extends SpanSensorBase with: + - Grace period tracking for offline scenarios + - State restoration across HA restarts via RestoreSensor mixin + - Automatic persistence of last_valid_state and last_valid_changed + + High-churn diagnostic attributes are listed in ``extra_state_attributes`` for + the UI but omitted from recorder history via ``_unrecorded_attributes`` so the + database is not flooded with unique attribute blobs on every energy update. + """ + + _unrecorded_attributes = _ENERGY_SENSOR_UNRECORDED_ATTRIBUTES + + def __init__( + self, + data_coordinator: SpanPanelCoordinator, + description: T, + snapshot: SpanPanelSnapshot, + ) -> None: + """Initialize the energy sensor with grace period tracking.""" + super().__init__(data_coordinator, description, snapshot) + self._last_valid_state: float | None = None + self._last_valid_changed: datetime | None = None + self._grace_period_minutes = data_coordinator.config_entry.options.get( + ENERGY_REPORTING_GRACE_PERIOD, 15 + ) + # Track if we've restored data (used for logging) + self._restored_from_storage: bool = False + + # Energy dip compensation state + self._energy_offset: float = 0.0 + self._last_panel_reading: float | None = None + self._last_dip_delta: float | None = None + self._is_total_increasing: bool = ( + getattr(description, "state_class", None) == SensorStateClass.TOTAL_INCREASING + ) + self._dip_compensation_enabled: bool = data_coordinator.config_entry.options.get( + ENABLE_ENERGY_DIP_COMPENSATION, False + ) + + @property + def energy_offset(self) -> float: + """Return the cumulative dip compensation offset.""" + return self._energy_offset + + def _process_raw_value(self, raw_value: float | str | None) -> None: + """Process the raw value with energy dip compensation for TOTAL_INCREASING sensors.""" + if ( + self._dip_compensation_enabled + and self._is_total_increasing + and isinstance(raw_value, float | int) + ): + raw_float = float(raw_value) + new_offset, dip_delta, compensated = process_energy_dip( + raw_float, self._last_panel_reading, self._energy_offset + ) + if dip_delta is not None: + self._last_dip_delta = dip_delta + self.coordinator.report_energy_dip( + self.entity_id or self._attr_unique_id or "unknown", + dip_delta, + new_offset, + ) + self._energy_offset = new_offset + self._last_panel_reading = raw_float + super()._process_raw_value(compensated) + else: + super()._process_raw_value(raw_value) + + async def async_added_to_hass(self) -> None: + """Restore grace period state when entity is added to Home Assistant. + + This method is called when the entity is added to HA, which happens + during startup or when the integration is reloaded. We use this + opportunity to restore the grace period tracking state from storage. + + Dip compensation state is restored BEFORE calling super() because + super() registers the coordinator listener, and the SPAN coordinator + may immediately push a snapshot via async_set_updated_data() which + calls async_update_listeners() synchronously. If that push fires before + the offset is restored, _process_raw_value() runs with _energy_offset=0, + reports the raw panel counter to HA, and HA statistics treats the value + drop as a counter reset — permanently inflating the energy sum. + """ + # Pre-fetch stored extra data so dip state can be applied before the + # coordinator listener is registered inside super(). + last_extra_data = await self.async_get_last_extra_data() + restored = ( + SpanEnergyExtraStoredData.from_dict(last_extra_data.as_dict()) + if last_extra_data is not None + else None + ) + + # Restore dip compensation state before super() registers the listener. + if restored and self._dip_compensation_enabled and self._is_total_increasing: + if restored.energy_offset is not None: + self._energy_offset = restored.energy_offset + if restored.last_panel_reading is not None: + self._last_panel_reading = restored.last_panel_reading + if restored.last_dip_delta is not None: + self._last_dip_delta = restored.last_dip_delta + _LOGGER.debug( + "Restored energy dip compensation for %s: offset=%s, last_reading=%s, last_dip=%s", + self.entity_id or self._attr_unique_id, + self._energy_offset, + self._last_panel_reading, + self._last_dip_delta, + ) + + # Register the coordinator listener (and base RestoreSensor setup). + await super().async_added_to_hass() + + # Complete grace period restoration from the already-fetched extra data. + if restored: + if restored.last_valid_state is not None: + self._last_valid_state = restored.last_valid_state + + if restored.last_valid_changed is not None: + try: + parsed = datetime.fromisoformat(restored.last_valid_changed) + # Ensure UTC-aware: old storage may have naive timestamps + self._last_valid_changed = ( + parsed.replace(tzinfo=UTC) if parsed.tzinfo is None else parsed + ) + self._restored_from_storage = True + _LOGGER.debug( + "Restored grace period state for %s: " + "last_valid_state=%s, last_valid_changed=%s", + self.entity_id or self._attr_unique_id, + self._last_valid_state, + self._last_valid_changed, + ) + except (ValueError, TypeError) as e: + _LOGGER.warning( + "Failed to parse restored last_valid_changed for %s: %s", + self.entity_id or self._attr_unique_id, + e, + ) + + # Seed grace period tracking from the last stored HA state when extra data + # is missing (e.g., after first install or early offline event). + await self._initialize_grace_period_from_last_state() + + async def _initialize_grace_period_from_last_state(self) -> None: + """Seed grace tracking from HA's last stored state when extra data is missing.""" + + if self._last_valid_state is not None: + return + + try: + last_state = await self.async_get_last_state() + except Exception as err: # noqa: BLE001 # pragma: no cover - defensive + _LOGGER.debug( + "Grace period restore: failed for %s: %s", + self.entity_id or self._attr_unique_id, + err, + ) + return + + value, changed = initialize_from_last_state(last_state) + if value is not None: + self._last_valid_state = value + self._last_valid_changed = changed + self._restored_from_storage = True + _LOGGER.debug( + "Grace period initialized for %s: value=%s, changed=%s", + self.entity_id or self._attr_unique_id, + value, + changed, + ) + + @property + def extra_restore_state_data(self) -> SensorExtraStoredData: + """Return sensor-specific state data to be restored. + + This data is automatically saved by Home Assistant when the + integration is unloaded or HA shuts down, and restored when + the entity is added back to HA. + """ + return cast( + SensorExtraStoredData, + SpanEnergyExtraStoredData( + native_value=( + float(self._attr_native_value) + if isinstance(self._attr_native_value, int | float) + else None + ), + native_unit_of_measurement=self.native_unit_of_measurement, + last_valid_state=self._last_valid_state, + last_valid_changed=( + self._last_valid_changed.isoformat() if self._last_valid_changed else None + ), + energy_offset=self._energy_offset or None, + last_panel_reading=self._last_panel_reading, + last_dip_delta=self._last_dip_delta, + ), + ) + + def _update_native_value(self) -> None: + """Update the native value with grace period logic for energy sensors.""" + if self.coordinator.panel_offline: + # Use grace period logic when offline + self._handle_offline_grace_period() + return + + # Panel is online - use normal update logic from parent class + super()._update_native_value() + + self._track_valid_state(self._attr_native_value) + + def _track_valid_state(self, value: StateType | date | Decimal | None) -> None: + """Update last valid state tracking when a numeric value is available.""" + if value is not None and isinstance(value, int | float | Decimal): + self._last_valid_state = float(value) + self._last_valid_changed = datetime.now(tz=UTC) + + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator with grace period tracking.""" + self._sync_circuit_name() + + # Update grace period from options in case it changed + self._grace_period_minutes = self.coordinator.config_entry.options.get( + ENERGY_REPORTING_GRACE_PERIOD, 15 + ) + + # Update dip compensation flag from options in case it changed + self._dip_compensation_enabled = self.coordinator.config_entry.options.get( + ENABLE_ENERGY_DIP_COMPENSATION, False + ) + + # Use the overridden _update_native_value method which handles grace period + self._update_native_value() + + # Call the parent's parent class coordinator update to avoid the intermediate parent's logic + super(SpanSensorBase, self)._handle_coordinator_update() + + def _handle_offline_grace_period(self) -> None: + """Handle grace period logic when panel is offline.""" + result = handle_offline_grace_period( + self._last_valid_state, + self._last_valid_changed, + self._attr_native_value, + coerce_grace_period_minutes(self._grace_period_minutes), + ) + self._attr_native_value = result[0] + self._last_valid_state = result[1] + self._last_valid_changed = result[2] + + @property + def extra_state_attributes(self) -> dict[str, Any] | None: + """Return additional state attributes including grace period info.""" + attributes = {} + + # Always show grace period information if we have valid tracking data + if self._last_valid_changed is not None: + if self._last_valid_state is not None: + attributes["last_valid_state"] = str(self._last_valid_state) + attributes["last_valid_changed"] = self._last_valid_changed.isoformat() + + # Calculate grace period remaining (coerce + sync back to instance) + grace_minutes = coerce_grace_period_minutes(self._grace_period_minutes) + self._grace_period_minutes = grace_minutes + if grace_minutes > 0: + time_since_last_valid = datetime.now(tz=UTC) - self._last_valid_changed + grace_period_duration = timedelta(minutes=grace_minutes) + remaining_seconds = (grace_period_duration - time_since_last_valid).total_seconds() + remaining_minutes = max(0, int(remaining_seconds / 60)) + attributes["grace_period_remaining"] = str(remaining_minutes) + + # Indicate if we're currently using grace period + panel_offline = getattr(self.coordinator, "panel_offline", False) + if panel_offline and remaining_seconds > 0: + attributes["using_grace_period"] = "True" + + # Energy dip compensation attributes + dip_attrs = build_dip_attributes( + self._energy_offset, + self._last_dip_delta, + self._is_total_increasing, + self._dip_compensation_enabled, + ) + attributes.update(dip_attrs) + + return attributes or None diff --git a/custom_components/span_panel/sensor_circuit.py b/custom_components/span_panel/sensor_circuit.py new file mode 100644 index 00000000..8e12c30c --- /dev/null +++ b/custom_components/span_panel/sensor_circuit.py @@ -0,0 +1,480 @@ +"""Circuit-level sensors for Span Panel integration.""" + +from __future__ import annotations + +from collections.abc import Mapping +import logging +from typing import Any + +from homeassistant.helpers.device_registry import DeviceInfo +from span_panel_api import SpanCircuitSnapshot, SpanPanelSnapshot + +from .const import USE_CIRCUIT_NUMBERS +from .coordinator import SpanPanelCoordinator +from .helpers import ( + construct_circuit_identifier_from_tabs, + construct_circuit_unique_id_for_entry, + construct_single_circuit_entity_id, + construct_tabs_attribute, + construct_unmapped_friendly_name, + construct_voltage_attribute, + get_user_friendly_suffix, +) +from .sensor_base import SpanEnergySensorBase, SpanSensorBase +from .sensor_definitions import SpanPanelCircuitsSensorEntityDescription + +_LOGGER: logging.Logger = logging.getLogger(__name__) + + +def _get_circuit_data_source(circuit_id: str, snapshot: SpanPanelSnapshot) -> SpanCircuitSnapshot: + """Look up a circuit in the snapshot, raising KeyError if temporarily missing.""" + circuit = snapshot.circuits.get(circuit_id) + if circuit is None: + raise KeyError(f"Circuit {circuit_id} not found in panel data") + return circuit + + +# Device types that use "Solar" as the fallback identifier when unnamed, +# matching v1 naming conventions (e.g., "Solar Power", "Solar Produced Energy"). +_SOLAR_DEVICE_TYPES: frozenset[str] = frozenset({"pv"}) + +# Device types that use "EV Charger" as the fallback identifier when unnamed. +_EVSE_DEVICE_TYPES: frozenset[str] = frozenset({"evse"}) + + +def _unnamed_circuit_fallback(circuit: SpanCircuitSnapshot, circuit_id: str) -> str: + """Return a descriptive identifier for an unnamed circuit. + + PV circuits use "Solar" (matching v1 naming), EVSE circuits use "EV Charger", + all others use tab-based naming. + """ + device_type = getattr(circuit, "device_type", "circuit") + if device_type in _SOLAR_DEVICE_TYPES: + return "Solar" + if device_type in _EVSE_DEVICE_TYPES: + return "EV Charger" + return construct_circuit_identifier_from_tabs(circuit.tabs, circuit_id) + + +def _resolve_circuit_identifier( + circuit: SpanCircuitSnapshot, + circuit_id: str, + options: Mapping[str, Any], +) -> str | None: + """Resolve the circuit identifier respecting user naming preference. + + Returns None when the circuit has no name and user is in friendly-name mode, + matching v1 behavior where HA handles default naming. + """ + use_circuit_numbers = options.get(USE_CIRCUIT_NUMBERS, False) + + if use_circuit_numbers: + return construct_circuit_identifier_from_tabs(circuit.tabs, circuit_id) + + name: str = circuit.name + if name: + return name + + return None + + +def _resolve_circuit_identifier_for_sync(circuit: SpanCircuitSnapshot, circuit_id: str) -> str: + """Resolve the circuit identifier for name-sync (always panel name, with fallback).""" + name: str = circuit.name + if name: + return name + return _unnamed_circuit_fallback(circuit, circuit_id) + + +class SpanCircuitPowerSensor( + SpanSensorBase[SpanPanelCircuitsSensorEntityDescription, SpanCircuitSnapshot] +): + """Circuit power/current/breaker-rating sensor with extra state attributes.""" + + def __init__( + self, + data_coordinator: SpanPanelCoordinator, + description: SpanPanelCircuitsSensorEntityDescription, + snapshot: SpanPanelSnapshot, + circuit_id: str, + device_info_override: DeviceInfo | None = None, + ) -> None: + """Initialize the enhanced circuit power sensor.""" + self.circuit_id = circuit_id + self.original_key = description.key + self._is_sub_device = device_info_override is not None + + # Override the description key to use the circuit_id for data lookup + description_with_circuit = SpanPanelCircuitsSensorEntityDescription( + key=circuit_id, + name=description.name, + native_unit_of_measurement=description.native_unit_of_measurement, + state_class=description.state_class, + suggested_display_precision=description.suggested_display_precision, + device_class=description.device_class, + value_fn=description.value_fn, + entity_registry_enabled_default=description.entity_registry_enabled_default, + entity_registry_visible_default=description.entity_registry_visible_default, + entity_category=description.entity_category, + ) + + super().__init__(data_coordinator, description_with_circuit, snapshot) + + if device_info_override is not None: + self._attr_device_info = device_info_override + + # Map original description keys to API keys for unique ID generation + _API_KEY_MAP: dict[str, str] = { + "circuit_power": "instantPowerW", + "circuit_current": "current", + "circuit_breaker_rating": "breaker_rating", + } + + def _generate_unique_id( + self, + snapshot: SpanPanelSnapshot, + description: SpanPanelCircuitsSensorEntityDescription, + ) -> str: + """Generate unique ID for circuit power sensors.""" + api_key = self._API_KEY_MAP.get(self.original_key, self.original_key) + return construct_circuit_unique_id_for_entry( + self.coordinator, snapshot, self.circuit_id, api_key, self._device_name + ) + + def _generate_friendly_name( + self, + snapshot: SpanPanelSnapshot, + description: SpanPanelCircuitsSensorEntityDescription, + ) -> str | None: + """Generate friendly name for circuit power sensors based on user preferences. + + Returns None when circuit has no name in friendly-name mode, + matching v1 behavior where HA handles default naming. + For sub-device sensors (EVSE), returns just the description name + since the device name already provides circuit context. + """ + if self._is_sub_device: + return str(description.name or "Sensor") + + circuit = snapshot.circuits.get(self.circuit_id) + if not circuit: + return construct_unmapped_friendly_name( + self.circuit_id, str(description.name or "Sensor") + ) + + circuit_identifier = _resolve_circuit_identifier( + circuit, self.circuit_id, self.coordinator.config_entry.options + ) + if circuit_identifier is None: + return None + return f"{circuit_identifier} {description.name or 'Sensor'}" + + def _generate_panel_name( + self, + snapshot: SpanPanelSnapshot, + description: SpanPanelCircuitsSensorEntityDescription, + ) -> str: + """Generate panel name for circuit sensors (always uses panel circuit name).""" + if self._is_sub_device: + return str(description.name or "Sensor") + + circuit = snapshot.circuits.get(self.circuit_id) + if not circuit: + return construct_unmapped_friendly_name( + self.circuit_id, str(description.name or "Sensor") + ) + + circuit_identifier = _resolve_circuit_identifier_for_sync(circuit, self.circuit_id) + return f"{circuit_identifier} {description.name or 'Sensor'}" + + def _construct_entity_id( + self, + snapshot: SpanPanelSnapshot, + description: SpanPanelCircuitsSensorEntityDescription, + existing_entity_id: str | None = None, + ) -> str | None: + """Construct explicit entity_id for circuit power sensors.""" + circuit = snapshot.circuits.get(self.circuit_id) + if not circuit: + return None + suffix = get_user_friendly_suffix( + self._API_KEY_MAP.get(self.original_key, self.original_key) + ) + return construct_single_circuit_entity_id( + self.coordinator, + snapshot, + "sensor", + suffix, + circuit, + unique_id=self._attr_unique_id if existing_entity_id else None, + ) + + def get_data_source(self, snapshot: SpanPanelSnapshot) -> SpanCircuitSnapshot: + """Get the data source for the circuit power sensor.""" + return _get_circuit_data_source(self.circuit_id, snapshot) + + @property + def extra_state_attributes(self) -> dict[str, Any] | None: + """Return additional state attributes.""" + if not self.coordinator.data: + return None + + circuit = self.coordinator.data.circuits.get(self.circuit_id) + if not circuit: + return None + + attributes: dict[str, Any] = {} + + # Panel position (tabs) + tabs_result = construct_tabs_attribute(circuit) + if tabs_result is not None: + attributes["tabs"] = tabs_result + + # Voltage derived from tab count + voltage = construct_voltage_attribute(circuit) or 240 + attributes["voltage"] = voltage + + attributes["always_on"] = circuit.always_on + attributes["relay_state"] = circuit.relay_state + attributes["relay_requester"] = circuit.relay_requester + attributes["shed_priority"] = circuit.priority + attributes["is_sheddable"] = circuit.is_sheddable + + return attributes + + +class SpanCircuitEnergySensor( + SpanEnergySensorBase[SpanPanelCircuitsSensorEntityDescription, SpanCircuitSnapshot] +): + """Circuit energy sensor with grace period tracking.""" + + def __init__( + self, + data_coordinator: SpanPanelCoordinator, + description: SpanPanelCircuitsSensorEntityDescription, + snapshot: SpanPanelSnapshot, + circuit_id: str, + device_info_override: DeviceInfo | None = None, + ) -> None: + """Initialize the circuit energy sensor.""" + self.circuit_id = circuit_id + self.original_key = description.key + self._is_sub_device = device_info_override is not None + + # Override the description key to use the circuit_id for data lookup + description_with_circuit = SpanPanelCircuitsSensorEntityDescription( + key=circuit_id, + name=description.name, + native_unit_of_measurement=description.native_unit_of_measurement, + state_class=description.state_class, + suggested_display_precision=description.suggested_display_precision, + device_class=description.device_class, + value_fn=description.value_fn, + entity_registry_enabled_default=description.entity_registry_enabled_default, + entity_registry_visible_default=description.entity_registry_visible_default, + ) + + super().__init__(data_coordinator, description_with_circuit, snapshot) + + if device_info_override is not None: + self._attr_device_info = device_info_override + + async def async_added_to_hass(self) -> None: + """Register consumed/produced sensors on the coordinator for net energy lookup.""" + await super().async_added_to_hass() + energy_type = self._ENERGY_TYPE_MAP.get(self.original_key) + if energy_type: + self.coordinator.register_circuit_energy_sensor(self.circuit_id, energy_type, self) + + def _generate_unique_id( + self, + snapshot: SpanPanelSnapshot, + description: SpanPanelCircuitsSensorEntityDescription, + ) -> str: + """Generate unique ID for circuit energy sensors.""" + # Map new description keys to original API keys that migration normalized from + api_key_mapping = { + "circuit_energy_produced": "producedEnergyWh", + "circuit_energy_consumed": "consumedEnergyWh", + "circuit_energy_net": "netEnergyWh", + } + api_key = api_key_mapping.get(self.original_key, self.original_key) + return construct_circuit_unique_id_for_entry( + self.coordinator, snapshot, self.circuit_id, api_key, self._device_name + ) + + def _generate_friendly_name( + self, + snapshot: SpanPanelSnapshot, + description: SpanPanelCircuitsSensorEntityDescription, + ) -> str | None: + """Generate friendly name for circuit energy sensors based on user preferences. + + Returns None when circuit has no name in friendly-name mode, + matching v1 behavior where HA handles default naming. + For sub-device sensors (EVSE), returns just the description name + since the device name already provides circuit context. + """ + if self._is_sub_device: + return str(description.name) + + circuit = snapshot.circuits.get(self.circuit_id) + if not circuit: + return f"Circuit {self.circuit_id} {description.name}" + + circuit_identifier = _resolve_circuit_identifier( + circuit, self.circuit_id, self.coordinator.config_entry.options + ) + if circuit_identifier is None: + return None + return f"{circuit_identifier} {description.name}" + + def _generate_panel_name( + self, + snapshot: SpanPanelSnapshot, + description: SpanPanelCircuitsSensorEntityDescription, + ) -> str: + """Generate panel name for circuit energy sensors (always uses panel circuit name).""" + if self._is_sub_device: + return str(description.name) + + circuit = snapshot.circuits.get(self.circuit_id) + if not circuit: + return f"Circuit {self.circuit_id} {description.name}" + + circuit_identifier = _resolve_circuit_identifier_for_sync(circuit, self.circuit_id) + return f"{circuit_identifier} {description.name}" + + def _construct_entity_id( + self, + snapshot: SpanPanelSnapshot, + description: SpanPanelCircuitsSensorEntityDescription, + existing_entity_id: str | None = None, + ) -> str | None: + """Construct explicit entity_id for circuit energy sensors.""" + circuit = snapshot.circuits.get(self.circuit_id) + if not circuit: + return None + api_key_mapping = { + "circuit_energy_produced": "producedEnergyWh", + "circuit_energy_consumed": "consumedEnergyWh", + "circuit_energy_net": "netEnergyWh", + } + api_key = api_key_mapping.get(self.original_key, self.original_key) + suffix = get_user_friendly_suffix(api_key) + return construct_single_circuit_entity_id( + self.coordinator, + snapshot, + "sensor", + suffix, + circuit, + unique_id=self._attr_unique_id if existing_entity_id else None, + ) + + # Map original_key to the energy type used for coordinator dip offset tracking + _ENERGY_TYPE_MAP: dict[str, str] = { + "circuit_energy_consumed": "consumed", + "circuit_energy_produced": "produced", + } + + def _process_raw_value(self, raw_value: float | str | None) -> None: + """Process raw value, adjusting net energy for dip compensation consistency. + + Consumed/produced sensors apply dip offsets via the base class. The net + energy sensor reads those offsets from the registered sibling sensors + so its value stays equal to compensated_consumed - compensated_produced. + """ + super()._process_raw_value(raw_value) + + if self.original_key == "circuit_energy_net" and isinstance(self._attr_native_value, float): + consumed_offset = self.coordinator.get_circuit_dip_offset(self.circuit_id, "consumed") + produced_offset = self.coordinator.get_circuit_dip_offset(self.circuit_id, "produced") + net_adjustment = consumed_offset - produced_offset + if net_adjustment: + self._attr_native_value += net_adjustment + + def get_data_source(self, snapshot: SpanPanelSnapshot) -> SpanCircuitSnapshot: + """Get the data source for the circuit energy sensor.""" + return _get_circuit_data_source(self.circuit_id, snapshot) + + @property + def extra_state_attributes(self) -> dict[str, Any] | None: + """Return additional state attributes including grace period and circuit info.""" + # Get base grace period attributes + base_attributes = super().extra_state_attributes or {} + attributes = dict(base_attributes) + + # Add circuit-specific attributes if we have data + if self.coordinator.data: + circuit = self.coordinator.data.circuits.get(self.circuit_id) + + if circuit: + tabs = construct_tabs_attribute(circuit) + if tabs is not None: + attributes["tabs"] = tabs + + voltage = construct_voltage_attribute(circuit) or 240 + attributes["voltage"] = voltage + + return attributes or None + + +class SpanUnmappedCircuitSensor( + SpanSensorBase[SpanPanelCircuitsSensorEntityDescription, SpanCircuitSnapshot] +): + """Span Panel unmapped circuit sensor entity - native sensors for synthetic calculations.""" + + def __init__( + self, + data_coordinator: SpanPanelCoordinator, + description: SpanPanelCircuitsSensorEntityDescription, + snapshot: SpanPanelSnapshot, + circuit_id: str, + ) -> None: + """Initialize the Span Panel unmapped circuit sensor.""" + self.circuit_id = circuit_id + # Store the original description key for unique ID and entity ID generation + self.original_key = description.key + + # Override the description key to use the circuit_id for data lookup + description_with_circuit = SpanPanelCircuitsSensorEntityDescription( + key=circuit_id, + name=description.name, + native_unit_of_measurement=description.native_unit_of_measurement, + state_class=description.state_class, + suggested_display_precision=description.suggested_display_precision, + device_class=description.device_class, + value_fn=description.value_fn, + entity_registry_enabled_default=True, + entity_registry_visible_default=False, + ) + + super().__init__(data_coordinator, description_with_circuit, snapshot) + + def _generate_unique_id( + self, + snapshot: SpanPanelSnapshot, + description: SpanPanelCircuitsSensorEntityDescription, + ) -> str: + """Generate unique ID for unmapped circuit sensors.""" + return construct_circuit_unique_id_for_entry( + self.coordinator, + snapshot, + self.circuit_id, + self.original_key, + self._device_name, + ) + + def _generate_friendly_name( + self, + snapshot: SpanPanelSnapshot, + description: SpanPanelCircuitsSensorEntityDescription, + ) -> str: + """Generate friendly name for unmapped circuit sensors.""" + tab_number = self.circuit_id.replace("unmapped_tab_", "") + description_name = str(description.name) if description.name else "Sensor" + return construct_unmapped_friendly_name(tab_number, description_name) + + def get_data_source(self, snapshot: SpanPanelSnapshot) -> SpanCircuitSnapshot: + """Get the data source for the unmapped circuit sensor.""" + return _get_circuit_data_source(self.circuit_id, snapshot) diff --git a/custom_components/span_panel/sensor_definitions.py b/custom_components/span_panel/sensor_definitions.py index 7a94fed7..e94d3ac6 100644 --- a/custom_components/span_panel/sensor_definitions.py +++ b/custom_components/span_panel/sensor_definitions.py @@ -1,12 +1,12 @@ """Sensor definitions for SPAN Panel integration. -This file contains sensor definitions for NATIVE integration sensors only: -- Panel status sensors (DSM state, grid state, current run config, main relay state) +This file contains sensor definitions for all native integration sensors: +- Panel status sensors (grid state, run config, relay state, dominant power source, vendor cloud) - Hardware status sensors (software version) -- Unmapped circuit sensors (power, energy for unmapped breaker positions - invisible backing data) - -SYNTHETIC sensors (panel power, circuit power/energy, solar, battery) are now defined -in YAML templates in yaml_templates/ directory and created via ha-synthetic-sensors package. +- Panel power and energy sensors (grid, feedthrough, battery, site) +- Circuit power and energy sensors +- Unmapped circuit sensors (invisible backing data) +- Battery sensor """ from __future__ import annotations @@ -19,24 +19,27 @@ SensorEntityDescription, SensorStateClass, ) -from homeassistant.const import PERCENTAGE, UnitOfEnergy, UnitOfPower - -from .const import ( - CIRCUITS_ENERGY_CONSUMED, - CIRCUITS_ENERGY_PRODUCED, - CIRCUITS_POWER, +from homeassistant.const import ( + PERCENTAGE, + UnitOfElectricCurrent, + UnitOfElectricPotential, + UnitOfEnergy, + UnitOfPower, +) +from homeassistant.helpers.entity import EntityCategory +from span_panel_api import ( + SpanBatterySnapshot, + SpanCircuitSnapshot, + SpanEvseSnapshot, + SpanPanelSnapshot, ) -from .span_panel_circuit import SpanPanelCircuit -from .span_panel_data import SpanPanelData -from .span_panel_hardware_status import SpanPanelHardwareStatus -from .span_panel_storage_battery import SpanPanelStorageBattery @dataclass(frozen=True) class SpanPanelCircuitsRequiredKeysMixin: """Required keys mixin for Span Panel circuit sensors.""" - value_fn: Callable[[SpanPanelCircuit], float] + value_fn: Callable[[SpanCircuitSnapshot], float | None] @dataclass(frozen=True) @@ -50,7 +53,7 @@ class SpanPanelCircuitsSensorEntityDescription( class SpanPanelDataRequiredKeysMixin: """Required keys mixin for Span Panel data sensors.""" - value_fn: Callable[[SpanPanelData], float | str] + value_fn: Callable[[SpanPanelSnapshot], float | str | None] @dataclass(frozen=True) @@ -62,7 +65,7 @@ class SpanPanelDataSensorEntityDescription(SensorEntityDescription, SpanPanelDat class SpanPanelStatusRequiredKeysMixin: """Required keys mixin for Span Panel status sensors.""" - value_fn: Callable[[SpanPanelHardwareStatus], str] + value_fn: Callable[[SpanPanelSnapshot], str] @dataclass(frozen=True) @@ -76,7 +79,7 @@ class SpanPanelStatusSensorEntityDescription( class SpanPanelBatteryRequiredKeysMixin: """Required keys mixin for Span Panel battery sensors.""" - value_fn: Callable[[SpanPanelStorageBattery], int] + value_fn: Callable[[SpanBatterySnapshot], float | None] @dataclass(frozen=True) @@ -86,121 +89,443 @@ class SpanPanelBatterySensorEntityDescription( """Describes a Span Panel battery sensor entity.""" -# Panel data status sensor definitions (native sensors) +# Panel data status sensor definitions PANEL_DATA_STATUS_SENSORS: tuple[ SpanPanelDataSensorEntityDescription, SpanPanelDataSensorEntityDescription, SpanPanelDataSensorEntityDescription, SpanPanelDataSensorEntityDescription, + SpanPanelDataSensorEntityDescription, + SpanPanelDataSensorEntityDescription, ] = ( SpanPanelDataSensorEntityDescription( key="dsm_state", - name="DSM State", - value_fn=lambda panel_data: panel_data.dsm_state, + translation_key="dsm_state", + device_class=SensorDeviceClass.ENUM, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + options=["unknown"], + value_fn=lambda s: s.dsm_state, ), SpanPanelDataSensorEntityDescription( key="dsm_grid_state", - name="DSM Grid State", - value_fn=lambda panel_data: panel_data.dsm_grid_state, + translation_key="dsm_grid_state", + device_class=SensorDeviceClass.ENUM, + entity_category=EntityCategory.DIAGNOSTIC, + options=["unknown"], + value_fn=lambda s: s.dsm_state, # deprecated alias — reads dsm_state ), SpanPanelDataSensorEntityDescription( key="current_run_config", - name="Current Run Config", - value_fn=lambda panel_data: panel_data.current_run_config, + translation_key="current_run_config", + device_class=SensorDeviceClass.ENUM, + entity_category=EntityCategory.DIAGNOSTIC, + options=["unknown"], + value_fn=lambda s: s.current_run_config, ), SpanPanelDataSensorEntityDescription( key="main_relay_state", - name="Main Relay State", - value_fn=lambda panel_data: panel_data.main_relay_state, + translation_key="main_relay_state", + device_class=SensorDeviceClass.ENUM, + entity_category=EntityCategory.DIAGNOSTIC, + options=["unknown"], + value_fn=lambda s: s.main_relay_state, + ), + SpanPanelDataSensorEntityDescription( + key="grid_forming_entity", + translation_key="grid_forming_entity", + device_class=SensorDeviceClass.ENUM, + entity_category=EntityCategory.DIAGNOSTIC, + options=["unknown"], + value_fn=lambda s: s.dominant_power_source or "unknown", + ), + SpanPanelDataSensorEntityDescription( + key="vendor_cloud", + translation_key="vendor_cloud", + device_class=SensorDeviceClass.ENUM, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + options=["unknown"], + value_fn=lambda s: s.vendor_cloud or "unknown", ), ) -# Hardware status sensor definitions (native sensors) +# Hardware status sensor definitions STATUS_SENSORS: tuple[SpanPanelStatusSensorEntityDescription,] = ( SpanPanelStatusSensorEntityDescription( key="software_version", - name="Software Version", - value_fn=lambda status: getattr(status, "firmware_version", "Unknown"), + translation_key="software_version", + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda s: s.firmware_version, ), ) -# Unmapped circuit sensor definitions (native sensors - invisible backing data for synthetics) +# Unmapped circuit sensor definitions (invisible backing data) +# Keys are inline string literals preserving the v1 camelCase values for unique_id stability UNMAPPED_SENSORS: tuple[ SpanPanelCircuitsSensorEntityDescription, SpanPanelCircuitsSensorEntityDescription, SpanPanelCircuitsSensorEntityDescription, ] = ( SpanPanelCircuitsSensorEntityDescription( - key=CIRCUITS_POWER, + key="instantPowerW", name="Power", native_unit_of_measurement=UnitOfPower.WATT, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=2, device_class=SensorDeviceClass.POWER, - value_fn=lambda circuit: circuit.instant_power, - entity_registry_enabled_default=True, # Enabled but invisible - entity_registry_visible_default=False, # Hidden from UI + value_fn=lambda c: c.instant_power_w, + entity_registry_enabled_default=True, + entity_registry_visible_default=False, ), SpanPanelCircuitsSensorEntityDescription( - key=CIRCUITS_ENERGY_PRODUCED, + key="producedEnergyWh", name="Produced Energy", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, suggested_display_precision=2, device_class=SensorDeviceClass.ENERGY, - value_fn=lambda circuit: circuit.produced_energy, - entity_registry_enabled_default=True, # Enabled but invisible - entity_registry_visible_default=False, # Hidden from UI + value_fn=lambda c: c.produced_energy_wh, + entity_registry_enabled_default=True, + entity_registry_visible_default=False, ), SpanPanelCircuitsSensorEntityDescription( - key=CIRCUITS_ENERGY_CONSUMED, + key="consumedEnergyWh", name="Consumed Energy", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, suggested_display_precision=2, device_class=SensorDeviceClass.ENERGY, - value_fn=lambda circuit: circuit.consumed_energy, - entity_registry_enabled_default=True, # Enabled but invisible - entity_registry_visible_default=False, # Hidden from UI + value_fn=lambda c: c.consumed_energy_wh, + entity_registry_enabled_default=True, + entity_registry_visible_default=False, ), ) -# Battery sensor definition (native sensor - conditionally created) +# Battery sensor definition (conditionally created when battery data available) BATTERY_SENSOR: SpanPanelBatterySensorEntityDescription = SpanPanelBatterySensorEntityDescription( key="storage_battery_percentage", - name="Battery Level", + translation_key="battery_level", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, device_class=SensorDeviceClass.BATTERY, - value_fn=lambda battery: battery.storage_battery_percentage, + value_fn=lambda b: b.soe_percentage, +) + +# --------------------------------------------------------------------------- +# Panel diagnostic sensors (promoted from attributes) +# --------------------------------------------------------------------------- + +# L1/L2 voltage sensors (v2 only, conditionally created) +L1_VOLTAGE_SENSOR: SpanPanelDataSensorEntityDescription = SpanPanelDataSensorEntityDescription( + key="l1_voltage", + translation_key="l1_voltage", + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + suggested_display_precision=1, + value_fn=lambda s: s.l1_voltage, +) + +L2_VOLTAGE_SENSOR: SpanPanelDataSensorEntityDescription = SpanPanelDataSensorEntityDescription( + key="l2_voltage", + translation_key="l2_voltage", + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + suggested_display_precision=1, + value_fn=lambda s: s.l2_voltage, +) + +# Upstream/downstream lug current sensors (v2 only, conditionally created) +UPSTREAM_L1_CURRENT_SENSOR: SpanPanelDataSensorEntityDescription = ( + SpanPanelDataSensorEntityDescription( + key="upstream_l1_current", + translation_key="upstream_l1_current", + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + suggested_display_precision=2, + value_fn=lambda s: s.upstream_l1_current_a, + ) +) + +UPSTREAM_L2_CURRENT_SENSOR: SpanPanelDataSensorEntityDescription = ( + SpanPanelDataSensorEntityDescription( + key="upstream_l2_current", + translation_key="upstream_l2_current", + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + suggested_display_precision=2, + value_fn=lambda s: s.upstream_l2_current_a, + ) +) + +DOWNSTREAM_L1_CURRENT_SENSOR: SpanPanelDataSensorEntityDescription = ( + SpanPanelDataSensorEntityDescription( + key="downstream_l1_current", + translation_key="downstream_l1_current", + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + entity_category=EntityCategory.DIAGNOSTIC, + suggested_display_precision=2, + value_fn=lambda s: s.downstream_l1_current_a, + ) ) -# Panel power and energy sensor definitions (native sensors to replace synthetic ones) +DOWNSTREAM_L2_CURRENT_SENSOR: SpanPanelDataSensorEntityDescription = ( + SpanPanelDataSensorEntityDescription( + key="downstream_l2_current", + translation_key="downstream_l2_current", + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + entity_category=EntityCategory.DIAGNOSTIC, + suggested_display_precision=2, + value_fn=lambda s: s.downstream_l2_current_a, + ) +) + +# Main breaker rating sensor (v2 only, conditionally created) +MAIN_BREAKER_RATING_SENSOR: SpanPanelDataSensorEntityDescription = ( + SpanPanelDataSensorEntityDescription( + key="main_breaker_rating", + translation_key="main_breaker_rating", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda s: s.main_breaker_rating_a, + ) +) + +# --------------------------------------------------------------------------- +# Circuit diagnostic sensors (promoted from attributes) +# --------------------------------------------------------------------------- + +# Per-circuit current sensor (v2 only, conditionally created) +CIRCUIT_CURRENT_SENSOR: SpanPanelCircuitsSensorEntityDescription = ( + SpanPanelCircuitsSensorEntityDescription( + key="circuit_current", + name="Current", + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + suggested_display_precision=2, + value_fn=lambda c: c.current_a, + ) +) + +# Per-circuit breaker rating sensor (v2 only, conditionally created) +CIRCUIT_BREAKER_RATING_SENSOR: SpanPanelCircuitsSensorEntityDescription = ( + SpanPanelCircuitsSensorEntityDescription( + key="circuit_breaker_rating", + name="Breaker Rating", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda c: c.breaker_rating_a, + ) +) + +# --------------------------------------------------------------------------- +# BESS metadata sensors (on BESS sub-device) +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class SpanBessMetadataRequiredKeysMixin: + """Required keys mixin for BESS metadata sensors.""" + + value_fn: Callable[[SpanBatterySnapshot], float | str | None] + + +@dataclass(frozen=True) +class SpanBessMetadataSensorEntityDescription( + SensorEntityDescription, SpanBessMetadataRequiredKeysMixin +): + """Describes a BESS metadata sensor entity.""" + + +BESS_METADATA_SENSORS: tuple[ + SpanBessMetadataSensorEntityDescription, + SpanBessMetadataSensorEntityDescription, + SpanBessMetadataSensorEntityDescription, + SpanBessMetadataSensorEntityDescription, + SpanBessMetadataSensorEntityDescription, + SpanBessMetadataSensorEntityDescription, +] = ( + SpanBessMetadataSensorEntityDescription( + key="vendor", + translation_key="bess_vendor", + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda b: b.vendor_name, + ), + SpanBessMetadataSensorEntityDescription( + key="model", + translation_key="bess_model", + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda b: b.product_name, + ), + SpanBessMetadataSensorEntityDescription( + key="serial_number", + translation_key="bess_serial_number", + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda b: b.serial_number, + ), + SpanBessMetadataSensorEntityDescription( + key="firmware_version", + translation_key="bess_firmware_version", + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda b: b.software_version, + ), + SpanBessMetadataSensorEntityDescription( + key="nameplate_capacity", + translation_key="bess_nameplate_capacity", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda b: b.nameplate_capacity_kwh, + ), + SpanBessMetadataSensorEntityDescription( + key="soe_kwh", + translation_key="bess_soe_kwh", + device_class=SensorDeviceClass.ENERGY_STORAGE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + entity_category=EntityCategory.DIAGNOSTIC, + suggested_display_precision=2, + value_fn=lambda b: b.soe_kwh, + ), +) + +# --------------------------------------------------------------------------- +# PV metadata sensors (on main panel device) +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class SpanPVMetadataRequiredKeysMixin: + """Required keys mixin for PV metadata sensors.""" + + value_fn: Callable[[SpanPanelSnapshot], float | str | None] + + +@dataclass(frozen=True) +class SpanPVMetadataSensorEntityDescription( + SensorEntityDescription, SpanPVMetadataRequiredKeysMixin +): + """Describes a PV metadata sensor entity.""" + + +PV_METADATA_SENSORS: tuple[ + SpanPVMetadataSensorEntityDescription, + SpanPVMetadataSensorEntityDescription, + SpanPVMetadataSensorEntityDescription, +] = ( + SpanPVMetadataSensorEntityDescription( + key="pv_vendor", + translation_key="pv_vendor", + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda s: s.pv.vendor_name, + ), + SpanPVMetadataSensorEntityDescription( + key="pv_product", + translation_key="pv_product", + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda s: s.pv.product_name, + ), + SpanPVMetadataSensorEntityDescription( + key="pv_nameplate_capacity", + translation_key="pv_nameplate_capacity", + native_unit_of_measurement=UnitOfPower.WATT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda s: s.pv.nameplate_capacity_w, + ), +) + + +# Panel power sensor definitions PANEL_POWER_SENSORS: tuple[ SpanPanelDataSensorEntityDescription, SpanPanelDataSensorEntityDescription, ] = ( SpanPanelDataSensorEntityDescription( key="instantGridPowerW", - name="Current Power", + translation_key="instant_grid_power", native_unit_of_measurement=UnitOfPower.WATT, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, device_class=SensorDeviceClass.POWER, - value_fn=lambda panel_data: panel_data.instant_grid_power, + value_fn=lambda s: s.instant_grid_power_w, ), SpanPanelDataSensorEntityDescription( key="feedthroughPowerW", - name="Feed Through Power", + translation_key="feedthrough_power", native_unit_of_measurement=UnitOfPower.WATT, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, device_class=SensorDeviceClass.POWER, - value_fn=lambda panel_data: panel_data.feedthrough_power, + value_fn=lambda s: s.feedthrough_power_w, ), ) +# Battery power sensor (conditionally created when BESS is commissioned) +BATTERY_POWER_SENSOR: SpanPanelDataSensorEntityDescription = SpanPanelDataSensorEntityDescription( + key="batteryPowerW", + translation_key="battery_power", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, + device_class=SensorDeviceClass.POWER, + value_fn=lambda s: (-s.power_flow_battery or 0.0) if s.power_flow_battery is not None else 0.0, +) + +# PV power sensor (conditionally created when PV is commissioned) +PV_POWER_SENSOR: SpanPanelDataSensorEntityDescription = SpanPanelDataSensorEntityDescription( + key="pvPowerW", + translation_key="pv_power", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, + device_class=SensorDeviceClass.POWER, + value_fn=lambda s: (-s.power_flow_pv or 0.0) if s.power_flow_pv is not None else 0.0, +) + +# Grid power flow sensor (conditionally created when power-flows data is available) +GRID_POWER_FLOW_SENSOR: SpanPanelDataSensorEntityDescription = SpanPanelDataSensorEntityDescription( + key="gridPowerFlowW", + translation_key="grid_power_flow", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, + device_class=SensorDeviceClass.POWER, + value_fn=lambda s: (-s.power_flow_grid or 0.0) if s.power_flow_grid is not None else 0.0, +) + +# Site power sensor (conditionally created when power-flows data is available) +SITE_POWER_SENSOR: SpanPanelDataSensorEntityDescription = SpanPanelDataSensorEntityDescription( + key="sitePowerW", + translation_key="site_power", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, + device_class=SensorDeviceClass.POWER, + value_fn=lambda s: s.power_flow_site if s.power_flow_site is not None else 0.0, +) + +# Panel energy sensor definitions PANEL_ENERGY_SENSORS: tuple[ SpanPanelDataSensorEntityDescription, SpanPanelDataSensorEntityDescription, @@ -211,63 +536,67 @@ class SpanPanelBatterySensorEntityDescription( ] = ( SpanPanelDataSensorEntityDescription( key="mainMeterEnergyProducedWh", - name="Main Meter Produced Energy", + translation_key="main_meter_produced_energy", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, suggested_display_precision=2, device_class=SensorDeviceClass.ENERGY, - value_fn=lambda panel_data: panel_data.main_meter_energy_produced, + value_fn=lambda s: s.main_meter_energy_produced_wh, ), SpanPanelDataSensorEntityDescription( key="mainMeterEnergyConsumedWh", - name="Main Meter Consumed Energy", + translation_key="main_meter_consumed_energy", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, suggested_display_precision=2, device_class=SensorDeviceClass.ENERGY, - value_fn=lambda panel_data: panel_data.main_meter_energy_consumed, + value_fn=lambda s: s.main_meter_energy_consumed_wh, ), SpanPanelDataSensorEntityDescription( key="feedthroughEnergyProducedWh", - name="Feed Through Produced Energy", + translation_key="feedthrough_produced_energy", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, - state_class=SensorStateClass.TOTAL_INCREASING, + state_class=SensorStateClass.TOTAL, suggested_display_precision=2, device_class=SensorDeviceClass.ENERGY, - value_fn=lambda panel_data: panel_data.feedthrough_energy_produced, + value_fn=lambda s: s.feedthrough_energy_produced_wh, ), SpanPanelDataSensorEntityDescription( key="feedthroughEnergyConsumedWh", - name="Feed Through Consumed Energy", + translation_key="feedthrough_consumed_energy", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, - state_class=SensorStateClass.TOTAL_INCREASING, + state_class=SensorStateClass.TOTAL, suggested_display_precision=2, device_class=SensorDeviceClass.ENERGY, - value_fn=lambda panel_data: panel_data.feedthrough_energy_consumed, + value_fn=lambda s: s.feedthrough_energy_consumed_wh, ), SpanPanelDataSensorEntityDescription( key="mainMeterNetEnergyWh", - name="Main Meter Net Energy", + translation_key="main_meter_net_energy", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, state_class=SensorStateClass.TOTAL, suggested_display_precision=2, device_class=SensorDeviceClass.ENERGY, - value_fn=lambda panel_data: (panel_data.main_meter_energy_consumed or 0) - - (panel_data.main_meter_energy_produced or 0), + entity_registry_enabled_default=False, + value_fn=lambda s: ( + (s.main_meter_energy_consumed_wh or 0) - (s.main_meter_energy_produced_wh or 0) + ), ), SpanPanelDataSensorEntityDescription( key="feedthroughNetEnergyWh", - name="Feed Through Net Energy", + translation_key="feedthrough_net_energy", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, state_class=SensorStateClass.TOTAL, suggested_display_precision=2, device_class=SensorDeviceClass.ENERGY, - value_fn=lambda panel_data: (panel_data.feedthrough_energy_consumed or 0) - - (panel_data.feedthrough_energy_produced or 0), + entity_registry_enabled_default=False, + value_fn=lambda s: ( + (s.feedthrough_energy_consumed_wh or 0) - (s.feedthrough_energy_produced_wh or 0) + ), ), ) -# Circuit sensor definitions (native sensors to replace synthetic ones) +# Circuit sensor definitions CIRCUIT_SENSORS: tuple[ SpanPanelCircuitsSensorEntityDescription, SpanPanelCircuitsSensorEntityDescription, @@ -281,7 +610,9 @@ class SpanPanelBatterySensorEntityDescription( state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, device_class=SensorDeviceClass.POWER, - value_fn=lambda circuit: circuit.instant_power, + value_fn=lambda c: ( + (-c.instant_power_w or 0.0) if c.device_type == "pv" else c.instant_power_w + ), entity_registry_enabled_default=True, entity_registry_visible_default=True, ), @@ -292,7 +623,7 @@ class SpanPanelBatterySensorEntityDescription( state_class=SensorStateClass.TOTAL_INCREASING, suggested_display_precision=2, device_class=SensorDeviceClass.ENERGY, - value_fn=lambda circuit: circuit.produced_energy, + value_fn=lambda c: c.produced_energy_wh, entity_registry_enabled_default=True, entity_registry_visible_default=True, ), @@ -303,7 +634,7 @@ class SpanPanelBatterySensorEntityDescription( state_class=SensorStateClass.TOTAL_INCREASING, suggested_display_precision=2, device_class=SensorDeviceClass.ENERGY, - value_fn=lambda circuit: circuit.consumed_energy, + value_fn=lambda c: c.consumed_energy_wh, entity_registry_enabled_default=True, entity_registry_visible_default=True, ), @@ -314,72 +645,60 @@ class SpanPanelBatterySensorEntityDescription( state_class=SensorStateClass.TOTAL, suggested_display_precision=2, device_class=SensorDeviceClass.ENERGY, - value_fn=lambda circuit: (circuit.consumed_energy or 0) - (circuit.produced_energy or 0), + value_fn=lambda c: ( + (c.produced_energy_wh or 0) - (c.consumed_energy_wh or 0) + if c.device_type == "pv" + else (c.consumed_energy_wh or 0) - (c.produced_energy_wh or 0) + ), entity_registry_enabled_default=True, entity_registry_visible_default=True, ), ) -# Solar sensor definitions (native sensors to replace synthetic ones) -# These are template sensors that will be created when solar is enabled +# --------------------------------------------------------------------------- +# EVSE (EV Charger) sensor definitions +# --------------------------------------------------------------------------- + + @dataclass(frozen=True) -class SpanSolarSensorEntityDescription(SensorEntityDescription): - """Describes a solar sensor entity that combines leg1 and leg2 circuits.""" +class SpanEvseRequiredKeysMixin: + """Required keys mixin for EVSE sensors.""" + + value_fn: Callable[[SpanEvseSnapshot], float | str | None] - leg1_circuit_suffix: str = "" - leg2_circuit_suffix: str = "" - calculation_type: str = "sum" # "sum" for power, "sum" for energy + +@dataclass(frozen=True) +class SpanEvseSensorEntityDescription(SensorEntityDescription, SpanEvseRequiredKeysMixin): + """Describes an EVSE sensor entity.""" -SOLAR_SENSORS: tuple[ - SpanSolarSensorEntityDescription, - SpanSolarSensorEntityDescription, - SpanSolarSensorEntityDescription, - SpanSolarSensorEntityDescription, +EVSE_SENSORS: tuple[ + SpanEvseSensorEntityDescription, + SpanEvseSensorEntityDescription, + SpanEvseSensorEntityDescription, ] = ( - SpanSolarSensorEntityDescription( - key="solar_current_power", - name="Solar Current Power", - native_unit_of_measurement=UnitOfPower.WATT, - state_class=SensorStateClass.MEASUREMENT, - suggested_display_precision=0, - device_class=SensorDeviceClass.POWER, - leg1_circuit_suffix="power", - leg2_circuit_suffix="power", - calculation_type="sum", - ), - SpanSolarSensorEntityDescription( - key="solar_produced_energy", - name="Solar Produced Energy", - native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, - state_class=SensorStateClass.TOTAL_INCREASING, - suggested_display_precision=2, - device_class=SensorDeviceClass.ENERGY, - leg1_circuit_suffix="produced_energy", - leg2_circuit_suffix="produced_energy", - calculation_type="sum", + SpanEvseSensorEntityDescription( + key="evse_status", + translation_key="evse_status", + device_class=SensorDeviceClass.ENUM, + options=["unknown"], + value_fn=lambda e: e.status or "unknown", ), - SpanSolarSensorEntityDescription( - key="solar_consumed_energy", - name="Solar Consumed Energy", - native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, - state_class=SensorStateClass.TOTAL_INCREASING, - suggested_display_precision=2, - device_class=SensorDeviceClass.ENERGY, - leg1_circuit_suffix="consumed_energy", - leg2_circuit_suffix="consumed_energy", - calculation_type="sum", + SpanEvseSensorEntityDescription( + key="evse_advertised_current", + translation_key="evse_advertised_current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.CURRENT, + suggested_display_precision=1, + value_fn=lambda e: e.advertised_current_a, ), - SpanSolarSensorEntityDescription( - key="solar_net_energy", - name="Solar Net Energy", - native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, - state_class=SensorStateClass.TOTAL, - suggested_display_precision=2, - device_class=SensorDeviceClass.ENERGY, - leg1_circuit_suffix="net_energy", - leg2_circuit_suffix="net_energy", - calculation_type="sum", + SpanEvseSensorEntityDescription( + key="evse_lock_state", + translation_key="evse_lock_state", + device_class=SensorDeviceClass.ENUM, + options=["unknown"], + value_fn=lambda e: e.lock_state or "unknown", ), ) diff --git a/custom_components/span_panel/sensor_evse.py b/custom_components/span_panel/sensor_evse.py new file mode 100644 index 00000000..f45d6a66 --- /dev/null +++ b/custom_components/span_panel/sensor_evse.py @@ -0,0 +1,72 @@ +"""EVSE (EV Charger) sensors for Span Panel integration.""" + +from __future__ import annotations + +import logging + +from span_panel_api import SpanEvseSnapshot, SpanPanelSnapshot + +from .const import CONF_DEVICE_NAME, USE_CIRCUIT_NUMBERS +from .coordinator import SpanPanelCoordinator +from .helpers import build_evse_unique_id_for_entry, resolve_evse_display_suffix +from .sensor_base import SpanSensorBase +from .sensor_definitions import SpanEvseSensorEntityDescription +from .util import evse_device_info + +_LOGGER: logging.Logger = logging.getLogger(__name__) + +# Fallback EVSE snapshot used when the EVSE disappears mid-session +_EMPTY_EVSE = SpanEvseSnapshot(node_id="", feed_circuit_id="") + + +class SpanEvseSensor(SpanSensorBase[SpanEvseSensorEntityDescription, SpanEvseSnapshot]): + """EVSE (EV charger) sensor entity.""" + + def __init__( + self, + data_coordinator: SpanPanelCoordinator, + description: SpanEvseSensorEntityDescription, + snapshot: SpanPanelSnapshot, + evse_id: str, + ) -> None: + """Initialize the EVSE sensor.""" + self._evse_id = evse_id + super().__init__(data_coordinator, description, snapshot) + + # Override device_info to point to EVSE sub-device instead of panel + panel_name = ( + data_coordinator.config_entry.data.get( + CONF_DEVICE_NAME, data_coordinator.config_entry.title + ) + or "Span Panel" + ) + panel_identifier = snapshot.serial_number + + evse = snapshot.evse.get(evse_id, _EMPTY_EVSE) + use_circuit_numbers = data_coordinator.config_entry.options.get(USE_CIRCUIT_NUMBERS, False) + display_suffix = resolve_evse_display_suffix(evse, snapshot, use_circuit_numbers) + self._attr_device_info = evse_device_info( + panel_identifier, evse, panel_name, display_suffix + ) + + def _generate_unique_id( + self, snapshot: SpanPanelSnapshot, description: SpanEvseSensorEntityDescription + ) -> str: + """Generate unique ID for EVSE sensors.""" + return build_evse_unique_id_for_entry( + self.coordinator, + snapshot, + self._evse_id, + description.key, + self._device_name, + ) + + def _generate_friendly_name( + self, snapshot: SpanPanelSnapshot, description: SpanEvseSensorEntityDescription + ) -> str: + """Generate friendly name for EVSE sensors.""" + return str(description.name) + + def get_data_source(self, snapshot: SpanPanelSnapshot) -> SpanEvseSnapshot: + """Get the EVSE snapshot for this sensor's charger.""" + return snapshot.evse.get(self._evse_id, _EMPTY_EVSE) diff --git a/custom_components/span_panel/sensor_panel.py b/custom_components/span_panel/sensor_panel.py new file mode 100644 index 00000000..da07c49a --- /dev/null +++ b/custom_components/span_panel/sensor_panel.py @@ -0,0 +1,358 @@ +"""Panel-level sensors for Span Panel integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.typing import UNDEFINED +from span_panel_api import SpanBatterySnapshot, SpanPanelSnapshot + +from .coordinator import SpanPanelCoordinator +from .helpers import ( + build_bess_unique_id_for_entry, + construct_panel_unique_id_for_entry, + construct_synthetic_unique_id_for_entry, + get_panel_entity_suffix, +) +from .sensor_base import SpanEnergySensorBase, SpanSensorBase +from .sensor_definitions import ( + SpanBessMetadataSensorEntityDescription, + SpanPanelBatterySensorEntityDescription, + SpanPanelDataSensorEntityDescription, + SpanPanelStatusSensorEntityDescription, + SpanPVMetadataSensorEntityDescription, +) + +_LOGGER: logging.Logger = logging.getLogger(__name__) + + +class SpanPanelPanelStatus(SpanSensorBase[SpanPanelDataSensorEntityDescription, SpanPanelSnapshot]): + """Span Panel data status sensor entity.""" + + def __init__( + self, + data_coordinator: SpanPanelCoordinator, + description: SpanPanelDataSensorEntityDescription, + snapshot: SpanPanelSnapshot, + ) -> None: + """Initialize the Span Panel data status sensor.""" + super().__init__(data_coordinator, description, snapshot) + + def _generate_unique_id( + self, + snapshot: SpanPanelSnapshot, + description: SpanPanelDataSensorEntityDescription, + ) -> str: + """Generate unique ID for panel data sensors.""" + return construct_panel_unique_id_for_entry( + self.coordinator, snapshot, description.key, self._device_name + ) + + def _generate_friendly_name( + self, + snapshot: SpanPanelSnapshot, + description: SpanPanelDataSensorEntityDescription, + ) -> str: + """Generate friendly name for panel data sensors.""" + if description.name is not None and description.name is not UNDEFINED: + return str(description.name) + return "Sensor" + + def get_data_source(self, snapshot: SpanPanelSnapshot) -> SpanPanelSnapshot: + """Get the data source for the panel data status sensor.""" + return snapshot + + +class SpanPanelStatus(SpanSensorBase[SpanPanelStatusSensorEntityDescription, SpanPanelSnapshot]): + """Span Panel hardware status sensor entity.""" + + def __init__( + self, + data_coordinator: SpanPanelCoordinator, + description: SpanPanelStatusSensorEntityDescription, + snapshot: SpanPanelSnapshot, + ) -> None: + """Initialize the Span Panel hardware status sensor.""" + super().__init__(data_coordinator, description, snapshot) + + def _generate_unique_id( + self, + snapshot: SpanPanelSnapshot, + description: SpanPanelStatusSensorEntityDescription, + ) -> str: + """Generate unique ID for panel status sensors.""" + return construct_panel_unique_id_for_entry( + self.coordinator, snapshot, description.key, self._device_name + ) + + def _generate_friendly_name( + self, + snapshot: SpanPanelSnapshot, + description: SpanPanelStatusSensorEntityDescription, + ) -> str: + """Generate friendly name for panel status sensors.""" + if description.name is not None and description.name is not UNDEFINED: + return str(description.name) + return "Status" + + def get_data_source(self, snapshot: SpanPanelSnapshot) -> SpanPanelSnapshot: + """Get the data source for the panel status sensor.""" + return snapshot + + @property + def extra_state_attributes(self) -> dict[str, Any] | None: + """Return additional state attributes for the software version sensor.""" + if not self.coordinator.data: + return None + + snapshot = self.coordinator.data + attributes: dict[str, Any] = {} + + attributes["panel_size"] = snapshot.panel_size + if snapshot.wifi_ssid is not None: + attributes["wifi_ssid"] = snapshot.wifi_ssid + + return attributes or None + + +class SpanPanelBattery( + SpanSensorBase[SpanPanelBatterySensorEntityDescription, SpanBatterySnapshot] +): + """Span Panel battery sensor entity.""" + + def __init__( + self, + data_coordinator: SpanPanelCoordinator, + description: SpanPanelBatterySensorEntityDescription, + snapshot: SpanPanelSnapshot, + device_info_override: DeviceInfo | None = None, + ) -> None: + """Initialize the Span Panel battery sensor.""" + super().__init__(data_coordinator, description, snapshot) + + if device_info_override is not None: + self._attr_device_info = device_info_override + + def _generate_unique_id( + self, + snapshot: SpanPanelSnapshot, + description: SpanPanelBatterySensorEntityDescription, + ) -> str: + """Generate unique ID for battery sensors.""" + return construct_panel_unique_id_for_entry( + self.coordinator, snapshot, description.key, self._device_name + ) + + def _generate_friendly_name( + self, + snapshot: SpanPanelSnapshot, + description: SpanPanelBatterySensorEntityDescription, + ) -> str: + """Generate friendly name for battery sensors.""" + if description.name is not None and description.name is not UNDEFINED: + return str(description.name) + return "Battery" + + def get_data_source(self, snapshot: SpanPanelSnapshot) -> SpanBatterySnapshot: + """Get the data source for the battery sensor.""" + return snapshot.battery + + +class SpanPanelPowerSensor(SpanSensorBase[SpanPanelDataSensorEntityDescription, SpanPanelSnapshot]): + """Panel power sensor with calculated amperage attribute.""" + + def __init__( + self, + data_coordinator: SpanPanelCoordinator, + description: SpanPanelDataSensorEntityDescription, + snapshot: SpanPanelSnapshot, + device_info_override: DeviceInfo | None = None, + ) -> None: + """Initialize the enhanced panel power sensor.""" + self._description_key = description.key + super().__init__(data_coordinator, description, snapshot) + + if device_info_override is not None: + self._attr_device_info = device_info_override + + def _generate_unique_id( + self, + snapshot: SpanPanelSnapshot, + description: SpanPanelDataSensorEntityDescription, + ) -> str: + """Generate unique ID for panel power sensors.""" + entity_suffix = get_panel_entity_suffix(description.key) + return construct_synthetic_unique_id_for_entry( + self.coordinator, snapshot, entity_suffix, self._device_name + ) + + def _generate_friendly_name( + self, + snapshot: SpanPanelSnapshot, + description: SpanPanelDataSensorEntityDescription, + ) -> str: + """Generate friendly name for panel power sensors.""" + if description.name is not None and description.name is not UNDEFINED: + return str(description.name) + return "Power" + + def get_data_source(self, snapshot: SpanPanelSnapshot) -> SpanPanelSnapshot: + """Get the data source for the panel power sensor.""" + return snapshot + + @property + def extra_state_attributes(self) -> dict[str, Any] | None: + """Return additional state attributes including amperage calculation.""" + if not self.coordinator.data: + return None + + attributes: dict[str, Any] = {} + + # Add voltage attribute (standard panel voltage) + attributes["voltage"] = 240 + + # Calculate amperage from power (P = V * I, so I = P / V) + if self.native_value is not None and isinstance(self.native_value, int | float): + try: + amperage = float(self.native_value) / 240.0 + attributes["amperage"] = round(amperage, 2) + except (ValueError, ZeroDivisionError): + attributes["amperage"] = 0.0 + else: + attributes["amperage"] = 0.0 + + return attributes + + +class SpanPanelEnergySensor( + SpanEnergySensorBase[SpanPanelDataSensorEntityDescription, SpanPanelSnapshot] +): + """Panel energy sensor with grace period tracking.""" + + def __init__( + self, + data_coordinator: SpanPanelCoordinator, + description: SpanPanelDataSensorEntityDescription, + snapshot: SpanPanelSnapshot, + ) -> None: + """Initialize the panel energy sensor.""" + super().__init__(data_coordinator, description, snapshot) + + def _generate_unique_id( + self, + snapshot: SpanPanelSnapshot, + description: SpanPanelDataSensorEntityDescription, + ) -> str: + """Generate unique ID for panel energy sensors.""" + entity_suffix = get_panel_entity_suffix(description.key) + return construct_synthetic_unique_id_for_entry( + self.coordinator, snapshot, entity_suffix, self._device_name + ) + + def _generate_friendly_name( + self, + snapshot: SpanPanelSnapshot, + description: SpanPanelDataSensorEntityDescription, + ) -> str: + """Generate friendly name for panel energy sensors.""" + if description.name is not None and description.name is not UNDEFINED: + return str(description.name) + return "Energy" + + def get_data_source(self, snapshot: SpanPanelSnapshot) -> SpanPanelSnapshot: + """Get the data source for the panel energy sensor.""" + return snapshot + + @property + def extra_state_attributes(self) -> dict[str, Any] | None: + """Return additional state attributes including grace period and voltage.""" + # Get base grace period attributes + base_attributes = super().extra_state_attributes or {} + attributes = dict(base_attributes) + + # Add voltage attribute (standard panel voltage) + attributes["voltage"] = 240 + + return attributes or None + + +class SpanBessMetadataSensor( + SpanSensorBase[SpanBessMetadataSensorEntityDescription, SpanBatterySnapshot] +): + """BESS metadata sensor entity on the BESS sub-device.""" + + def __init__( + self, + data_coordinator: SpanPanelCoordinator, + description: SpanBessMetadataSensorEntityDescription, + snapshot: SpanPanelSnapshot, + device_info_override: DeviceInfo, + ) -> None: + """Initialize the BESS metadata sensor.""" + super().__init__(data_coordinator, description, snapshot) + self._attr_device_info = device_info_override + + def _generate_unique_id( + self, + snapshot: SpanPanelSnapshot, + description: SpanBessMetadataSensorEntityDescription, + ) -> str: + """Generate unique ID for BESS metadata sensors.""" + return build_bess_unique_id_for_entry( + self.coordinator, snapshot, description.key, self._device_name + ) + + def _generate_friendly_name( + self, + snapshot: SpanPanelSnapshot, + description: SpanBessMetadataSensorEntityDescription, + ) -> str: + """Generate friendly name for BESS metadata sensors.""" + if description.name is not None and description.name is not UNDEFINED: + return str(description.name) + return "BESS Sensor" + + def get_data_source(self, snapshot: SpanPanelSnapshot) -> SpanBatterySnapshot: + """Get the data source for the BESS metadata sensor.""" + return snapshot.battery + + +class SpanPVMetadataSensor( + SpanSensorBase[SpanPVMetadataSensorEntityDescription, SpanPanelSnapshot] +): + """PV metadata sensor entity on the main panel device.""" + + def __init__( + self, + data_coordinator: SpanPanelCoordinator, + description: SpanPVMetadataSensorEntityDescription, + snapshot: SpanPanelSnapshot, + ) -> None: + """Initialize the PV metadata sensor.""" + super().__init__(data_coordinator, description, snapshot) + + def _generate_unique_id( + self, + snapshot: SpanPanelSnapshot, + description: SpanPVMetadataSensorEntityDescription, + ) -> str: + """Generate unique ID for PV metadata sensors.""" + return construct_panel_unique_id_for_entry( + self.coordinator, snapshot, description.key, self._device_name + ) + + def _generate_friendly_name( + self, + snapshot: SpanPanelSnapshot, + description: SpanPVMetadataSensorEntityDescription, + ) -> str: + """Generate friendly name for PV metadata sensors.""" + if description.name is not None and description.name is not UNDEFINED: + return str(description.name) + return "PV Sensor" + + def get_data_source(self, snapshot: SpanPanelSnapshot) -> SpanPanelSnapshot: + """Get the data source for the PV metadata sensor.""" + return snapshot diff --git a/custom_components/span_panel/sensors/__init__.py b/custom_components/span_panel/sensors/__init__.py deleted file mode 100644 index 897c13fb..00000000 --- a/custom_components/span_panel/sensors/__init__.py +++ /dev/null @@ -1,30 +0,0 @@ -"""Sensors package for Span Panel integration.""" - -from .base import SpanEnergySensorBase, SpanSensorBase -from .circuit import SpanCircuitEnergySensor, SpanCircuitPowerSensor, SpanUnmappedCircuitSensor -from .factory import create_native_sensors, enable_unmapped_tab_entities -from .panel import ( - SpanPanelBattery, - SpanPanelEnergySensor, - SpanPanelPanelStatus, - SpanPanelPowerSensor, - SpanPanelStatus, -) -from .solar import SpanSolarEnergySensor, SpanSolarSensor - -__all__ = [ - "SpanSensorBase", - "SpanEnergySensorBase", - "SpanPanelPanelStatus", - "SpanPanelStatus", - "SpanPanelBattery", - "SpanPanelPowerSensor", - "SpanPanelEnergySensor", - "SpanCircuitPowerSensor", - "SpanCircuitEnergySensor", - "SpanUnmappedCircuitSensor", - "SpanSolarSensor", - "SpanSolarEnergySensor", - "create_native_sensors", - "enable_unmapped_tab_entities", -] diff --git a/custom_components/span_panel/sensors/base.py b/custom_components/span_panel/sensors/base.py deleted file mode 100644 index 139665e5..00000000 --- a/custom_components/span_panel/sensors/base.py +++ /dev/null @@ -1,526 +0,0 @@ -"""Base sensor classes for Span Panel integration.""" - -from __future__ import annotations - -from abc import ABC, abstractmethod -from collections.abc import Callable -from dataclasses import dataclass -from datetime import datetime, timedelta -import logging -from typing import Any, Generic, Self, TypeVar - -from homeassistant.components.sensor import ( - RestoreSensor, - SensorEntity, - SensorEntityDescription, -) -from homeassistant.const import STATE_UNKNOWN -from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.restore_state import ExtraStoredData -from homeassistant.helpers.update_coordinator import CoordinatorEntity - -from custom_components.span_panel.const import DOMAIN -from custom_components.span_panel.coordinator import SpanPanelCoordinator -from custom_components.span_panel.options import ENERGY_REPORTING_GRACE_PERIOD -from custom_components.span_panel.span_panel import SpanPanel -from custom_components.span_panel.util import panel_to_device_info - -_LOGGER: logging.Logger = logging.getLogger(__name__) - -T = TypeVar("T", bound=SensorEntityDescription) -D = TypeVar("D") # For the type returned by get_data_source - - -class SpanSensorBase(CoordinatorEntity[SpanPanelCoordinator], SensorEntity, Generic[T, D], ABC): - """Abstract base class for Span Panel Sensors with overrideable methods.""" - - _attr_has_entity_name = True - - def __init__( - self, - data_coordinator: SpanPanelCoordinator, - description: T, - span_panel: SpanPanel, - ) -> None: - """Initialize Span Panel Sensor base entity.""" - super().__init__(data_coordinator, context=description) - # See developer_attrtribute_readme.md for why we use - # entity_description instead of _attr_entity_descriptio - self.entity_description = description - - if hasattr(description, "device_class"): - self._attr_device_class = description.device_class - - # Get device name from config entry data - self._device_name = data_coordinator.config_entry.data.get( - "device_name", data_coordinator.config_entry.title - ) - - device_info: DeviceInfo = panel_to_device_info(span_panel, self._device_name) - self._attr_device_info = device_info # Re-enable device info - - # Check if entity already exists in registry for name sync - if span_panel.status.serial_number and description.key: - self._attr_unique_id = self._generate_unique_id(span_panel, description) - - # Check if entity exists for name sync logic - entity_registry = er.async_get(data_coordinator.hass) - existing_entity_id = entity_registry.async_get_entity_id( - "sensor", DOMAIN, self._attr_unique_id - ) - - if existing_entity_id: - # Entity exists - use panel name for sync - self._attr_name = self._generate_panel_name(span_panel, description) - else: - # Initial install - use flag-based name - self._attr_name = self._generate_friendly_name(span_panel, description) - else: - # Fallback for entities without unique_id - self._attr_name = self._generate_friendly_name(span_panel, description) - - self._attr_icon = "mdi:flash" - - # Set entity registry defaults if they exist in the description - if hasattr(description, "entity_registry_enabled_default"): - self._attr_entity_registry_enabled_default = description.entity_registry_enabled_default - if hasattr(description, "entity_registry_visible_default"): - self._attr_entity_registry_visible_default = description.entity_registry_visible_default - - # Initialize name sync tracking - # Only set to None if entity doesn't exist in registry (true first time) - if span_panel.status.serial_number and description.key and self._attr_unique_id: - entity_registry = er.async_get(data_coordinator.hass) - existing_entity_id = entity_registry.async_get_entity_id( - "sensor", DOMAIN, self._attr_unique_id - ) - if not existing_entity_id: - self._previous_circuit_name = None - else: - # Entity exists, get current circuit name for comparison - if hasattr(self, "circuit_id"): - circuit = span_panel.circuits.get(getattr(self, "circuit_id", "")) - self._previous_circuit_name = circuit.name if circuit else None - else: - self._previous_circuit_name = None - else: - self._previous_circuit_name = None - - # Use standard coordinator pattern - entities will update automatically - # when coordinator data changes - - @abstractmethod - def _generate_unique_id(self, span_panel: SpanPanel, description: T) -> str: - """Generate unique ID for the sensor. - - Subclasses must implement this to define their unique ID strategy. - - Args: - span_panel: The span panel data - description: The sensor description - - Returns: - Unique ID string - - """ - - @abstractmethod - def _generate_friendly_name(self, span_panel: SpanPanel, description: T) -> str: - """Generate friendly name for the sensor. - - Subclasses must implement this to define their naming strategy. - - Args: - span_panel: The span panel data - description: The sensor description - - Returns: - Friendly name string - - """ - - def _generate_panel_name(self, span_panel: SpanPanel, description: T) -> str: - """Generate panel name for the sensor (always uses panel circuit name). - - This method is used for name sync - it always uses the panel circuit name - regardless of user preferences. - - Args: - span_panel: The span panel data - description: The sensor description - - Returns: - Panel name string - - """ - # This should be implemented by subclasses that need name sync - # For now, fall back to friendly name - return self._generate_friendly_name(span_panel, description) - - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" - # Check for circuit name changes for name sync (only for circuit sensors) - if hasattr(self, "circuit_id") and hasattr(self.coordinator.data, "circuits"): - circuit = self.coordinator.data.circuits.get(getattr(self, "circuit_id", "")) - if circuit: - current_circuit_name = circuit.name - - if self._previous_circuit_name is None: - # First update - sync to panel name - _LOGGER.info( - "First update: syncing sensor name to panel name '%s', requesting reload", - current_circuit_name, - ) - # Update stored previous name for next comparison - self._previous_circuit_name = current_circuit_name - # Request integration reload to persist name change - self.coordinator.request_reload() - elif current_circuit_name != self._previous_circuit_name: - _LOGGER.info( - "Auto-sync detected circuit name change from '%s' to '%s' for sensor, requesting integration reload", - self._previous_circuit_name, - current_circuit_name, - ) - # Update stored previous name for next comparison - self._previous_circuit_name = current_circuit_name - # Request integration reload for next update cycle - self.coordinator.request_reload() - - self._update_native_value() - super()._handle_coordinator_update() - - @property - def available(self) -> bool: - """Return entity availability. - - Keep entities available during a panel_offline condition so sensors can show - their grace period state (last_valid_state) or None when grace period expires. - """ - try: - if getattr(self.coordinator, "panel_offline", False): - return True - except AttributeError as err: - # If coordinator is missing expected attribute, log and fall back - _LOGGER.debug("Availability check: missing coordinator attribute: %s", err) - except Exception as err: # pragma: no cover - defensive - # Any unexpected error shouldn't crash the availability check - _LOGGER.debug("Availability check: unexpected error: %s", err) - return super().available - - def _update_native_value(self) -> None: - """Update the native value of the sensor.""" - if self.coordinator.panel_offline: - self._handle_offline_state() - return - - self._handle_online_state() - - def _handle_offline_state(self) -> None: - """Handle sensor state when panel is offline.""" - _LOGGER.debug("STATUS_SENSOR_DEBUG: Panel is offline for %s", self._attr_name) - - # For power sensors, set to 0.0 when offline (instantaneous values) - # For energy sensors, set to None when offline (HA will report as unknown) - # For numeric sensors (battery, etc.), set to None when offline (HA will report as unknown) - # For other sensors, set to STATE_UNKNOWN when offline - device_class = getattr(self.entity_description, "device_class", None) - state_class = getattr(self.entity_description, "state_class", None) - if device_class == "power": - self._attr_native_value = 0.0 - elif device_class == "energy": - self._attr_native_value = None - elif state_class is not None: - # Any sensor with a state_class (measurement, total, etc.) expects numeric values - self._attr_native_value = None - else: - self._attr_native_value = STATE_UNKNOWN - - def _handle_online_state(self) -> None: - """Handle sensor state when panel is online.""" - value_function: Callable[[D], float | int | str | None] | None = getattr( - self.entity_description, "value_fn", None - ) - if value_function is None: - _LOGGER.debug("STATUS_SENSOR_DEBUG: No value_function for %s", self._attr_name) - # For sensors with state_class, use None (HA reports as unknown) - # For other sensors, use STATE_UNKNOWN string - state_class = getattr(self.entity_description, "state_class", None) - self._attr_native_value = None if state_class is not None else STATE_UNKNOWN - return - - try: - data_source: D = self.get_data_source(self.coordinator.data) - self._log_debug_info(data_source) - raw_value: float | int | str | None = value_function(data_source) - self._process_raw_value(raw_value) - except (AttributeError, KeyError, IndexError): - # For sensors with state_class, use None (HA reports as unknown) - # For other sensors, use STATE_UNKNOWN string - state_class = getattr(self.entity_description, "state_class", None) - self._attr_native_value = None if state_class is not None else STATE_UNKNOWN - - def _log_debug_info(self, data_source: D) -> None: - """Log debug information for circuit sensors.""" - # Only do debug logging if we have valid data and the panel is online - if ( - not self.coordinator.panel_offline - and hasattr(self, "id") - and hasattr(data_source, "instant_power") - ): - circuit_id = getattr(self, "id", STATE_UNKNOWN) - instant_power = getattr(data_source, "instant_power", None) - description_key = getattr(self.entity_description, "key", STATE_UNKNOWN) - _LOGGER.debug( - "CIRCUIT_POWER_DEBUG: Circuit %s, sensor %s, instant_power=%s, data_source type=%s", - circuit_id, - description_key, - instant_power, - type(data_source).__name__, - ) - - def _process_raw_value(self, raw_value: float | int | str | None) -> None: - """Process the raw value from the value function.""" - if raw_value is None: - # For sensors with state_class, use None (HA reports as unknown) - # For other sensors, use STATE_UNKNOWN string - state_class = getattr(self.entity_description, "state_class", None) - self._attr_native_value = None if state_class is not None else STATE_UNKNOWN - elif isinstance(raw_value, float | int): - self._attr_native_value = float(raw_value) - else: - # For string values, keep as string - this is valid for Home Assistant sensors - self._attr_native_value = str(raw_value) - - def get_data_source(self, span_panel: SpanPanel) -> D: - """Get the data source for the sensor.""" - raise NotImplementedError("Subclasses must implement this method") - - -@dataclass -class SpanEnergyExtraStoredData(ExtraStoredData): - """Extra stored data for Span energy sensors with grace period tracking. - - This data is persisted across Home Assistant restarts to maintain - grace period state for energy sensors, preventing statistics spikes - when the panel is offline at startup. - """ - - native_value: float | None - native_unit_of_measurement: str | None - last_valid_state: float | None - last_valid_changed: str | None # ISO format datetime string - - def as_dict(self) -> dict[str, Any]: - """Return a dict representation of the extra data.""" - return { - "native_value": self.native_value, - "native_unit_of_measurement": self.native_unit_of_measurement, - "last_valid_state": self.last_valid_state, - "last_valid_changed": self.last_valid_changed, - } - - @classmethod - def from_dict(cls, restored: dict[str, Any]) -> Self | None: - """Initialize extra stored data from a dict. - - Args: - restored: Dictionary containing the stored data - - Returns: - SpanEnergyExtraStoredData instance or None if restoration fails - - """ - try: - return cls( - native_value=restored.get("native_value"), - native_unit_of_measurement=restored.get("native_unit_of_measurement"), - last_valid_state=restored.get("last_valid_state"), - last_valid_changed=restored.get("last_valid_changed"), - ) - except (KeyError, TypeError): - return None - - -class SpanEnergySensorBase(SpanSensorBase[T, D], RestoreSensor, ABC): - """Base class for energy sensors that includes grace period tracking. - - This class extends SpanSensorBase with: - - Grace period tracking for offline scenarios - - State restoration across HA restarts via RestoreSensor mixin - - Automatic persistence of last_valid_state and last_valid_changed - """ - - def __init__( - self, - data_coordinator: SpanPanelCoordinator, - description: T, - span_panel: SpanPanel, - ) -> None: - """Initialize the energy sensor with grace period tracking.""" - super().__init__(data_coordinator, description, span_panel) - self._last_valid_state: float | None = None - self._last_valid_changed: datetime | None = None - self._grace_period_minutes = data_coordinator.config_entry.options.get( - ENERGY_REPORTING_GRACE_PERIOD, 15 - ) - # Track if we've restored data (used for logging) - self._restored_from_storage: bool = False - - async def async_added_to_hass(self) -> None: - """Restore grace period state when entity is added to Home Assistant. - - This method is called when the entity is added to HA, which happens - during startup or when the integration is reloaded. We use this - opportunity to restore the grace period tracking state from storage. - """ - await super().async_added_to_hass() - - # Try to restore the grace period state from storage - if (last_extra_data := await self.async_get_last_extra_data()) is not None: - restored = SpanEnergyExtraStoredData.from_dict(last_extra_data.as_dict()) - if restored: - # Restore last_valid_state - if restored.last_valid_state is not None: - self._last_valid_state = restored.last_valid_state - - # Restore last_valid_changed timestamp - if restored.last_valid_changed is not None: - try: - self._last_valid_changed = datetime.fromisoformat( - restored.last_valid_changed - ) - self._restored_from_storage = True - _LOGGER.debug( - "Restored grace period state for %s: " - "last_valid_state=%s, last_valid_changed=%s", - self.entity_id or self._attr_unique_id, - self._last_valid_state, - self._last_valid_changed, - ) - except (ValueError, TypeError) as e: - _LOGGER.warning( - "Failed to parse restored last_valid_changed for %s: %s", - self.entity_id or self._attr_unique_id, - e, - ) - - @property - def extra_restore_state_data(self) -> SpanEnergyExtraStoredData: - """Return sensor-specific state data to be restored. - - This data is automatically saved by Home Assistant when the - integration is unloaded or HA shuts down, and restored when - the entity is added back to HA. - """ - return SpanEnergyExtraStoredData( - native_value=( - float(self._attr_native_value) - if isinstance(self._attr_native_value, int | float) - else None - ), - native_unit_of_measurement=self.native_unit_of_measurement, - last_valid_state=self._last_valid_state, - last_valid_changed=( - self._last_valid_changed.isoformat() if self._last_valid_changed else None - ), - ) - - def _update_native_value(self) -> None: - """Update the native value with grace period logic for energy sensors.""" - if self.coordinator.panel_offline: - # Use grace period logic when offline - self._handle_offline_grace_period() - return - - # Panel is online - use normal update logic from parent class - super()._update_native_value() - - # Track valid state for grace period (only when we have a valid value) - if self._attr_native_value is not None and isinstance(self._attr_native_value, int | float): - self._last_valid_state = float(self._attr_native_value) - self._last_valid_changed = datetime.now() - - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator with grace period tracking.""" - # Check for circuit name changes for name sync (only for circuit sensors) - if hasattr(self, "circuit_id") and hasattr(self.coordinator.data, "circuits"): - circuit = self.coordinator.data.circuits.get(getattr(self, "circuit_id", "")) - if circuit: - current_circuit_name = circuit.name - - if self._previous_circuit_name is None: - # First update - sync to panel name - _LOGGER.info( - "First update: syncing energy sensor name to panel name '%s', requesting reload", - current_circuit_name, - ) - # Update stored previous name for next comparison - self._previous_circuit_name = current_circuit_name - # Request integration reload to persist name change - self.coordinator.request_reload() - elif current_circuit_name != self._previous_circuit_name: - _LOGGER.info( - "Auto-sync detected circuit name change from '%s' to '%s' for energy sensor, requesting integration reload", - self._previous_circuit_name, - current_circuit_name, - ) - # Update stored previous name for next comparison - self._previous_circuit_name = current_circuit_name - # Request integration reload for next update cycle - self.coordinator.request_reload() - - # Update grace period from options in case it changed - self._grace_period_minutes = self.coordinator.config_entry.options.get( - ENERGY_REPORTING_GRACE_PERIOD, 15 - ) - - # Use the overridden _update_native_value method which handles grace period - self._update_native_value() - - # Call the parent's parent class coordinator update to avoid the intermediate parent's logic - super(SpanSensorBase, self)._handle_coordinator_update() - - def _handle_offline_grace_period(self) -> None: - """Handle grace period logic when panel is offline.""" - if self._last_valid_changed is None or self._last_valid_state is None: - # No previous valid state, set to None (HA reports unknonwn) - self._attr_native_value = None - return - - # Check if we're still within the grace period - time_since_last_valid = datetime.now() - self._last_valid_changed - grace_period_duration = timedelta(minutes=self._grace_period_minutes) - - if time_since_last_valid <= grace_period_duration: - # Still within grace period - use last valid state - self._attr_native_value = self._last_valid_state - else: - # Grace period expired - set to None (makes sensor unknown) - self._attr_native_value = None - - @property - def extra_state_attributes(self) -> dict[str, Any] | None: - """Return additional state attributes including grace period info.""" - attributes = {} - - # Always show grace period information if we have valid tracking data - if self._last_valid_changed is not None: - if self._last_valid_state is not None: - attributes["last_valid_state"] = str(self._last_valid_state) - attributes["last_valid_changed"] = self._last_valid_changed.isoformat() - - # Calculate grace period remaining - if self._grace_period_minutes > 0: - time_since_last_valid = datetime.now() - self._last_valid_changed - grace_period_duration = timedelta(minutes=self._grace_period_minutes) - remaining_seconds = (grace_period_duration - time_since_last_valid).total_seconds() - remaining_minutes = max(0, int(remaining_seconds / 60)) - attributes["grace_period_remaining"] = str(remaining_minutes) - - # Indicate if we're currently using grace period - panel_offline = getattr(self.coordinator, "panel_offline", False) - if panel_offline and remaining_seconds > 0: - attributes["using_grace_period"] = "True" - - return attributes if attributes else None diff --git a/custom_components/span_panel/sensors/circuit.py b/custom_components/span_panel/sensors/circuit.py deleted file mode 100644 index ecc5c1ed..00000000 --- a/custom_components/span_panel/sensors/circuit.py +++ /dev/null @@ -1,320 +0,0 @@ -"""Circuit-level sensors for Span Panel integration.""" - -from __future__ import annotations - -import logging -from typing import Any - -from custom_components.span_panel.const import USE_CIRCUIT_NUMBERS -from custom_components.span_panel.coordinator import SpanPanelCoordinator -from custom_components.span_panel.helpers import ( - construct_circuit_unique_id_for_entry, - construct_tabs_attribute, - construct_unmapped_friendly_name, - construct_voltage_attribute, -) -from custom_components.span_panel.sensor_definitions import SpanPanelCircuitsSensorEntityDescription -from custom_components.span_panel.span_panel import SpanPanel -from custom_components.span_panel.span_panel_circuit import SpanPanelCircuit - -from .base import SpanEnergySensorBase, SpanSensorBase - -_LOGGER: logging.Logger = logging.getLogger(__name__) - - -class SpanCircuitPowerSensor( - SpanSensorBase[SpanPanelCircuitsSensorEntityDescription, SpanPanelCircuit] -): - """Enhanced circuit power sensor with amperage and tabs attributes.""" - - def __init__( - self, - data_coordinator: SpanPanelCoordinator, - description: SpanPanelCircuitsSensorEntityDescription, - span_panel: SpanPanel, - circuit_id: str, - ) -> None: - """Initialize the enhanced circuit power sensor.""" - self.circuit_id = circuit_id - self.original_key = description.key - - # Override the description key to use the circuit_id for data lookup - description_with_circuit = SpanPanelCircuitsSensorEntityDescription( - key=circuit_id, - name=description.name, - native_unit_of_measurement=description.native_unit_of_measurement, - state_class=description.state_class, - suggested_display_precision=description.suggested_display_precision, - device_class=description.device_class, - value_fn=description.value_fn, - entity_registry_enabled_default=description.entity_registry_enabled_default, - entity_registry_visible_default=description.entity_registry_visible_default, - ) - - super().__init__(data_coordinator, description_with_circuit, span_panel) - - def _generate_unique_id( - self, span_panel: SpanPanel, description: SpanPanelCircuitsSensorEntityDescription - ) -> str: - """Generate unique ID for circuit power sensors.""" - # Use the original API key that migration normalized from - api_key = "instantPowerW" # This maps to "power" suffix - return construct_circuit_unique_id_for_entry( - self.coordinator, span_panel, self.circuit_id, api_key, self._device_name - ) - - def _generate_friendly_name( - self, span_panel: SpanPanel, description: SpanPanelCircuitsSensorEntityDescription - ) -> str: - """Generate friendly name for circuit power sensors based on user preferences.""" - circuit = span_panel.circuits.get(self.circuit_id) - if not circuit: - return construct_unmapped_friendly_name( - self.circuit_id, str(description.name or "Sensor") - ) - - # Respect user's naming preference - use_circuit_numbers = self.coordinator.config_entry.options.get(USE_CIRCUIT_NUMBERS, False) - - if use_circuit_numbers: - # Use circuit number format: "Circuit 15 Power" - if circuit.tabs and len(circuit.tabs) == 2: - # 240V circuit - use both tab numbers - sorted_tabs = sorted(circuit.tabs) - circuit_identifier = f"Circuit {sorted_tabs[0]} {sorted_tabs[1]}" - elif circuit.tabs and len(circuit.tabs) == 1: - # 120V circuit - use single tab number - circuit_identifier = f"Circuit {circuit.tabs[0]}" - else: - # Fallback - circuit_identifier = f"Circuit {self.circuit_id}" - else: - # Use friendly name format: "Kitchen Outlets Power" - circuit_identifier = circuit.name - - return f"{circuit_identifier} {description.name or 'Sensor'}" - - def _generate_panel_name( - self, span_panel: SpanPanel, description: SpanPanelCircuitsSensorEntityDescription - ) -> str: - """Generate panel name for circuit sensors (always uses panel circuit name).""" - circuit = span_panel.circuits.get(self.circuit_id) - if not circuit: - return construct_unmapped_friendly_name( - self.circuit_id, str(description.name or "Sensor") - ) - - # Always use panel name for sync - circuit_identifier = circuit.name - return f"{circuit_identifier} {description.name or 'Sensor'}" - - def get_data_source(self, span_panel: SpanPanel) -> SpanPanelCircuit: - """Get the data source for the circuit power sensor.""" - circuit = span_panel.circuits.get(self.circuit_id) - if circuit is None: - raise ValueError(f"Circuit {self.circuit_id} not found in panel data") - return circuit - - @property - def extra_state_attributes(self) -> dict[str, Any] | None: - """Return additional state attributes including amperage and tabs.""" - if not self.coordinator.last_update_success or not self.coordinator.data: - return None - - circuit = self.coordinator.data.circuits.get(self.circuit_id) - if not circuit: - return None - - attributes = {} - - # Add tabs attribute - tabs_result = construct_tabs_attribute(circuit) - if tabs_result is not None: - attributes["tabs"] = str(tabs_result) - - # Add voltage attribute - voltage = construct_voltage_attribute(circuit) or 240 - attributes["voltage"] = str(voltage) - - # Calculate amperage from power (P = V * I, so I = P / V) - if self.native_value is not None and isinstance(self.native_value, int | float): - try: - amperage = float(self.native_value) / float(voltage) - attributes["amperage"] = str(round(amperage, 2)) - except (ValueError, ZeroDivisionError): - attributes["amperage"] = "0.0" - else: - attributes["amperage"] = "0.0" - - return attributes - - -class SpanCircuitEnergySensor( - SpanEnergySensorBase[SpanPanelCircuitsSensorEntityDescription, SpanPanelCircuit] -): - """Circuit energy sensor with grace period tracking.""" - - def __init__( - self, - data_coordinator: SpanPanelCoordinator, - description: SpanPanelCircuitsSensorEntityDescription, - span_panel: SpanPanel, - circuit_id: str, - ) -> None: - """Initialize the circuit energy sensor.""" - self.circuit_id = circuit_id - self.original_key = description.key - - # Override the description key to use the circuit_id for data lookup - description_with_circuit = SpanPanelCircuitsSensorEntityDescription( - key=circuit_id, - name=description.name, - native_unit_of_measurement=description.native_unit_of_measurement, - state_class=description.state_class, - suggested_display_precision=description.suggested_display_precision, - device_class=description.device_class, - value_fn=description.value_fn, - entity_registry_enabled_default=description.entity_registry_enabled_default, - entity_registry_visible_default=description.entity_registry_visible_default, - ) - - super().__init__(data_coordinator, description_with_circuit, span_panel) - - def _generate_unique_id( - self, span_panel: SpanPanel, description: SpanPanelCircuitsSensorEntityDescription - ) -> str: - """Generate unique ID for circuit energy sensors.""" - # Map new description keys to original API keys that migration normalized from - api_key_mapping = { - "circuit_energy_produced": "producedEnergyWh", - "circuit_energy_consumed": "consumedEnergyWh", - "circuit_energy_net": "netEnergyWh", - } - api_key = api_key_mapping.get(self.original_key, self.original_key) - return construct_circuit_unique_id_for_entry( - self.coordinator, span_panel, self.circuit_id, api_key, self._device_name - ) - - def _generate_friendly_name( - self, span_panel: SpanPanel, description: SpanPanelCircuitsSensorEntityDescription - ) -> str: - """Generate friendly name for circuit energy sensors based on user preferences.""" - circuit = span_panel.circuits.get(self.circuit_id) - if not circuit: - return f"Circuit {self.circuit_id} {description.name}" - - # Respect user's naming preference - use_circuit_numbers = self.coordinator.config_entry.options.get(USE_CIRCUIT_NUMBERS, False) - - if use_circuit_numbers: - # Use circuit number format: "Circuit 15 Power" - if circuit.tabs and len(circuit.tabs) == 2: - # 240V circuit - use both tab numbers - sorted_tabs = sorted(circuit.tabs) - circuit_identifier = f"Circuit {sorted_tabs[0]} {sorted_tabs[1]}" - elif circuit.tabs and len(circuit.tabs) == 1: - # 120V circuit - use single tab number - circuit_identifier = f"Circuit {circuit.tabs[0]}" - else: - # Fallback - circuit_identifier = f"Circuit {self.circuit_id}" - else: - # Use friendly name format: "Kitchen Outlets Power" - circuit_identifier = circuit.name - - return f"{circuit_identifier} {description.name}" - - def _generate_panel_name( - self, span_panel: SpanPanel, description: SpanPanelCircuitsSensorEntityDescription - ) -> str: - """Generate panel name for circuit energy sensors (always uses panel circuit name).""" - circuit = span_panel.circuits.get(self.circuit_id) - if not circuit: - return f"Circuit {self.circuit_id} {description.name}" - - # Always use panel name for sync - circuit_identifier = circuit.name - return f"{circuit_identifier} {description.name}" - - def get_data_source(self, span_panel: SpanPanel) -> SpanPanelCircuit: - """Get the data source for the circuit energy sensor.""" - return span_panel.circuits[self.circuit_id] - - @property - def extra_state_attributes(self) -> dict[str, Any] | None: - """Return additional state attributes including grace period and circuit info.""" - # Get base grace period attributes - base_attributes = super().extra_state_attributes or {} - attributes = dict(base_attributes) - - # Add circuit-specific attributes if we have data - if self.coordinator.data: - span_panel = self.coordinator.data - circuit = span_panel.circuits.get(self.circuit_id) - - if circuit: - # Add tabs and voltage attributes - tabs = construct_tabs_attribute(circuit) - if tabs is not None: - attributes["tabs"] = tabs - - voltage = construct_voltage_attribute(circuit) or 240 - attributes["voltage"] = voltage - - return attributes if attributes else None - - -class SpanUnmappedCircuitSensor( - SpanSensorBase[SpanPanelCircuitsSensorEntityDescription, SpanPanelCircuit] -): - """Span Panel unmapped circuit sensor entity - native sensors for synthetic calculations.""" - - def __init__( - self, - data_coordinator: SpanPanelCoordinator, - description: SpanPanelCircuitsSensorEntityDescription, - span_panel: SpanPanel, - circuit_id: str, - ) -> None: - """Initialize the Span Panel unmapped circuit sensor.""" - self.circuit_id = circuit_id - # Store the original description key for unique ID and entity ID generation - self.original_key = description.key - - # Override the description key to use the circuit_id for data lookup - description_with_circuit = SpanPanelCircuitsSensorEntityDescription( - key=circuit_id, - name=description.name, - native_unit_of_measurement=description.native_unit_of_measurement, - state_class=description.state_class, - suggested_display_precision=description.suggested_display_precision, - device_class=description.device_class, - value_fn=description.value_fn, - entity_registry_enabled_default=True, - entity_registry_visible_default=False, - ) - - super().__init__(data_coordinator, description_with_circuit, span_panel) - - def _generate_unique_id( - self, span_panel: SpanPanel, description: SpanPanelCircuitsSensorEntityDescription - ) -> str: - """Generate unique ID for unmapped circuit sensors.""" - # Unmapped tab sensors are regular circuit sensors, use standard circuit unique ID pattern - # circuit_id is already "unmapped_tab_32", so this creates "span_{serial}_unmapped_tab_32_{suffix}" - # Use the original key (e.g., "instantPowerW") instead of the modified description.key - return construct_circuit_unique_id_for_entry( - self.coordinator, span_panel, self.circuit_id, self.original_key, self._device_name - ) - - def _generate_friendly_name( - self, span_panel: SpanPanel, description: SpanPanelCircuitsSensorEntityDescription - ) -> str: - """Generate friendly name for unmapped circuit sensors.""" - tab_number = self.circuit_id.replace("unmapped_tab_", "") - description_name = str(description.name) if description.name else "Sensor" - return construct_unmapped_friendly_name(tab_number, description_name) - - def get_data_source(self, span_panel: SpanPanel) -> SpanPanelCircuit: - """Get the data source for the unmapped circuit sensor.""" - return span_panel.circuits[self.circuit_id] diff --git a/custom_components/span_panel/sensors/factory.py b/custom_components/span_panel/sensors/factory.py deleted file mode 100644 index a939cd7d..00000000 --- a/custom_components/span_panel/sensors/factory.py +++ /dev/null @@ -1,276 +0,0 @@ -"""Factory functions for creating Span Panel sensors.""" - -from __future__ import annotations - -import logging -from typing import Any - -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er - -from custom_components.span_panel.const import ( - ENABLE_CIRCUIT_NET_ENERGY_SENSORS, - ENABLE_PANEL_NET_ENERGY_SENSORS, - ENABLE_SOLAR_NET_ENERGY_SENSORS, -) -from custom_components.span_panel.coordinator import SpanPanelCoordinator -from custom_components.span_panel.options import ( - BATTERY_ENABLE, - INVERTER_ENABLE, - INVERTER_LEG1, - INVERTER_LEG2, -) -from custom_components.span_panel.sensor_definitions import ( - BATTERY_SENSOR, - CIRCUIT_SENSORS, - PANEL_DATA_STATUS_SENSORS, - PANEL_ENERGY_SENSORS, - PANEL_POWER_SENSORS, - SOLAR_SENSORS, - STATUS_SENSORS, - UNMAPPED_SENSORS, -) -from custom_components.span_panel.span_panel import SpanPanel - -from .circuit import SpanCircuitEnergySensor, SpanCircuitPowerSensor, SpanUnmappedCircuitSensor -from .panel import ( - SpanPanelBattery, - SpanPanelEnergySensor, - SpanPanelPanelStatus, - SpanPanelPowerSensor, - SpanPanelStatus, -) -from .solar import SpanSolarEnergySensor, SpanSolarSensor - -_LOGGER: logging.Logger = logging.getLogger(__name__) - - -def create_panel_sensors( - coordinator: SpanPanelCoordinator, span_panel: SpanPanel, config_entry: ConfigEntry -) -> list[SpanPanelPanelStatus | SpanPanelStatus | SpanPanelPowerSensor | SpanPanelEnergySensor]: - """Create panel-level sensors.""" - entities: list[ - SpanPanelPanelStatus | SpanPanelStatus | SpanPanelPowerSensor | SpanPanelEnergySensor - ] = [] - - # Add panel data status sensors (DSM State, DSM Grid State, etc.) - for description in PANEL_DATA_STATUS_SENSORS: - entities.append(SpanPanelPanelStatus(coordinator, description, span_panel)) - - # Add panel power sensors (replacing synthetic ones) - for description in PANEL_POWER_SENSORS: - entities.append(SpanPanelPowerSensor(coordinator, description, span_panel)) - - # Add panel energy sensors (replacing synthetic ones) - # Filter out net energy sensors if disabled - panel_net_energy_enabled = config_entry.options.get(ENABLE_PANEL_NET_ENERGY_SENSORS, True) - - for description in PANEL_ENERGY_SENSORS: - # Skip net energy sensors if disabled - is_net_energy_sensor = "net_energy" in description.key or "NetEnergy" in description.key - - if not panel_net_energy_enabled and is_net_energy_sensor: - continue - entities.append(SpanPanelEnergySensor(coordinator, description, span_panel)) - - # Add hardware status sensors (Door State, WiFi, Cellular, etc.) - for description_ss in STATUS_SENSORS: - entities.append(SpanPanelStatus(coordinator, description_ss, span_panel)) - - return entities - - -def create_circuit_sensors( - coordinator: SpanPanelCoordinator, span_panel: SpanPanel, config_entry: ConfigEntry -) -> list[SpanCircuitPowerSensor | SpanCircuitEnergySensor]: - """Create circuit-level sensors for named circuits.""" - entities: list[SpanCircuitPowerSensor | SpanCircuitEnergySensor] = [] - - # Add circuit sensors for all named circuits (replacing synthetic ones) - named_circuits = [cid for cid in span_panel.circuits if not cid.startswith("unmapped_tab_")] - circuit_net_energy_enabled = config_entry.options.get(ENABLE_CIRCUIT_NET_ENERGY_SENSORS, True) - - for circuit_id in named_circuits: - for circuit_description in CIRCUIT_SENSORS: - # Skip net energy sensors if disabled - is_net_energy_sensor = ( - "net_energy" in circuit_description.key or "energy_net" in circuit_description.key - ) - - if not circuit_net_energy_enabled and is_net_energy_sensor: - continue - - if circuit_description.key == "circuit_power": - # Use enhanced power sensor for power measurements - entities.append( - SpanCircuitPowerSensor(coordinator, circuit_description, span_panel, circuit_id) - ) - else: - # Use energy sensor with grace period tracking for energy measurements - entities.append( - SpanCircuitEnergySensor( - coordinator, circuit_description, span_panel, circuit_id - ) - ) - - return entities - - -def create_unmapped_circuit_sensors( - coordinator: SpanPanelCoordinator, span_panel: SpanPanel -) -> list[SpanUnmappedCircuitSensor]: - """Create unmapped circuit sensors for synthetic calculations.""" - entities: list[SpanUnmappedCircuitSensor] = [] - - # Add unmapped circuit sensors (native sensors for synthetic calculations) - # These are invisible sensors that provide stable entity IDs for solar synthetics - unmapped_circuits = [cid for cid in span_panel.circuits if cid.startswith("unmapped_tab_")] - for circuit_id in unmapped_circuits: - for unmapped_description in UNMAPPED_SENSORS: - # UNMAPPED_SENSORS contains SpanPanelCircuitsSensorEntityDescription - entities.append( - SpanUnmappedCircuitSensor(coordinator, unmapped_description, span_panel, circuit_id) - ) - - return entities - - -def create_battery_sensors( - coordinator: SpanPanelCoordinator, span_panel: SpanPanel, config_entry: ConfigEntry -) -> list[SpanPanelBattery]: - """Create battery sensors if enabled.""" - entities: list[SpanPanelBattery] = [] - - # Add battery sensor if enabled - battery_enabled = config_entry.options.get(BATTERY_ENABLE, False) - if battery_enabled: - entities.append(SpanPanelBattery(coordinator, BATTERY_SENSOR, span_panel)) - - return entities - - -def create_solar_sensors( - coordinator: SpanPanelCoordinator, span_panel: SpanPanel, config_entry: ConfigEntry -) -> list[SpanSolarSensor | SpanSolarEnergySensor]: - """Create solar sensors if enabled and configured.""" - entities: list[SpanSolarSensor | SpanSolarEnergySensor] = [] - - # Add solar sensors if enabled - solar_enabled = config_entry.options.get(INVERTER_ENABLE, False) - if not solar_enabled: - return entities - - # Get leg circuit IDs from options - leg1_raw = config_entry.options.get(INVERTER_LEG1, 0) - leg2_raw = config_entry.options.get(INVERTER_LEG2, 0) - - try: - leg1_tab = int(leg1_raw) - leg2_tab = int(leg2_raw) - except (TypeError, ValueError): - leg1_tab = 0 - leg2_tab = 0 - - if leg1_tab <= 0 or leg2_tab <= 0: - return entities - - # Find the circuit IDs for the specified tabs - leg1_circuit_id = None - leg2_circuit_id = None - - for circuit_id, circuit in span_panel.circuits.items(): - if hasattr(circuit, "tabs") and circuit.tabs: - if leg1_tab in circuit.tabs: - leg1_circuit_id = circuit_id - if leg2_tab in circuit.tabs: - leg2_circuit_id = circuit_id - - # Create solar sensors if both legs found - if leg1_circuit_id and leg2_circuit_id: - solar_net_energy_enabled = config_entry.options.get(ENABLE_SOLAR_NET_ENERGY_SENSORS, True) - - for solar_description in SOLAR_SENSORS: - # Skip net energy sensors if disabled - if not solar_net_energy_enabled and "net_energy" in solar_description.key: - continue - - if solar_description.key == "solar_current_power": - # Use regular solar sensor for power measurements - entities.append( - SpanSolarSensor( - coordinator, - solar_description, - span_panel, - leg1_circuit_id, - leg2_circuit_id, - ) - ) - else: - # Use energy sensor with grace period tracking for energy measurements - entities.append( - SpanSolarEnergySensor( - coordinator, - solar_description, - span_panel, - leg1_circuit_id, - leg2_circuit_id, - ) - ) - - return entities - - -def create_native_sensors( - coordinator: SpanPanelCoordinator, span_panel: SpanPanel, config_entry: ConfigEntry -) -> list[ - SpanPanelPanelStatus - | SpanPanelStatus - | SpanPanelPowerSensor - | SpanPanelEnergySensor - | SpanCircuitPowerSensor - | SpanCircuitEnergySensor - | SpanUnmappedCircuitSensor - | SpanPanelBattery - | SpanSolarSensor - | SpanSolarEnergySensor -]: - """Create all native sensors for the platform.""" - entities: list[ - SpanPanelPanelStatus - | SpanPanelStatus - | SpanPanelPowerSensor - | SpanPanelEnergySensor - | SpanCircuitPowerSensor - | SpanCircuitEnergySensor - | SpanUnmappedCircuitSensor - | SpanPanelBattery - | SpanSolarSensor - | SpanSolarEnergySensor - ] = [] - - # Create different sensor types - entities.extend(create_panel_sensors(coordinator, span_panel, config_entry)) - entities.extend(create_circuit_sensors(coordinator, span_panel, config_entry)) - entities.extend(create_unmapped_circuit_sensors(coordinator, span_panel)) - entities.extend(create_battery_sensors(coordinator, span_panel, config_entry)) - entities.extend(create_solar_sensors(coordinator, span_panel, config_entry)) - - return entities - - -def enable_unmapped_tab_entities(hass: HomeAssistant, entities: list[Any]) -> None: - """Enable unmapped tab entities in the entity registry if they were disabled.""" - entity_registry = er.async_get(hass) - for entity in entities: - # Check if this is an unmapped tab circuit sensor - if ( - hasattr(entity, "unique_id") - and entity.unique_id - and "unmapped_tab_" in entity.unique_id - ): - entity_id = entity.entity_id - registry_entry = entity_registry.async_get(entity_id) - if registry_entry and registry_entry.disabled: - _LOGGER.debug("Enabling previously disabled unmapped tab entity: %s", entity_id) - entity_registry.async_update_entity(entity_id, disabled_by=None) diff --git a/custom_components/span_panel/sensors/panel.py b/custom_components/span_panel/sensors/panel.py deleted file mode 100644 index dac2a608..00000000 --- a/custom_components/span_panel/sensors/panel.py +++ /dev/null @@ -1,254 +0,0 @@ -"""Panel-level sensors for Span Panel integration.""" - -from __future__ import annotations - -import logging -from typing import Any - -from homeassistant.helpers.typing import UNDEFINED - -from custom_components.span_panel.coordinator import SpanPanelCoordinator -from custom_components.span_panel.helpers import ( - construct_panel_unique_id_for_entry, - construct_synthetic_unique_id_for_entry, - get_panel_entity_suffix, -) -from custom_components.span_panel.sensor_definitions import ( - SpanPanelBatterySensorEntityDescription, - SpanPanelDataSensorEntityDescription, - SpanPanelStatusSensorEntityDescription, -) -from custom_components.span_panel.span_panel import SpanPanel -from custom_components.span_panel.span_panel_data import SpanPanelData -from custom_components.span_panel.span_panel_hardware_status import SpanPanelHardwareStatus -from custom_components.span_panel.span_panel_storage_battery import SpanPanelStorageBattery - -from .base import SpanEnergySensorBase, SpanSensorBase - -_LOGGER: logging.Logger = logging.getLogger(__name__) - - -class SpanPanelPanelStatus(SpanSensorBase[SpanPanelDataSensorEntityDescription, SpanPanelData]): - """Span Panel data status sensor entity.""" - - def __init__( - self, - data_coordinator: SpanPanelCoordinator, - description: SpanPanelDataSensorEntityDescription, - span_panel: SpanPanel, - ) -> None: - """Initialize the Span Panel data status sensor.""" - super().__init__(data_coordinator, description, span_panel) - - def _generate_unique_id( - self, span_panel: SpanPanel, description: SpanPanelDataSensorEntityDescription - ) -> str: - """Generate unique ID for panel data sensors.""" - return construct_panel_unique_id_for_entry( - self.coordinator, span_panel, description.key, self._device_name - ) - - def _generate_friendly_name( - self, span_panel: SpanPanel, description: SpanPanelDataSensorEntityDescription - ) -> str: - """Generate friendly name for panel data sensors.""" - if description.name is not None and description.name is not UNDEFINED: - return str(description.name) - return "Sensor" - - def get_data_source(self, span_panel: SpanPanel) -> SpanPanelData: - """Get the data source for the panel data status sensor.""" - return span_panel.panel - - -class SpanPanelStatus( - SpanSensorBase[SpanPanelStatusSensorEntityDescription, SpanPanelHardwareStatus] -): - """Span Panel hardware status sensor entity.""" - - def __init__( - self, - data_coordinator: SpanPanelCoordinator, - description: SpanPanelStatusSensorEntityDescription, - span_panel: SpanPanel, - ) -> None: - """Initialize the Span Panel hardware status sensor.""" - super().__init__(data_coordinator, description, span_panel) - - def _generate_unique_id( - self, span_panel: SpanPanel, description: SpanPanelStatusSensorEntityDescription - ) -> str: - """Generate unique ID for panel status sensors.""" - return construct_panel_unique_id_for_entry( - self.coordinator, span_panel, description.key, self._device_name - ) - - def _generate_friendly_name( - self, span_panel: SpanPanel, description: SpanPanelStatusSensorEntityDescription - ) -> str: - """Generate friendly name for panel status sensors.""" - if description.name is not None and description.name is not UNDEFINED: - return str(description.name) - return "Status" - - def get_data_source(self, span_panel: SpanPanel) -> SpanPanelHardwareStatus: - """Get the data source for the panel status sensor.""" - try: - result = span_panel.status - return result - except Exception as e: - _LOGGER.error("HARDWARE_STATUS: Error getting status data: %s", e) - raise - - -class SpanPanelBattery( - SpanSensorBase[SpanPanelBatterySensorEntityDescription, SpanPanelStorageBattery] -): - """Span Panel battery sensor entity.""" - - def __init__( - self, - data_coordinator: SpanPanelCoordinator, - description: SpanPanelBatterySensorEntityDescription, - span_panel: SpanPanel, - ) -> None: - """Initialize the Span Panel battery sensor.""" - super().__init__(data_coordinator, description, span_panel) - - def _generate_unique_id( - self, span_panel: SpanPanel, description: SpanPanelBatterySensorEntityDescription - ) -> str: - """Generate unique ID for battery sensors.""" - return construct_panel_unique_id_for_entry( - self.coordinator, span_panel, description.key, self._device_name - ) - - def _generate_friendly_name( - self, span_panel: SpanPanel, description: SpanPanelBatterySensorEntityDescription - ) -> str: - """Generate friendly name for battery sensors.""" - if description.name is not None and description.name is not UNDEFINED: - return str(description.name) - return "Battery" - - def get_data_source(self, span_panel: SpanPanel) -> SpanPanelStorageBattery: - """Get the data source for the battery sensor.""" - _LOGGER.debug("BATTERY_DEBUG: get_data_source called for battery sensor") - try: - result = span_panel.storage_battery - _LOGGER.debug("Successfully got battery data: %s", type(result).__name__) - return result - except Exception as e: - _LOGGER.error("Error getting battery data: %s", e) - raise - - -class SpanPanelPowerSensor(SpanSensorBase[SpanPanelDataSensorEntityDescription, SpanPanelData]): - """Enhanced panel power sensor with amperage attribute calculation.""" - - def __init__( - self, - data_coordinator: SpanPanelCoordinator, - description: SpanPanelDataSensorEntityDescription, - span_panel: SpanPanel, - ) -> None: - """Initialize the enhanced panel power sensor.""" - super().__init__(data_coordinator, description, span_panel) - - def _generate_unique_id( - self, span_panel: SpanPanel, description: SpanPanelDataSensorEntityDescription - ) -> str: - """Generate unique ID for panel power sensors.""" - # Use the same logic as migration: get entity suffix and use synthetic unique_id - - entity_suffix = get_panel_entity_suffix(description.key) - unique_id = construct_synthetic_unique_id_for_entry( - self.coordinator, span_panel, entity_suffix, self._device_name - ) - - return unique_id - - def _generate_friendly_name( - self, span_panel: SpanPanel, description: SpanPanelDataSensorEntityDescription - ) -> str: - """Generate friendly name for panel power sensors.""" - if description.name is not None and description.name is not UNDEFINED: - return str(description.name) - return "Power" - - def get_data_source(self, span_panel: SpanPanel) -> SpanPanelData: - """Get the data source for the panel power sensor.""" - return span_panel.panel - - @property - def extra_state_attributes(self) -> dict[str, Any] | None: - """Return additional state attributes including amperage calculation.""" - if not self.coordinator.last_update_success or not self.coordinator.data: - return None - - attributes = {} - - # Add voltage attribute (standard panel voltage) - attributes["voltage"] = "240" - - # Calculate amperage from power (P = V * I, so I = P / V) - if self.native_value is not None and isinstance(self.native_value, int | float): - try: - amperage = float(self.native_value) / 240.0 - attributes["amperage"] = str(round(amperage, 2)) - except (ValueError, ZeroDivisionError): - attributes["amperage"] = "0.0" - else: - attributes["amperage"] = "0.0" - - return attributes - - -class SpanPanelEnergySensor( - SpanEnergySensorBase[SpanPanelDataSensorEntityDescription, SpanPanelData] -): - """Panel energy sensor with grace period tracking.""" - - def __init__( - self, - data_coordinator: SpanPanelCoordinator, - description: SpanPanelDataSensorEntityDescription, - span_panel: SpanPanel, - ) -> None: - """Initialize the panel energy sensor.""" - super().__init__(data_coordinator, description, span_panel) - - def _generate_unique_id( - self, span_panel: SpanPanel, description: SpanPanelDataSensorEntityDescription - ) -> str: - """Generate unique ID for panel energy sensors.""" - # Use the same logic as migration: get entity suffix and use synthetic unique_id - - entity_suffix = get_panel_entity_suffix(description.key) - return construct_synthetic_unique_id_for_entry( - self.coordinator, span_panel, entity_suffix, self._device_name - ) - - def _generate_friendly_name( - self, span_panel: SpanPanel, description: SpanPanelDataSensorEntityDescription - ) -> str: - """Generate friendly name for panel energy sensors.""" - if description.name is not None and description.name is not UNDEFINED: - return str(description.name) - return "Energy" - - def get_data_source(self, span_panel: SpanPanel) -> SpanPanelData: - """Get the data source for the panel energy sensor.""" - return span_panel.panel - - @property - def extra_state_attributes(self) -> dict[str, Any] | None: - """Return additional state attributes including grace period and voltage.""" - # Get base grace period attributes - base_attributes = super().extra_state_attributes or {} - attributes = dict(base_attributes) - - # Add voltage attribute (standard panel voltage) - attributes["voltage"] = "240" - - return attributes if attributes else None diff --git a/custom_components/span_panel/sensors/solar.py b/custom_components/span_panel/sensors/solar.py deleted file mode 100644 index 626fc354..00000000 --- a/custom_components/span_panel/sensors/solar.py +++ /dev/null @@ -1,407 +0,0 @@ -"""Solar sensor classes for Span Panel integration.""" - -from __future__ import annotations - -from datetime import datetime -import logging -from typing import Any - -from homeassistant.const import STATE_UNKNOWN -from homeassistant.helpers.typing import UNDEFINED - -from custom_components.span_panel.coordinator import SpanPanelCoordinator -from custom_components.span_panel.helpers import ( - construct_panel_unique_id_for_entry, -) -from custom_components.span_panel.sensor_definitions import SpanSolarSensorEntityDescription -from custom_components.span_panel.span_panel import SpanPanel - -from .base import SpanEnergySensorBase, SpanSensorBase - -_LOGGER: logging.Logger = logging.getLogger(__name__) - - -class SpanSolarSensor(SpanSensorBase[SpanSolarSensorEntityDescription, SpanPanel]): - """Solar sensor that combines values from leg1 and leg2 circuits.""" - - def __init__( - self, - data_coordinator: SpanPanelCoordinator, - description: SpanSolarSensorEntityDescription, - span_panel: SpanPanel, - leg1_circuit_id: str, - leg2_circuit_id: str, - ) -> None: - """Initialize the solar sensor.""" - self.leg1_circuit_id = leg1_circuit_id - self.leg2_circuit_id = leg2_circuit_id - super().__init__(data_coordinator, description, span_panel) - - def _generate_unique_id( - self, span_panel: SpanPanel, description: SpanSolarSensorEntityDescription - ) -> str: - """Generate unique ID for solar sensors.""" - return construct_panel_unique_id_for_entry( - self.coordinator, span_panel, description.key, self._device_name - ) - - def _generate_friendly_name( - self, span_panel: SpanPanel, description: SpanSolarSensorEntityDescription - ) -> str: - """Generate friendly name for solar sensors.""" - if description.name is not None and description.name is not UNDEFINED: - return str(description.name) - return "Solar" - - def get_data_source(self, span_panel: SpanPanel) -> SpanPanel: - """Get the data source for the solar sensor.""" - return span_panel - - def _update_native_value(self) -> None: - """Update the native value by combining leg1 and leg2 circuit values.""" - if self.coordinator.panel_offline: - self._handle_solar_offline_state() - return - - if not self.coordinator.last_update_success or not self.coordinator.data: - # For sensors with state_class, use None (HA reports as unknown) - # For other sensors, use STATE_UNKNOWN string - state_class = getattr(self.entity_description, "state_class", None) - self._attr_native_value = None if state_class is not None else STATE_UNKNOWN - return - - self._calculate_solar_value() - - def _handle_solar_offline_state(self) -> None: - """Handle solar sensor state when panel is offline.""" - _LOGGER.debug("SOLAR_SENSOR_DEBUG: Panel is offline for %s", self._attr_name) - # For solar power sensors, set to 0.0 when offline (instantaneous values) - # For energy sensors, set to None when offline (HA will report as unknown) - # For numeric sensors (with state_class), set to None when offline (HA will report as unknown) - # For other sensors, set to STATE_UNKNOWN when offline - device_class = getattr(self.entity_description, "device_class", None) - state_class = getattr(self.entity_description, "state_class", None) - - if device_class == "power": - self._attr_native_value = 0.0 - elif device_class == "energy": - self._attr_native_value = None - elif state_class is not None: - # Any sensor with a state_class (measurement, total, etc.) expects numeric values - self._attr_native_value = None - else: - self._attr_native_value = STATE_UNKNOWN - - def _calculate_solar_value(self) -> None: - """Calculate the solar sensor value from leg circuits.""" - span_panel = self.coordinator.data - leg1_circuit = span_panel.circuits.get(self.leg1_circuit_id) - leg2_circuit = span_panel.circuits.get(self.leg2_circuit_id) - - if not leg1_circuit or not leg2_circuit: - # For sensors with state_class, use None (HA reports as unknown) - # For other sensors, use STATE_UNKNOWN string - state_class = getattr(self.entity_description, "state_class", None) - self._attr_native_value = None if state_class is not None else STATE_UNKNOWN - return - - try: - leg1_value, leg2_value = self._get_leg_values(leg1_circuit, leg2_circuit) - # Combine the values - description = self.entity_description - assert isinstance(description, SpanSolarSensorEntityDescription) - if hasattr(description, "calculation_type") and description.calculation_type == "sum": - self._attr_native_value = float(leg1_value) + float(leg2_value) - else: - self._attr_native_value = float(leg1_value) + float(leg2_value) - - except (ValueError, TypeError, AttributeError) as e: - _LOGGER.warning("Error calculating solar sensor value for %s: %s", description.key, e) - # For sensors with state_class, use None (HA reports as unknown) - # For other sensors, use STATE_UNKNOWN string - state_class = getattr(self.entity_description, "state_class", None) - self._attr_native_value = None if state_class is not None else STATE_UNKNOWN - - def _get_leg_values(self, leg1_circuit: str, leg2_circuit: str) -> tuple[float, float]: - """Get values from both leg circuits based on sensor type.""" - description = self.entity_description - assert isinstance(description, SpanSolarSensorEntityDescription) - - if description.key == "solar_current_power": - leg1_value = getattr(leg1_circuit, "instant_power", 0) or 0 - leg2_value = getattr(leg2_circuit, "instant_power", 0) or 0 - elif description.key == "solar_produced_energy": - leg1_value = getattr(leg1_circuit, "produced_energy", 0) or 0 - leg2_value = getattr(leg2_circuit, "produced_energy", 0) or 0 - elif description.key == "solar_consumed_energy": - leg1_value = getattr(leg1_circuit, "consumed_energy", 0) or 0 - leg2_value = getattr(leg2_circuit, "consumed_energy", 0) or 0 - elif description.key == "solar_net_energy": - # Net energy = produced - consumed for each leg, then sum - leg1_produced = getattr(leg1_circuit, "produced_energy", 0) or 0 - leg1_consumed = getattr(leg1_circuit, "consumed_energy", 0) or 0 - leg2_produced = getattr(leg2_circuit, "produced_energy", 0) or 0 - leg2_consumed = getattr(leg2_circuit, "consumed_energy", 0) or 0 - leg1_value = leg1_produced - leg1_consumed - leg2_value = leg2_produced - leg2_consumed - else: - leg1_value = 0 - leg2_value = 0 - - return leg1_value, leg2_value - - @property - def extra_state_attributes(self) -> dict[str, Any] | None: - """Return additional state attributes including tabs and voltage.""" - if not self.coordinator.last_update_success or not self.coordinator.data: - return None - - span_panel = self.coordinator.data - leg1_circuit = span_panel.circuits.get(self.leg1_circuit_id) - leg2_circuit = span_panel.circuits.get(self.leg2_circuit_id) - - if not leg1_circuit or not leg2_circuit: - return None - - return self._build_solar_attributes(leg1_circuit, leg2_circuit) - - def _build_solar_attributes(self, leg1_circuit: Any, leg2_circuit: Any) -> dict[str, Any]: - """Build state attributes for solar sensor.""" - attributes = {} - - # Add tabs attribute combining both legs - all_tabs = [] - if leg1_circuit.tabs: - all_tabs.extend(leg1_circuit.tabs) - if leg2_circuit.tabs: - all_tabs.extend(leg2_circuit.tabs) - - if all_tabs: - # Sort tabs for consistent ordering and remove duplicates - sorted_unique_tabs = sorted(set(all_tabs)) - attributes["tabs"] = self._format_tabs_attribute(sorted_unique_tabs) - - # Add voltage attribute based on total number of unique tabs - voltage = self._calculate_voltage_from_tabs(all_tabs, sorted_unique_tabs) - attributes["voltage"] = str(voltage) - - # Calculate amperage for power sensors - if ( - self.entity_description.key == "solar_current_power" - and self.native_value is not None - and isinstance(self.native_value, int | float) - ): - try: - amperage = float(self.native_value) / float(voltage) - attributes["amperage"] = str(round(amperage, 2)) - except (ValueError, ZeroDivisionError): - attributes["amperage"] = "0.0" - - return attributes - - def _format_tabs_attribute(self, sorted_unique_tabs: list[int]) -> str: - """Format the tabs attribute string.""" - if len(sorted_unique_tabs) == 1: - return f"tabs [{sorted_unique_tabs[0]}]" - elif len(sorted_unique_tabs) == 2: - return f"tabs [{sorted_unique_tabs[0]}:{sorted_unique_tabs[1]}]" - else: - # Multiple non-contiguous tabs - list them - tab_list = ", ".join(str(tab) for tab in sorted_unique_tabs) - return f"tabs [{tab_list}]" - - def _calculate_voltage_from_tabs( - self, all_tabs: list[int], sorted_unique_tabs: list[int] - ) -> int: - """Calculate voltage based on tab configuration.""" - if all_tabs: - unique_tab_count = len(sorted_unique_tabs) - if unique_tab_count == 1: - return 120 - elif unique_tab_count == 2: - return 240 - else: - # More than 2 tabs is not valid for US electrical system - return 240 # Default to 240V for invalid configurations - else: - return 240 # Default to 240V if no tabs information - - -class SpanSolarEnergySensor(SpanEnergySensorBase[SpanSolarSensorEntityDescription, SpanPanel]): - """Solar energy sensor that combines values from leg1 and leg2 circuits with grace period tracking.""" - - def __init__( - self, - data_coordinator: SpanPanelCoordinator, - description: SpanSolarSensorEntityDescription, - span_panel: SpanPanel, - leg1_circuit_id: str, - leg2_circuit_id: str, - ) -> None: - """Initialize the solar energy sensor.""" - self.leg1_circuit_id = leg1_circuit_id - self.leg2_circuit_id = leg2_circuit_id - super().__init__(data_coordinator, description, span_panel) - - def _generate_unique_id( - self, span_panel: SpanPanel, description: SpanSolarSensorEntityDescription - ) -> str: - """Generate unique ID for solar energy sensors.""" - return construct_panel_unique_id_for_entry( - self.coordinator, span_panel, description.key, self._device_name - ) - - def _generate_friendly_name( - self, span_panel: SpanPanel, description: SpanSolarSensorEntityDescription - ) -> str: - """Generate friendly name for solar energy sensors.""" - if description.name is not None and description.name is not UNDEFINED: - return str(description.name) - return "Solar Energy" - - def get_data_source(self, span_panel: SpanPanel) -> SpanPanel: - """Get the data source for the solar energy sensor.""" - return span_panel - - def _update_native_value(self) -> None: - """Update the native value by combining leg1 and leg2 circuit values.""" - if self.coordinator.panel_offline: - _LOGGER.debug( - "SOLAR_ENERGY_SENSOR_DEBUG: Panel is offline for %s, using grace period logic", - self._attr_name, - ) - # Use grace period logic when offline - self._handle_offline_grace_period() - return - - if not self.coordinator.last_update_success or not self.coordinator.data: - # For sensors with state_class, use None (HA reports as unknown) - # For other sensors, use STATE_UNKNOWN string - state_class = getattr(self.entity_description, "state_class", None) - self._attr_native_value = None if state_class is not None else STATE_UNKNOWN - return - - self._calculate_solar_energy_value() - - def _calculate_solar_energy_value(self) -> None: - """Calculate the solar energy sensor value from leg circuits.""" - span_panel = self.coordinator.data - leg1_circuit = span_panel.circuits.get(self.leg1_circuit_id) - leg2_circuit = span_panel.circuits.get(self.leg2_circuit_id) - - if not leg1_circuit or not leg2_circuit: - # For sensors with state_class, use None (HA reports as unknown) - # For other sensors, use STATE_UNKNOWN string - state_class = getattr(self.entity_description, "state_class", None) - self._attr_native_value = None if state_class is not None else STATE_UNKNOWN - return - - try: - leg1_value, leg2_value = self._get_energy_leg_values(leg1_circuit, leg2_circuit) - # Combine the values - description = self.entity_description - assert isinstance(description, SpanSolarSensorEntityDescription) - if hasattr(description, "calculation_type") and description.calculation_type == "sum": - self._attr_native_value = float(leg1_value) + float(leg2_value) - else: - self._attr_native_value = float(leg1_value) + float(leg2_value) - - # Track valid state for grace period (only when we have a valid value) - if self._attr_native_value is not None and isinstance( - self._attr_native_value, int | float - ): - self._last_valid_state = float(self._attr_native_value) - self._last_valid_changed = datetime.now() - - except (ValueError, TypeError, AttributeError) as e: - _LOGGER.warning( - "Error calculating solar energy sensor value for %s: %s", description.key, e - ) - # For sensors with state_class, use None (HA reports as unknown) - # For other sensors, use STATE_UNKNOWN string - state_class = getattr(self.entity_description, "state_class", None) - self._attr_native_value = None if state_class is not None else STATE_UNKNOWN - - def _get_energy_leg_values(self, leg1_circuit: Any, leg2_circuit: Any) -> tuple[float, float]: - """Get energy values from both leg circuits based on sensor type.""" - description = self.entity_description - assert isinstance(description, SpanSolarSensorEntityDescription) - - if description.key == "solar_produced_energy": - leg1_value = getattr(leg1_circuit, "produced_energy", 0) or 0 - leg2_value = getattr(leg2_circuit, "produced_energy", 0) or 0 - elif description.key == "solar_consumed_energy": - leg1_value = getattr(leg1_circuit, "consumed_energy", 0) or 0 - leg2_value = getattr(leg2_circuit, "consumed_energy", 0) or 0 - elif description.key == "solar_net_energy": - # Net energy = produced - consumed for each leg, then sum - leg1_produced = getattr(leg1_circuit, "produced_energy", 0) or 0 - leg1_consumed = getattr(leg1_circuit, "consumed_energy", 0) or 0 - leg2_produced = getattr(leg2_circuit, "produced_energy", 0) or 0 - leg2_consumed = getattr(leg2_circuit, "consumed_energy", 0) or 0 - leg1_value = leg1_produced - leg1_consumed - leg2_value = leg2_produced - leg2_consumed - else: - leg1_value = 0 - leg2_value = 0 - - return leg1_value, leg2_value - - @property - def extra_state_attributes(self) -> dict[str, Any] | None: - """Return additional state attributes including grace period and solar info.""" - # Get base grace period attributes - base_attributes = super().extra_state_attributes or {} - attributes = dict(base_attributes) - - # Add solar-specific attributes if we have data - if self.coordinator.data: - span_panel = self.coordinator.data - leg1_circuit = span_panel.circuits.get(self.leg1_circuit_id) - leg2_circuit = span_panel.circuits.get(self.leg2_circuit_id) - - if leg1_circuit and leg2_circuit: - # Add tabs attribute combining both legs - all_tabs = [] - if leg1_circuit.tabs: - all_tabs.extend(leg1_circuit.tabs) - if leg2_circuit.tabs: - all_tabs.extend(leg2_circuit.tabs) - - if all_tabs: - # Sort tabs for consistent ordering and remove duplicates - sorted_unique_tabs = sorted(set(all_tabs)) - attributes["tabs"] = self._format_energy_tabs_attribute(sorted_unique_tabs) - - # Add voltage attribute based on total number of unique tabs - voltage = self._calculate_energy_voltage_from_tabs(all_tabs, sorted_unique_tabs) - attributes["voltage"] = str(voltage) - - return attributes if attributes else None - - def _format_energy_tabs_attribute(self, sorted_unique_tabs: list[int]) -> str: - """Format the tabs attribute string for energy sensors.""" - if len(sorted_unique_tabs) == 1: - return f"tabs [{sorted_unique_tabs[0]}]" - elif len(sorted_unique_tabs) == 2: - return f"tabs [{sorted_unique_tabs[0]}:{sorted_unique_tabs[1]}]" - else: - # Multiple non-contiguous tabs - list them - tab_list = ", ".join(str(tab) for tab in sorted_unique_tabs) - return f"tabs [{tab_list}]" - - def _calculate_energy_voltage_from_tabs( - self, all_tabs: list[int], sorted_unique_tabs: list[int] - ) -> int: - """Calculate voltage based on tab configuration for energy sensors.""" - if all_tabs: - unique_tab_count = len(sorted_unique_tabs) - if unique_tab_count == 1: - return 120 - elif unique_tab_count == 2: - return 240 - else: - # More than 2 tabs is not valid for US electrical system - return 240 # Default to 240V for invalid configurations - else: - return 240 # Default to 240V if no tabs information diff --git a/custom_components/span_panel/services.py b/custom_components/span_panel/services.py new file mode 100644 index 00000000..0500424b --- /dev/null +++ b/custom_components/span_panel/services.py @@ -0,0 +1,644 @@ +"""Service registration for the Span Panel integration.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, cast + +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant, ServiceCall, ServiceResponse, SupportsResponse +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import device_registry as dr, entity_registry as er +import voluptuous as vol + +from .const import DEFAULT_GRAPH_HORIZON, DOMAIN, VALID_GRAPH_HORIZONS +from .current_monitor import CurrentMonitor +from .frontend import FavoriteKind, async_get_favorites, async_set_favorite +from .graph_horizon import GraphHorizonManager +from .id_builder import build_circuit_unique_id, extract_circuit_uuid_from_unique_id +from .options import ( + CONTINUOUS_THRESHOLD_PCT, + COOLDOWN_DURATION_M, + SPIKE_THRESHOLD_PCT, + WINDOW_DURATION_M, +) + +if TYPE_CHECKING: + from homeassistant.config_entries import ConfigEntry + + from . import SpanPanelRuntimeData + +_LOGGER = logging.getLogger(__name__) + +# Map internal device_type values to external manifest format +_DEVICE_TYPE_MAP: dict[str, str] = {"bess": "battery"} + + +def _async_register_services(hass: HomeAssistant) -> None: + """Register domain-level services (called once per HA instance).""" + + async def async_handle_export_manifest( + _call: ServiceCall, + ) -> ServiceResponse: + """Export circuit topology manifest for all configured SPAN panels.""" + from . import SpanPanelRuntimeData # pylint: disable=import-outside-toplevel + + if not hass.config_entries.async_loaded_entries(DOMAIN): + raise ServiceValidationError( + "No SPAN panel configuration entries are loaded. " + "Add and configure a SPAN panel before calling this service.", + translation_domain=DOMAIN, + translation_key="export_manifest_no_entries", + ) + + entity_reg = er.async_get(hass) + panels = [] + + for entry in hass.config_entries.async_loaded_entries(DOMAIN): + if not hasattr(entry, "runtime_data") or not isinstance( + entry.runtime_data, SpanPanelRuntimeData + ): + continue + + snapshot = entry.runtime_data.coordinator.data + if snapshot is None: + continue + serial = snapshot.serial_number + circuits = [] + + for circuit_id, circuit in snapshot.circuits.items(): + if circuit_id.startswith("unmapped_tab_"): + continue + + tabs = getattr(circuit, "tabs", None) + if not tabs: + continue + + unique_id = build_circuit_unique_id(serial, circuit_id, "instantPowerW") + entity_id = entity_reg.async_get_entity_id("sensor", DOMAIN, unique_id) + if entity_id is None: + continue + + raw_type = getattr(circuit, "device_type", "circuit") + + circuits.append( + { + "entity_id": entity_id, + "template": f"clone_{min(tabs)}", + "device_type": _DEVICE_TYPE_MAP.get(raw_type, raw_type), + "tabs": list(tabs), + } + ) + + if circuits: + panels.append( + { + "serial": serial, + "host": entry.data[CONF_HOST], + "circuits": circuits, + } + ) + + return cast(ServiceResponse, {"panels": panels}) + + hass.services.async_register( + DOMAIN, + "export_circuit_manifest", + async_handle_export_manifest, + schema=vol.Schema({}), + supports_response=SupportsResponse.ONLY, + ) + + +def _build_set_circuit_threshold_schema() -> vol.Schema: + """Build schema for set_circuit_threshold service.""" + return vol.Schema( + { + vol.Required("circuit_id"): str, + vol.Optional(CONTINUOUS_THRESHOLD_PCT): vol.All(int, vol.Range(min=1, max=200)), + vol.Optional(SPIKE_THRESHOLD_PCT): vol.All(int, vol.Range(min=1, max=200)), + vol.Optional(WINDOW_DURATION_M): vol.All(int, vol.Range(min=1, max=180)), + vol.Optional(COOLDOWN_DURATION_M): vol.All(int, vol.Range(min=1, max=180)), + vol.Optional("monitoring_enabled"): bool, + vol.Optional("config_entry_id"): str, + } + ) + + +def _build_set_mains_threshold_schema() -> vol.Schema: + """Build schema for set_mains_threshold service.""" + return vol.Schema( + { + vol.Required("leg"): str, + vol.Optional(CONTINUOUS_THRESHOLD_PCT): vol.All(int, vol.Range(min=1, max=200)), + vol.Optional(SPIKE_THRESHOLD_PCT): vol.All(int, vol.Range(min=1, max=200)), + vol.Optional(WINDOW_DURATION_M): vol.All(int, vol.Range(min=1, max=180)), + vol.Optional(COOLDOWN_DURATION_M): vol.All(int, vol.Range(min=1, max=180)), + vol.Optional("monitoring_enabled"): bool, + vol.Optional("config_entry_id"): str, + } + ) + + +def _build_clear_circuit_threshold_schema() -> vol.Schema: + """Build schema for clear_circuit_threshold service.""" + return vol.Schema( + { + vol.Required("circuit_id"): str, + vol.Optional("config_entry_id"): str, + } + ) + + +def _build_clear_mains_threshold_schema() -> vol.Schema: + """Build schema for clear_mains_threshold service.""" + return vol.Schema( + { + vol.Required("leg"): str, + vol.Optional("config_entry_id"): str, + } + ) + + +def _build_set_global_monitoring_schema() -> vol.Schema: + """Build schema for set_global_monitoring service.""" + return vol.Schema( + { + vol.Optional("enabled"): bool, + vol.Optional("continuous_threshold_pct"): vol.All(int, vol.Range(min=1, max=200)), + vol.Optional("spike_threshold_pct"): vol.All(int, vol.Range(min=1, max=200)), + vol.Optional("window_duration_m"): vol.All(int, vol.Range(min=1, max=180)), + vol.Optional("cooldown_duration_m"): vol.All(int, vol.Range(min=1, max=180)), + vol.Optional("notify_targets"): str, + vol.Optional("notification_title_template"): str, + vol.Optional("notification_message_template"): str, + vol.Optional("notification_priority"): vol.In( + ["default", "passive", "active", "time-sensitive", "critical"] + ), + vol.Optional("config_entry_id"): str, + } + ) + + +def _async_register_monitoring_services(hass: HomeAssistant) -> None: + """Register current monitoring services.""" + + def _get_runtime_data( + config_entry_id: str | None = None, + ) -> tuple[SpanPanelRuntimeData, ConfigEntry] | None: + """Find SPAN panel runtime data and entry. + + When config_entry_id is provided, returns that specific entry. + Otherwise falls back to the first loaded entry. + """ + from . import SpanPanelRuntimeData # pylint: disable=import-outside-toplevel + + for entry in hass.config_entries.async_loaded_entries(DOMAIN): + if not hasattr(entry, "runtime_data") or not isinstance( + entry.runtime_data, SpanPanelRuntimeData + ): + continue + if config_entry_id is None or entry.entry_id == config_entry_id: + return entry.runtime_data, entry + return None + + def _get_monitor( + call: ServiceCall, + config_entry_id: str | None = None, + ) -> CurrentMonitor: + """Find the CurrentMonitor for the given entry.""" + entry_id = config_entry_id or call.data.get("config_entry_id") + result = _get_runtime_data(entry_id) + if result is not None: + runtime_data, _entry = result + if runtime_data.coordinator.current_monitor is not None: + return runtime_data.coordinator.current_monitor + raise ServiceValidationError( + "No SPAN panel with current monitoring enabled.", + translation_domain=DOMAIN, + translation_key="monitoring_not_enabled", + ) + + async def _get_or_create_monitor( + config_entry_id: str | None = None, + ) -> CurrentMonitor: + """Find or bootstrap a CurrentMonitor for the specified panel.""" + result = _get_runtime_data(config_entry_id) + if result is None: + raise ServiceValidationError( + "No SPAN panel integration loaded.", + translation_domain=DOMAIN, + translation_key="monitoring_not_enabled", + ) + runtime_data, entry = result + if runtime_data.coordinator.current_monitor is not None: + return runtime_data.coordinator.current_monitor + monitor = CurrentMonitor(hass, entry) + await monitor.async_start() + runtime_data.coordinator.current_monitor = monitor + # Seed the monitor with the coordinator's latest snapshot so that + # get_monitoring_status returns circuits immediately (before the + # next coordinator poll cycle). + snapshot = runtime_data.coordinator.data + if snapshot is not None: + monitor.process_snapshot(snapshot) + return monitor + + async def async_handle_set_circuit_threshold(call: ServiceCall) -> None: + monitor = _get_monitor(call) + data = dict(call.data) + entity_id = data.pop("circuit_id") + data.pop("config_entry_id", None) + circuit_id = monitor.resolve_entity_to_circuit_id(entity_id) + monitor.set_circuit_override(circuit_id, data) + + async def async_handle_clear_circuit_threshold(call: ServiceCall) -> None: + monitor = _get_monitor(call) + entity_id = call.data["circuit_id"] + circuit_id = monitor.resolve_entity_to_circuit_id(entity_id) + monitor.clear_circuit_override(circuit_id) + + async def async_handle_set_mains_threshold(call: ServiceCall) -> None: + monitor = _get_monitor(call) + data = dict(call.data) + entity_id = data.pop("leg") + data.pop("config_entry_id", None) + leg = monitor.resolve_entity_to_mains_leg(entity_id) + monitor.set_mains_override(leg, data) + + async def async_handle_clear_mains_threshold(call: ServiceCall) -> None: + monitor = _get_monitor(call) + entity_id = call.data["leg"] + leg = monitor.resolve_entity_to_mains_leg(entity_id) + monitor.clear_mains_override(leg) + + async def async_handle_get_monitoring_status( + call: ServiceCall, + ) -> ServiceResponse: + entry_id = call.data.get("config_entry_id") + result = _get_runtime_data(entry_id) + if result is None: + return cast(ServiceResponse, {"enabled": False}) + runtime_data, _entry = result + monitor = runtime_data.coordinator.current_monitor + if monitor is None: + return cast(ServiceResponse, {"enabled": False}) + status = monitor.get_monitoring_status() + status["enabled"] = True + status["global_settings"] = monitor.get_global_settings() + return cast(ServiceResponse, status) + + hass.services.async_register( + DOMAIN, + "set_circuit_threshold", + async_handle_set_circuit_threshold, + schema=_build_set_circuit_threshold_schema(), + ) + hass.services.async_register( + DOMAIN, + "clear_circuit_threshold", + async_handle_clear_circuit_threshold, + schema=_build_clear_circuit_threshold_schema(), + ) + hass.services.async_register( + DOMAIN, + "set_mains_threshold", + async_handle_set_mains_threshold, + schema=_build_set_mains_threshold_schema(), + ) + hass.services.async_register( + DOMAIN, + "clear_mains_threshold", + async_handle_clear_mains_threshold, + schema=_build_clear_mains_threshold_schema(), + ) + + async def async_handle_set_global_monitoring(call: ServiceCall) -> None: + data = dict(call.data) + enabled = data.pop("enabled", None) + entry_id = data.pop("config_entry_id", None) + + if enabled is False: + # Disable monitoring: stop the monitor and mark storage as disabled + result = _get_runtime_data(entry_id) + if result is not None: + runtime_data, entry = result + monitor = runtime_data.coordinator.current_monitor + if monitor is not None: + monitor.async_stop() + await monitor.async_save_disabled() + runtime_data.coordinator.current_monitor = None + return + + monitor = await _get_or_create_monitor(entry_id) + if data: + monitor.set_global_settings(data) + + hass.services.async_register( + DOMAIN, + "get_monitoring_status", + async_handle_get_monitoring_status, + schema=vol.Schema({vol.Optional("config_entry_id"): str}), + supports_response=SupportsResponse.ONLY, + ) + hass.services.async_register( + DOMAIN, + "set_global_monitoring", + async_handle_set_global_monitoring, + schema=_build_set_global_monitoring_schema(), + ) + + async def async_handle_test_notification(call: ServiceCall) -> None: + from .alert_dispatcher import dispatch_test_alert # pylint: disable=import-outside-toplevel + + entry_id = call.data.get("config_entry_id") + monitor = await _get_or_create_monitor(entry_id) + settings = monitor.get_global_settings() + dispatch_test_alert(hass, settings) + + hass.services.async_register( + DOMAIN, + "test_notification", + async_handle_test_notification, + schema=vol.Schema({vol.Optional("config_entry_id"): str}), + ) + + +def _async_register_graph_horizon_services(hass: HomeAssistant) -> None: + """Register graph time horizon services.""" + + def _get_horizon_manager( + call: ServiceCall, + ) -> GraphHorizonManager: + """Find the GraphHorizonManager for the given entry.""" + from . import SpanPanelRuntimeData # pylint: disable=import-outside-toplevel + + entry_id = call.data.get("config_entry_id") + for entry in hass.config_entries.async_loaded_entries(DOMAIN): + if not hasattr(entry, "runtime_data") or not isinstance( + entry.runtime_data, SpanPanelRuntimeData + ): + continue + if entry_id is None or entry.entry_id == entry_id: + mgr = entry.runtime_data.coordinator.graph_horizon_manager + if mgr is not None: + return mgr + raise ServiceValidationError( + "No SPAN panel with graph horizon manager found.", + translation_domain=DOMAIN, + translation_key="graph_horizon_not_available", + ) + + async def async_handle_set_graph_time_horizon(call: ServiceCall) -> None: + manager = _get_horizon_manager(call) + horizon = call.data["horizon"] + manager.set_global_horizon(horizon) + + async def async_handle_set_circuit_graph_horizon(call: ServiceCall) -> None: + manager = _get_horizon_manager(call) + circuit_id = call.data["circuit_id"] + horizon = call.data["horizon"] + manager.set_circuit_horizon(circuit_id, horizon) + + async def async_handle_clear_circuit_graph_horizon(call: ServiceCall) -> None: + manager = _get_horizon_manager(call) + circuit_id = call.data["circuit_id"] + manager.clear_circuit_horizon(circuit_id) + + async def async_handle_set_subdevice_graph_horizon(call: ServiceCall) -> None: + manager = _get_horizon_manager(call) + subdevice_id = call.data["subdevice_id"] + horizon = call.data["horizon"] + manager.set_subdevice_horizon(subdevice_id, horizon) + + async def async_handle_clear_subdevice_graph_horizon(call: ServiceCall) -> None: + manager = _get_horizon_manager(call) + subdevice_id = call.data["subdevice_id"] + manager.clear_subdevice_horizon(subdevice_id) + + async def async_handle_get_graph_settings( + call: ServiceCall, + ) -> ServiceResponse: + from . import SpanPanelRuntimeData # pylint: disable=import-outside-toplevel + + entry_id = call.data.get("config_entry_id") + for entry in hass.config_entries.async_loaded_entries(DOMAIN): + if not hasattr(entry, "runtime_data") or not isinstance( + entry.runtime_data, SpanPanelRuntimeData + ): + continue + if entry_id is None or entry.entry_id == entry_id: + mgr = entry.runtime_data.coordinator.graph_horizon_manager + if mgr is not None: + return cast(ServiceResponse, mgr.get_all_settings()) + return cast(ServiceResponse, {"global_horizon": DEFAULT_GRAPH_HORIZON, "circuits": {}}) + + hass.services.async_register( + DOMAIN, + "set_graph_time_horizon", + async_handle_set_graph_time_horizon, + schema=vol.Schema( + { + vol.Required("horizon"): vol.In(VALID_GRAPH_HORIZONS), + vol.Optional("config_entry_id"): str, + } + ), + ) + hass.services.async_register( + DOMAIN, + "set_circuit_graph_horizon", + async_handle_set_circuit_graph_horizon, + schema=vol.Schema( + { + vol.Required("circuit_id"): str, + vol.Required("horizon"): vol.In(VALID_GRAPH_HORIZONS), + vol.Optional("config_entry_id"): str, + } + ), + ) + hass.services.async_register( + DOMAIN, + "clear_circuit_graph_horizon", + async_handle_clear_circuit_graph_horizon, + schema=vol.Schema( + { + vol.Required("circuit_id"): str, + vol.Optional("config_entry_id"): str, + } + ), + ) + hass.services.async_register( + DOMAIN, + "set_subdevice_graph_horizon", + async_handle_set_subdevice_graph_horizon, + schema=vol.Schema( + { + vol.Required("subdevice_id"): str, + vol.Required("horizon"): vol.In(VALID_GRAPH_HORIZONS), + vol.Optional("config_entry_id"): str, + } + ), + ) + hass.services.async_register( + DOMAIN, + "clear_subdevice_graph_horizon", + async_handle_clear_subdevice_graph_horizon, + schema=vol.Schema( + { + vol.Required("subdevice_id"): str, + vol.Optional("config_entry_id"): str, + } + ), + ) + hass.services.async_register( + DOMAIN, + "get_graph_settings", + async_handle_get_graph_settings, + schema=vol.Schema({vol.Optional("config_entry_id"): str}), + supports_response=SupportsResponse.ONLY, + ) + + +def _async_register_favorites_services(hass: HomeAssistant) -> None: + """Register cross-panel favorites services (domain-level). + + The public API takes ``entity_id`` — any sensor on a SPAN circuit or + sub-device — and resolves it server-side to the internal + ``(panel_device_id, kind, target_id)`` tuple used in storage. Circuit + UUIDs and HA device IDs are not part of the user-visible surface. + """ + + def _resolve_entity_to_favorite_target(entity_id: str) -> tuple[str, FavoriteKind, str]: + """Return ``(panel_device_id, kind, target_id)`` for a SPAN entity. + + ``kind`` is ``"circuits"`` or ``"sub_devices"``. For circuits, + ``target_id`` is the panel-local circuit uuid (extracted from the + entity's unique_id). For sub-devices, ``target_id`` is the HA + device id of the BESS/EVSE; the panel id walks up via ``via_device_id``. + + Failure paths use distinct translation keys so users see the + actual reason their pick was rejected. + """ + entity_reg = er.async_get(hass) + entry = entity_reg.async_get(entity_id) + if entry is None or entry.platform != DOMAIN: + raise ServiceValidationError( + f"Entity {entity_id} is not a SPAN Panel entity.", + translation_domain=DOMAIN, + translation_key="favorite_not_span_entity", + translation_placeholders={"entity_id": entity_id}, + ) + + if entry.device_id is None: + raise ServiceValidationError( + f"Entity {entity_id} is not attached to a device.", + translation_domain=DOMAIN, + translation_key="favorite_no_device", + translation_placeholders={"entity_id": entity_id}, + ) + + device_registry = dr.async_get(hass) + device_entry = device_registry.async_get(entry.device_id) + if device_entry is None or not any( + domain == DOMAIN for domain, _ in device_entry.identifiers + ): + raise ServiceValidationError( + f"Entity {entity_id} does not belong to a SPAN Panel device.", + translation_domain=DOMAIN, + translation_key="favorite_not_span_entity", + translation_placeholders={"entity_id": entity_id}, + ) + + # Resolve the panel device id. Sub-devices register with + # via_device_id; main panels never do, so via_device_id presence is a + # reliable discriminator (BESS / EVSE today) and we walk up to the + # parent SPAN Panel here. + if device_entry.via_device_id is not None: + parent = device_registry.async_get(device_entry.via_device_id) + if parent is None or not any(domain == DOMAIN for domain, _ in parent.identifiers): + raise ServiceValidationError( + f"Sub-device {entity_id} has no SPAN Panel parent.", + translation_domain=DOMAIN, + translation_key="favorite_subdevice_no_span_parent", + translation_placeholders={"entity_id": entity_id}, + ) + panel_device_id = parent.id + else: + panel_device_id = device_entry.id + + # Sub-device-attached entities favorite the sub-device itself. + # Rationale: the device card on the dashboard already represents + # both the sub-device's status sensors AND its feed-circuit + # power. Routing a feed-circuit sensor (current/power, whose + # unique_id encodes a circuit UUID) to a circuit favorite would + # make a Favorites view show the same physical thing twice — a + # device card and a circuit row — and prevent the user from + # ever favoriting "the device" via a click on a feed-circuit + # entity. Treat any entity attached to a sub-device as the + # device-favorite for that sub-device. + if device_entry.via_device_id is not None: + return panel_device_id, "sub_devices", device_entry.id + + # Main-panel entity (regular breaker circuit) — favorite the + # circuit. Requires a unique_id that embeds the 32-char circuit + # UUID (``span_{serial}_{circuit_uuid}_{suffix}``). + circuit_uuid = ( + extract_circuit_uuid_from_unique_id(entry.unique_id) if entry.unique_id else None + ) + if circuit_uuid is not None: + return panel_device_id, "circuits", circuit_uuid + + if not entry.unique_id: + raise ServiceValidationError( + f"Entity {entity_id} has no unique id to resolve.", + translation_domain=DOMAIN, + translation_key="favorite_no_unique_id", + translation_placeholders={"entity_id": entity_id}, + ) + raise ServiceValidationError( + f"Could not derive a favorite target from entity {entity_id}. " + "Pick a circuit sensor (current/power) or a sub-device sensor.", + translation_domain=DOMAIN, + translation_key="favorite_no_circuit_uuid", + translation_placeholders={"entity_id": entity_id}, + ) + + async def async_handle_get_favorites(_call: ServiceCall) -> ServiceResponse: + favorites = await async_get_favorites(hass) + return cast(ServiceResponse, {"favorites": favorites}) + + async def async_handle_add_favorite(call: ServiceCall) -> ServiceResponse: + entity_id = call.data["entity_id"] + panel_device_id, kind, target_id = _resolve_entity_to_favorite_target(entity_id) + favorites = await async_set_favorite(hass, panel_device_id, kind, target_id, True) + return cast(ServiceResponse, {"favorites": favorites}) + + async def async_handle_remove_favorite(call: ServiceCall) -> ServiceResponse: + entity_id = call.data["entity_id"] + panel_device_id, kind, target_id = _resolve_entity_to_favorite_target(entity_id) + favorites = await async_set_favorite(hass, panel_device_id, kind, target_id, False) + return cast(ServiceResponse, {"favorites": favorites}) + + _favorite_mutation_schema = vol.Schema({vol.Required("entity_id"): str}) + + hass.services.async_register( + DOMAIN, + "get_favorites", + async_handle_get_favorites, + schema=vol.Schema({}), + supports_response=SupportsResponse.ONLY, + ) + hass.services.async_register( + DOMAIN, + "add_favorite", + async_handle_add_favorite, + schema=_favorite_mutation_schema, + supports_response=SupportsResponse.OPTIONAL, + ) + hass.services.async_register( + DOMAIN, + "remove_favorite", + async_handle_remove_favorite, + schema=_favorite_mutation_schema, + supports_response=SupportsResponse.OPTIONAL, + ) diff --git a/custom_components/span_panel/services.yaml b/custom_components/span_panel/services.yaml index 3f875223..530b9fcb 100644 --- a/custom_components/span_panel/services.yaml +++ b/custom_components/span_panel/services.yaml @@ -1,11 +1,285 @@ -export_synthetic_config: - name: Export Synthetic Sensor Config - description: Export the current synthetic sensor configuration to a YAML file. You can specify either a directory (file will be named automatically) or a full file path. +export_circuit_manifest: + +set_circuit_threshold: fields: - directory: - name: File Path - description: Either a directory path (file will be named automatically) or a full file path ending in .yaml where the configuration will be saved. + circuit_id: + required: true + selector: + entity: + domain: sensor + integration: span_panel + device_class: current + continuous_threshold_pct: + selector: + number: + min: 1 + max: 200 + unit_of_measurement: "%" + spike_threshold_pct: + selector: + number: + min: 1 + max: 200 + unit_of_measurement: "%" + window_duration_m: + selector: + number: + min: 1 + max: 180 + unit_of_measurement: min + cooldown_duration_m: + selector: + number: + min: 1 + max: 180 + unit_of_measurement: min + monitoring_enabled: + selector: + boolean: + config_entry_id: + selector: + config_entry: + integration: span_panel + +clear_circuit_threshold: + fields: + circuit_id: + required: true + selector: + entity: + domain: sensor + integration: span_panel + device_class: current + config_entry_id: + selector: + config_entry: + integration: span_panel + +set_mains_threshold: + fields: + leg: + required: true + selector: + entity: + domain: sensor + integration: span_panel + device_class: current + continuous_threshold_pct: + selector: + number: + min: 1 + max: 200 + unit_of_measurement: "%" + spike_threshold_pct: + selector: + number: + min: 1 + max: 200 + unit_of_measurement: "%" + window_duration_m: + selector: + number: + min: 1 + max: 180 + unit_of_measurement: min + cooldown_duration_m: + selector: + number: + min: 1 + max: 180 + unit_of_measurement: min + monitoring_enabled: + selector: + boolean: + config_entry_id: + selector: + config_entry: + integration: span_panel + +clear_mains_threshold: + fields: + leg: + required: true + selector: + entity: + domain: sensor + integration: span_panel + device_class: current + config_entry_id: + selector: + config_entry: + integration: span_panel + +get_monitoring_status: + fields: + config_entry_id: + selector: + config_entry: + integration: span_panel + +set_global_monitoring: + fields: + enabled: + selector: + boolean: + continuous_threshold_pct: + selector: + number: + min: 1 + max: 200 + unit_of_measurement: "%" + spike_threshold_pct: + selector: + number: + min: 1 + max: 200 + unit_of_measurement: "%" + window_duration_m: + selector: + number: + min: 1 + max: 180 + unit_of_measurement: min + cooldown_duration_m: + selector: + number: + min: 1 + max: 180 + unit_of_measurement: min + notify_targets: + selector: + text: + notification_title_template: + selector: + text: + notification_message_template: + selector: + text: + notification_priority: + selector: + select: + options: + - "default" + - "passive" + - "active" + - "time-sensitive" + - "critical" + config_entry_id: + selector: + config_entry: + integration: span_panel + +test_notification: + fields: + config_entry_id: + selector: + config_entry: + integration: span_panel + +set_graph_time_horizon: + fields: + horizon: + required: true + selector: + select: + options: + - "5m" + - "1h" + - "1d" + - "1w" + - "1M" + config_entry_id: + selector: + config_entry: + integration: span_panel + +set_circuit_graph_horizon: + fields: + circuit_id: + required: true + selector: + text: + horizon: + required: true + selector: + select: + options: + - "5m" + - "1h" + - "1d" + - "1w" + - "1M" + config_entry_id: + selector: + config_entry: + integration: span_panel + +clear_circuit_graph_horizon: + fields: + circuit_id: + required: true + selector: + text: + config_entry_id: + selector: + config_entry: + integration: span_panel + +set_subdevice_graph_horizon: + fields: + subdevice_id: + required: true + selector: + text: + horizon: + required: true + selector: + select: + options: + - "5m" + - "1h" + - "1d" + - "1w" + - "1M" + config_entry_id: + selector: + config_entry: + integration: span_panel + +clear_subdevice_graph_horizon: + fields: + subdevice_id: required: true - example: "/config/span_panel_sensor_config.yaml" selector: text: + config_entry_id: + selector: + config_entry: + integration: span_panel + +get_graph_settings: + fields: + config_entry_id: + selector: + config_entry: + integration: span_panel + +get_favorites: + +add_favorite: + fields: + entity_id: + required: true + selector: + entity: + domain: sensor + integration: span_panel + +remove_favorite: + fields: + entity_id: + required: true + selector: + entity: + domain: sensor + integration: span_panel diff --git a/custom_components/span_panel/simulation_configs/migration_test_config.yaml b/custom_components/span_panel/simulation_configs/migration_test_config.yaml deleted file mode 100644 index eeb0e165..00000000 --- a/custom_components/span_panel/simulation_configs/migration_test_config.yaml +++ /dev/null @@ -1,236 +0,0 @@ -# Migration test simulation configuration using actual registry circuit IDs -# This matches the real circuit IDs from the 1.0.10 entity registry - -panel_config: - serial_number: "nj-2316-005k6" - total_tabs: 32 - main_size: 200 # Main breaker size in Amps - -# Circuit templates define reusable behavior patterns -circuit_templates: - # Lighting circuits - lighting: - energy_profile: - mode: "consumer" - power_range: [0.0, 500.0] - typical_power: 300.0 - power_variation: 0.05 - relay_behavior: "controllable" - priority: "NON_ESSENTIAL" - - # Outlet circuits - outlets: - energy_profile: - mode: "consumer" - power_range: [0.0, 1800.0] - typical_power: 150.0 - power_variation: 0.4 - relay_behavior: "controllable" - priority: "MUST_HAVE" - - # Kitchen specific outlets (higher capacity) - kitchen_outlets: - energy_profile: - mode: "consumer" - power_range: [0.0, 2400.0] - typical_power: 300.0 - power_variation: 0.5 - relay_behavior: "controllable" - priority: "MUST_HAVE" - - # Major appliances - major_appliance: - energy_profile: - mode: "consumer" - power_range: [0.0, 2500.0] - typical_power: 800.0 - power_variation: 0.3 - relay_behavior: "controllable" - priority: "NON_ESSENTIAL" - - # Large appliances (240V) - large_appliance_240v: - energy_profile: - mode: "consumer" - power_range: [0.0, 4000.0] - typical_power: 2000.0 - power_variation: 0.15 - relay_behavior: "controllable" - priority: "NON_ESSENTIAL" - - # HVAC/AC systems - hvac: - energy_profile: - mode: "consumer" - power_range: [500.0, 4000.0] - typical_power: 2800.0 - power_variation: 0.25 - relay_behavior: "controllable" - priority: "NON_ESSENTIAL" - - # EV chargers - ev_charger: - energy_profile: - mode: "consumer" - power_range: [0.0, 11500.0] - typical_power: 7200.0 - power_variation: 0.05 - relay_behavior: "controllable" - priority: "NON_ESSENTIAL" - - # Special equipment - special_equipment: - energy_profile: - mode: "consumer" - power_range: [0.0, 1200.0] - typical_power: 400.0 - power_variation: 0.2 - relay_behavior: "controllable" - priority: "NON_ESSENTIAL" - -circuits: - # Using actual circuit IDs from the registry - - id: "0dad2f16cd514812ae1807b0457d473e" - name: "Lights Dining Room" - template: "lighting" - tabs: [1] - - - id: "11a47a0f69d54e12b7200f730c2ffda1" - name: "Lights-Outlets Bedroom" - template: "outlets" - tabs: [2] - - - id: "12ce227695cd44338864b0ef2ec4168b" - name: "Dining Room Wine Fridge" - template: "major_appliance" - tabs: [3] - overrides: - typical_power: 150.0 - - - id: "1ad73b0eb44e4022bb6270c76baed0c1" - name: "Dryer" - template: "large_appliance_240v" - tabs: [4, 6] # 240V appliance - overrides: - typical_power: 3000.0 - - - id: "31b36cde0fc642b39eec515267707a6f" - name: "Outlets / Kitchen" - template: "kitchen_outlets" - tabs: [5] - - - id: "3a847fe6eb374ec3bf92cee1ffaf2eda" - name: "Microwave & Oven" - template: "large_appliance_240v" - tabs: [7, 9] # 240V appliance - overrides: - typical_power: 2500.0 - - - id: "497ae7ebb2844772bf926f2f094c81bc" - name: "Dishwasher" - template: "major_appliance" - tabs: [8] - overrides: - typical_power: 1200.0 - - - id: "617059df47bb49bd8545a36a6b6b23d2" - name: "Spa" - template: "special_equipment" - tabs: [10, 12] # 240V equipment - overrides: - typical_power: 1500.0 - - - id: "6ad65cf2ad6443448e33973f26f7df3e" - name: "Kitchen Disposal" - template: "major_appliance" - tabs: [11] - overrides: - typical_power: 800.0 - - - id: "795e8eddb4f448af9625130332a41df8" - name: "Fountain" - template: "special_equipment" - tabs: [13] - overrides: - typical_power: 200.0 - - - id: "82a0888dc072416598d99f8b74ee3d8d" - name: "Internet Living room" - template: "outlets" - tabs: [14] - overrides: - typical_power: 100.0 - - - id: "8a2ffda9dbd24bada9a01b880e910612" - name: "Large Garage EV" - template: "ev_charger" - tabs: [15, 17] # 240V EV charger - overrides: - typical_power: 7200.0 - - - id: "914943d4798c462cadf5e5144989dd5b" - name: "Other Outlets" - template: "outlets" - tabs: [16] - - - id: "926441605113492189f9aa13dac356cd" - name: "Master bedroom" - template: "outlets" - tabs: [18] - - - id: "941d6a8b41ab4c57a6b8be14b5981fe6" - name: "Air Conditioner" - template: "hvac" - tabs: [19, 21] # 240V AC unit - overrides: - typical_power: 3500.0 - - - id: "988af7bb1fc04aac8bb3b45c660d920a" - name: "Garage Outlets" - template: "outlets" - tabs: [20] - - - id: "9941b417bfa046ef908d97c998c54c50" - name: "Refrigerator" - template: "major_appliance" - tabs: [22] - overrides: - typical_power: 150.0 - - - id: "cbe98d7307ca42658983b9f673211e14" - name: "Furnace" - template: "hvac" - tabs: [23, 25] # 240V furnace - overrides: - typical_power: 2800.0 - - - id: "d88c0c0e7c584472b2ec7e4d3c53c3b8" - name: "Small Garage EV" - template: "ev_charger" - tabs: [24, 26] # 240V EV charger - overrides: - typical_power: 3600.0 - - - id: "e3cb78d38dae41cdb50faee7bfa4ab80" - name: "Washing Machine Area" - template: "major_appliance" - tabs: [27] - overrides: - typical_power: 1000.0 - - - id: "ece352c3dc5c417e80757b716f24ba90" - name: "Kitchen and Master Bath" - template: "outlets" - tabs: [28] - - - id: "f19d909abcc4421f9ec630f82458c7ac" - name: "Lights Kitchen Master bathroom" - template: "lighting" - tabs: [29] - -# Global simulation parameters -simulation_params: - update_interval: 5 - time_acceleration: 1.0 - noise_factor: 0.02 - enable_realistic_behaviors: true diff --git a/custom_components/span_panel/simulation_configs/simple_test_config.yaml b/custom_components/span_panel/simulation_configs/simple_test_config.yaml deleted file mode 100644 index c73943b7..00000000 --- a/custom_components/span_panel/simulation_configs/simple_test_config.yaml +++ /dev/null @@ -1,73 +0,0 @@ -# Simple test configuration with minimal circuits for testing -# Demonstrates basic YAML simulation functionality - -panel_config: - serial_number: "SPAN-TEST-001" - total_tabs: 8 - main_size: 100 - -circuit_templates: - lighting: - energy_profile: - mode: "consumer" - power_range: [5.0, 50.0] - typical_power: 25.0 - power_variation: 0.1 - relay_behavior: "controllable" - priority: "MUST_HAVE" - - outlets: - energy_profile: - mode: "consumer" - power_range: [0.0, 1800.0] - typical_power: 150.0 - power_variation: 0.3 - relay_behavior: "controllable" - priority: "MUST_HAVE" - - hvac: - energy_profile: - mode: "consumer" - power_range: [0.0, 3000.0] - typical_power: 2000.0 - power_variation: 0.15 - relay_behavior: "controllable" - priority: "NON_ESSENTIAL" - - solar: - energy_profile: - mode: "producer" - power_range: [-5000.0, 0.0] - typical_power: -2500.0 - power_variation: 0.2 - relay_behavior: "non_controllable" - priority: "MUST_HAVE" - -circuits: - - id: "living_room_lights" - name: "Living Room Lights" - template: "lighting" - tabs: [1, 3] # L1 + L2 = Valid 240V - - - id: "kitchen_outlets" - name: "Kitchen Outlets" - template: "outlets" - tabs: [2, 4] # L1 + L2 = Valid 240V - - - id: "main_hvac" - name: "Main HVAC" - template: "hvac" - tabs: [5, 7] # L1 + L2 = Valid 240V - - - id: "solar_inverter" - name: "Solar Inverter" - template: "solar" - tabs: [6, 8] # L1 + L2 = Valid 240V - -unmapped_tabs: [] - -simulation_params: - update_interval: 5 - time_acceleration: 1.0 - noise_factor: 0.02 - enable_realistic_behaviors: true diff --git a/custom_components/span_panel/simulation_configs/simulation_config_32_circuit.yaml b/custom_components/span_panel/simulation_configs/simulation_config_32_circuit.yaml deleted file mode 100644 index aed513d2..00000000 --- a/custom_components/span_panel/simulation_configs/simulation_config_32_circuit.yaml +++ /dev/null @@ -1,445 +0,0 @@ -# Example YAML simulation configuration for a 32-circuit residential panel -# This demonstrates the new flexible simulation system - -panel_config: - serial_number: "SPAN-32-SIM-001" - total_tabs: 32 - main_size: 200 # Main breaker size in Amps - # Uncomment the line below to simulate panel being offline - # offline_mode: true # Makes panel appear offline for testing connection loss behavior - -# Circuit templates define reusable behavior patterns -circuit_templates: - # Always-on base load - always_on: - energy_profile: - mode: "consumer" # Consumes power only - power_range: [40.0, 100.0] - typical_power: 60.0 - power_variation: 0.1 - relay_behavior: "controllable" - priority: "MUST_HAVE" - - # Exterior lighting with time-based patterns - lighting: - energy_profile: - mode: "consumer" - power_range: [0.0, 500.0] - typical_power: 300.0 - power_variation: 0.05 - relay_behavior: "controllable" - priority: "NON_ESSENTIAL" - time_of_day_profile: - enabled: true - peak_hours: [18, 19, 20, 21, 22] # Evening hours - - # HVAC with cycling behavior - hvac: - energy_profile: - mode: "consumer" - power_range: [0.0, 3500.0] - typical_power: 2800.0 - power_variation: 0.1 - relay_behavior: "controllable" - priority: "MUST_HAVE" - cycling_pattern: - on_duration: 1200 # 20 minutes - off_duration: 2400 # 40 minutes - - # Large appliances (dishwasher, laundry, etc.) - large_appliance: - energy_profile: - mode: "consumer" - power_range: [0.0, 2500.0] - typical_power: 1800.0 - power_variation: 0.15 - relay_behavior: "controllable" - priority: "NON_ESSENTIAL" - - # Refrigerator with compressor cycling - refrigerator: - energy_profile: - mode: "consumer" - power_range: [50.0, 200.0] - typical_power: 120.0 - power_variation: 0.2 - relay_behavior: "non_controllable" - priority: "MUST_HAVE" - cycling_pattern: - on_duration: 600 # 10 minutes - off_duration: 1800 # 30 minutes - - # EV charger with smart grid response - ev_charger: - energy_profile: - mode: "consumer" - power_range: [0.0, 11500.0] - typical_power: 7200.0 # Level 2 charging - power_variation: 0.05 - relay_behavior: "controllable" - priority: "NON_ESSENTIAL" - smart_behavior: - responds_to_grid: true - max_power_reduction: 0.6 # Can reduce to 40% during grid stress - - # Pool equipment - pool_equipment: - energy_profile: - mode: "consumer" - power_range: [0.0, 1200.0] - typical_power: 800.0 - power_variation: 0.1 - relay_behavior: "controllable" - priority: "NON_ESSENTIAL" - cycling_pattern: - on_duration: 7200 # 2 hours on - off_duration: 14400 # 4 hours off - - # Solar production system - generic producer - solar_production: - energy_profile: - mode: "producer" # Produces power (negative values) - power_range: [-4000.0, 0.0] # 4kW peak production - typical_power: -2500.0 # Average production - power_variation: 0.3 - efficiency: 0.85 # 85% efficiency - relay_behavior: "non_controllable" - priority: "MUST_HAVE" - time_of_day_profile: - enabled: true - peak_hours: [11, 12, 13, 14, 15] # Peak production 11 AM - 3 PM - peak_multiplier: 1.0 - off_peak_multiplier: 0.0 # No production at night - hourly_multipliers: - 6: 0.1 # Dawn - 10% production - 7: 0.2 # Early morning - 8: 0.4 # Morning - 9: 0.6 # Mid morning - 10: 0.8 # Late morning - 11: 1.0 # Peak hours - 12: 1.0 - 13: 1.0 - 14: 1.0 - 15: 1.0 # End peak - 16: 0.8 # Afternoon - 17: 0.6 # Late afternoon - 18: 0.4 # Evening - 19: 0.2 # Dusk - 20: 0.0 # Night starts - - # Backup generator - another producer type - backup_generator: - energy_profile: - mode: "producer" - power_range: [-8000.0, 0.0] # 8kW backup generator - typical_power: -6000.0 - power_variation: 0.05 # Generators are very stable - efficiency: 0.92 # 92% fuel efficiency - relay_behavior: "controllable" - priority: "MUST_HAVE" - - # Battery storage - bidirectional - battery_storage: - energy_profile: - mode: "bidirectional" # Can charge or discharge - power_range: [-5000.0, 5000.0] # ±5kW battery - typical_power: 0.0 # Neutral when idle - power_variation: 0.02 # Very stable - efficiency: 0.95 # 95% round-trip efficiency - relay_behavior: "controllable" - priority: "MUST_HAVE" - battery_behavior: - enabled: true - charge_power: 3000.0 - discharge_power: -3000.0 - idle_power: 0.0 - - # Wind turbine - variable producer - wind_production: - energy_profile: - mode: "producer" - power_range: [-2000.0, 0.0] # 2kW small wind turbine - typical_power: -800.0 - power_variation: 0.5 # Very variable based on wind - efficiency: 0.75 # 75% efficiency - relay_behavior: "non_controllable" - priority: "MUST_HAVE" - - # General outlet circuits - outlets: - energy_profile: - mode: "consumer" - power_range: [0.0, 1800.0] - typical_power: 150.0 - power_variation: 0.4 # Very variable loads - relay_behavior: "controllable" - priority: "MUST_HAVE" - - # Kitchen specific outlets (higher capacity) - kitchen_outlets: - energy_profile: - mode: "consumer" - power_range: [0.0, 2400.0] - typical_power: 300.0 - power_variation: 0.5 # Very variable - appliances - relay_behavior: "controllable" - priority: "MUST_HAVE" - - # Heat pump with seasonal efficiency - heat_pump: - energy_profile: - mode: "consumer" - power_range: [500.0, 4000.0] - typical_power: 2800.0 - power_variation: 0.25 # Efficiency varies with temperature - relay_behavior: "controllable" - priority: "NON_ESSENTIAL" - cycling_pattern: - on_duration: 900 # 15 minutes on - off_duration: 1800 # 30 minutes off - - # Major appliances (dishwasher, laundry, etc.) - major_appliance: - energy_profile: - mode: "consumer" - power_range: [0.0, 2500.0] - typical_power: 800.0 - power_variation: 0.3 - relay_behavior: "controllable" - priority: "NON_ESSENTIAL" - -circuits: - # Lighting circuits (tabs 1-6) - - id: "master_bedroom_lights" - name: "Master Bedroom Lights" - template: "lighting" - tabs: [1] - overrides: - typical_power: 35.0 # Slightly higher for master - - - id: "living_room_lights" - name: "Living Room Lights" - template: "lighting" - tabs: [2] - overrides: - typical_power: 45.0 # Higher for main living area - - - id: "kitchen_lights" - name: "Kitchen Lights" - template: "lighting" - tabs: [3] - overrides: - typical_power: 40.0 - - - id: "bedroom_lights" - name: "Bedroom Lights" - template: "lighting" - tabs: [4] - - - id: "bathroom_lights" - name: "Bathroom Lights" - template: "lighting" - tabs: [5] - overrides: - typical_power: 30.0 - - - id: "exterior_lights" - name: "Exterior Lights" - template: "lighting" - tabs: [6] - overrides: - typical_power: 60.0 - time_of_day_profile: - enabled: true - peak_hours: [18, 19, 20, 21, 22, 23, 0, 1, 2, 3, 4, 5, 6] # Nighttime - - # Outlet circuits (tabs 7-14) - - id: "master_bedroom_outlets" - name: "Master Bedroom Outlets" - template: "outlets" - tabs: [7] - - - id: "living_room_outlets" - name: "Living Room Outlets" - template: "outlets" - tabs: [8] - overrides: - typical_power: 200.0 # TV, entertainment system - - - id: "kitchen_outlets_1" - name: "Kitchen Outlets 1" - template: "kitchen_outlets" - tabs: [9] # 120V circuit - - - id: "kitchen_outlets_2" - name: "Kitchen Outlets 2" - template: "kitchen_outlets" - tabs: [12] # 120V circuit - - - id: "office_outlets" - name: "Office Outlets" - template: "outlets" - tabs: [11] - overrides: - typical_power: 300.0 # Computers, monitors - - - id: "garage_outlets" - name: "Garage Outlets" - template: "outlets" - tabs: [10] - - - id: "laundry_outlets" - name: "Laundry Room Outlets" - template: "outlets" - tabs: [13] - - - id: "guest_room_outlets" - name: "Guest Room Outlets" - template: "outlets" - tabs: [14] - - # Major appliances (tabs 15-22) - - id: "refrigerator" - name: "Refrigerator" - template: "always_on" - tabs: [15] - overrides: - typical_power: 150.0 - - - id: "dishwasher" - name: "Dishwasher" - template: "major_appliance" - tabs: [16] - overrides: - typical_power: 1200.0 - - - id: "washing_machine" - name: "Washing Machine" - template: "major_appliance" - tabs: [17] - overrides: - typical_power: 1000.0 - - - id: "dryer" - name: "Electric Dryer" - template: "major_appliance" - tabs: [18, 20] # 240V appliance - overrides: - typical_power: 3000.0 - power_range: [0.0, 4000.0] - - - id: "oven" - name: "Electric Oven" - template: "major_appliance" - tabs: [19, 21] # 240V appliance - overrides: - typical_power: 2500.0 - power_range: [0.0, 3500.0] - - - id: "microwave" - name: "Microwave" - template: "major_appliance" - tabs: [22] - overrides: - typical_power: 1000.0 - - # HVAC systems (tabs 23-26) - - id: "main_hvac" - name: "Main HVAC Unit" - template: "hvac" - tabs: [23, 25] # 240V system - - - id: "heat_pump_backup" - name: "Heat Pump Backup" - template: "heat_pump" - tabs: [24, 26] # 240V system - - # EV charging (tabs 27-29) - - id: "ev_charger_garage" - name: "Garage EV Charger" - template: "ev_charger" - tabs: [27, 29] # 240V Level 2 charger - -# Configuration for unmapped tabs with specific behaviors -# Tabs 30 and 32: Solar production tabs for integration testing -# These tabs remain unmapped (no circuits) but have synchronized behavior -unmapped_tab_templates: - "30": - energy_profile: - mode: "producer" - power_range: [-2000.0, 0.0] # 2kW production capacity per phase - typical_power: -1500.0 # 1.5kW average per phase - power_variation: 0.2 - efficiency: 0.85 - relay_behavior: "non_controllable" - priority: "MUST_HAVE" - time_of_day_profile: - enabled: true - peak_hours: [11, 12, 13, 14, 15] # Peak production 11 AM - 3 PM - peak_multiplier: 1.0 - off_peak_multiplier: 0.0 # No production at night - hourly_multipliers: - 6: 0.1 # Dawn - 10% production - 7: 0.2 # Early morning - 8: 0.4 # Morning - 9: 0.6 # Mid morning - 10: 0.8 # Late morning - 11: 1.0 # Peak hours - 12: 1.0 - 13: 1.0 - 14: 1.0 - 15: 1.0 # End peak - 16: 0.8 # Afternoon - 17: 0.6 # Late afternoon - 18: 0.4 # Evening - 19: 0.2 # Dusk - 20: 0.0 # Night starts - - "32": - energy_profile: - mode: "producer" - power_range: [-2000.0, 0.0] # 2kW production capacity per phase - typical_power: -1500.0 # 1.5kW average per phase - power_variation: 0.2 - efficiency: 0.85 - relay_behavior: "non_controllable" - priority: "MUST_HAVE" - time_of_day_profile: - enabled: true - peak_hours: [11, 12, 13, 14, 15] # Peak production 11 AM - 3 PM - peak_multiplier: 1.0 - off_peak_multiplier: 0.0 # No production at night - hourly_multipliers: - 6: 0.1 # Dawn - 10% production - 7: 0.2 # Early morning - 8: 0.4 # Morning - 9: 0.6 # Mid morning - 10: 0.8 # Late morning - 11: 1.0 # Peak hours - 12: 1.0 - 13: 1.0 - 14: 1.0 - 15: 1.0 # End peak - 16: 0.8 # Afternoon - 17: 0.6 # Late afternoon - 18: 0.4 # Evening - 19: 0.2 # Dusk - 20: 0.0 # Night starts - -# Tab synchronization for coordinated behavior (e.g., 240V loads, multi-phase production) -tab_synchronizations: - - tabs: [30, 32] - behavior: "240v_split_phase" # Two phases of same 240V system - power_split: "equal" # Equal power on both phases - energy_sync: true # Synchronized energy accumulation - template: "solar_production" # Generic production template - -# Unmapped tabs that should have behavior but remain unmapped (no circuits created) -unmapped_tabs: [30, 32] - -# Global simulation parameters -simulation_params: - update_interval: 5 # Update every 5 seconds - time_acceleration: 1.0 # Real-time simulation - noise_factor: 0.02 # ±2% random noise on all values - enable_realistic_behaviors: true diff --git a/custom_components/span_panel/simulation_configs/simulation_config_40_circuit_with_battery.yaml b/custom_components/span_panel/simulation_configs/simulation_config_40_circuit_with_battery.yaml deleted file mode 100644 index de19e6bc..00000000 --- a/custom_components/span_panel/simulation_configs/simulation_config_40_circuit_with_battery.yaml +++ /dev/null @@ -1,321 +0,0 @@ -# 40-tab panel configuration with battery storage and unmapped tabs -# This config intentionally leaves some tabs unmapped to test edge cases - -panel_config: - serial_number: "SPAN-40-BATTERY-001" - total_tabs: 40 - main_size: 400 # Larger main breaker for 40-tab panel - -circuit_templates: - # Standard templates - lighting: - energy_profile: - mode: "consumer" - power_range: [5.0, 50.0] - typical_power: 25.0 - power_variation: 0.15 - relay_behavior: "controllable" - priority: "MUST_HAVE" - - outlets: - energy_profile: - mode: "bidirectional" - power_range: [0.0, 1800.0] - typical_power: 150.0 - power_variation: 0.4 - relay_behavior: "controllable" - priority: "MUST_HAVE" - - hvac: - energy_profile: - mode: "consumer" - power_range: [0.0, 3000.0] - typical_power: 1500.0 - power_variation: 0.2 - relay_behavior: "controllable" - priority: "MUST_HAVE" - cycling_pattern: - on_duration: 900 # 15 minutes - off_duration: 1800 # 30 minutes - - solar: - energy_profile: - mode: "producer" - power_range: [-8000.0, 0.0] - typical_power: -4000.0 - power_variation: 0.4 - relay_behavior: "non_controllable" - priority: "MUST_HAVE" - - battery: - energy_profile: - mode: "bidirectional" - power_range: [-5000.0, 5000.0] # Can charge or discharge - typical_power: 0.0 # Neutral when idle - power_variation: 0.1 - relay_behavior: "non_controllable" - priority: "MUST_HAVE" - battery_behavior: - enabled: true - charge_efficiency: 0.95 # 95% efficient charging - discharge_efficiency: 0.95 # 95% efficient discharging - charge_hours: [9, 10, 11, 12, 13, 14, 15, 16] # Solar hours - discharge_hours: [17, 18, 19, 20, 21] # Peak demand hours - max_charge_power: -3000.0 # Max charging power (negative) - max_discharge_power: 2500.0 # Max discharge power (positive) - idle_hours: [0, 1, 2, 3, 4, 5, 6, 7, 8, 22, 23] # Low activity hours - idle_power_range: [-100.0, 100.0] # Random power during idle hours - # Solar intensity profile for charging (hour: intensity_factor) - solar_intensity_profile: - 9: 0.2 - 10: 0.4 - 11: 0.7 - 12: 1.0 # Peak solar - 13: 1.0 - 14: 0.8 - 15: 0.6 - 16: 0.3 - # Demand factor profile for discharging (hour: demand_factor) - demand_factor_profile: - 17: 0.6 # Early evening - 18: 0.8 # Peak start - 19: 1.0 # Peak demand - 20: 0.9 # High demand - 21: 0.7 # Demand decreasing - -circuits: - # Define only 37 circuits for a 40-tab panel (tabs 38-40 will be unmapped) - - # Main lighting circuits (tabs 1-8) - - id: "kitchen_lights" - name: "Kitchen Lights" - template: "lighting" - tabs: [1] - - - id: "living_room_lights" - name: "Living Room Lights" - template: "lighting" - tabs: [2] - - - id: "bedroom_lights" - name: "Bedroom Lights" - template: "lighting" - tabs: [3] - - - id: "bathroom_lights" - name: "Bathroom Lights" - template: "lighting" - tabs: [4] - - - id: "outdoor_lights" - name: "Outdoor Lights" - template: "lighting" - tabs: [5] - - - id: "garage_lights" - name: "Garage Lights" - template: "lighting" - tabs: [6] - - - id: "basement_lights" - name: "Basement Lights" - template: "lighting" - tabs: [7] - - - id: "office_lights" - name: "Office Lights" - template: "lighting" - tabs: [8] - - # Outlet circuits (tabs 9-16) - - id: "kitchen_outlets" - name: "Kitchen Outlets" - template: "outlets" - tabs: [9] - - - id: "living_room_outlets" - name: "Living Room Outlets" - template: "outlets" - tabs: [10] - - - id: "bedroom_outlets" - name: "Bedroom Outlets" - template: "outlets" - tabs: [11] - - - id: "bathroom_outlets" - name: "Bathroom Outlets" - template: "outlets" - tabs: [12] - - - id: "garage_outlets" - name: "Garage Outlets" - template: "outlets" - tabs: [13] - - - id: "basement_outlets" - name: "Basement Outlets" - template: "outlets" - tabs: [14] - - - id: "office_outlets" - name: "Office Outlets" - template: "outlets" - tabs: [15] - - - id: "outdoor_outlets" - name: "Outdoor Outlets" - template: "outlets" - tabs: [16] - - # HVAC systems (240V, using opposing tabs) - - id: "main_hvac" - name: "Main HVAC System" - template: "hvac" - tabs: [17, 19] - - - id: "secondary_hvac" - name: "Secondary HVAC System" - template: "hvac" - tabs: [18, 20] - - # Major appliances (240V) - - id: "electric_range" - name: "Electric Range" - template: "outlets" - tabs: [21, 23] - overrides: - power_range: [0.0, 8000.0] - typical_power: 2000.0 - - - id: "electric_dryer" - name: "Electric Dryer" - template: "outlets" - tabs: [22, 24] - overrides: - power_range: [0.0, 5000.0] - typical_power: 1500.0 - - - id: "water_heater" - name: "Water Heater" - template: "outlets" - tabs: [25, 27] - overrides: - power_range: [0.0, 4500.0] - typical_power: 2000.0 - - - id: "ev_charger" - name: "EV Charger" - template: "outlets" - tabs: [26, 28] - overrides: - power_range: [0.0, 7200.0] - typical_power: 3600.0 - - # Solar and battery systems - - id: "solar_inverter_1" - name: "Solar Inverter 1" - template: "solar" - tabs: [29, 31] - - - id: "solar_inverter_2" - name: "Solar Inverter 2" - template: "solar" - tabs: [30, 32] - - # Battery storage on opposing phased tabs as requested - - id: "battery_system_1" - name: "Battery System 1" - template: "battery" - tabs: [33, 35] - - - id: "battery_system_2" - name: "Battery System 2" - template: "battery" - tabs: [34, 36] - - # Additional circuit (using tab 37) - - id: "pool_pump" - name: "Pool Pump" - template: "outlets" - tabs: [37] - overrides: - power_range: [0.0, 2000.0] - typical_power: 800.0 - - # Tabs 38-40 are intentionally left unmapped to test unmapped tab creation - # This will trigger the code in lines 866-876 of client.py - -# Circuit synchronizations for 240V battery systems and solar arrays -tab_synchronizations: - - tabs: [33, 35] # Battery System 1 - 240V split phase - behavior: "240v_split_phase" - power_split: "equal" - energy_sync: true - template: "battery_sync" - - - tabs: [34, 36] # Battery System 2 - 240V split phase - behavior: "240v_split_phase" - power_split: "equal" - energy_sync: true - template: "battery_sync" - - - tabs: [29, 31] # Solar Inverter 1 - 240V split phase - behavior: "240v_split_phase" - power_split: "equal" - energy_sync: true - template: "solar_sync" - - - tabs: [30, 32] # Solar Inverter 2 - 240V split phase - behavior: "240v_split_phase" - power_split: "equal" - energy_sync: true - template: "solar_sync" - -# Explicitly list unmapped tabs for testing purposes -unmapped_tabs: [38, 39, 40] # These tabs will have auto-generated behavior - -# Unmapped tab templates for tabs that should have behavior but remain unmapped -unmapped_tab_templates: - "38": - energy_profile: - mode: "consumer" - power_range: [0.0, 1000.0] - typical_power: 200.0 - power_variation: 0.3 - relay_behavior: "non_controllable" - priority: "NON_ESSENTIAL" - - "39": - energy_profile: - mode: "consumer" - power_range: [0.0, 800.0] - typical_power: 150.0 - power_variation: 0.2 - relay_behavior: "non_controllable" - priority: "NON_ESSENTIAL" - - "40": - energy_profile: - mode: "producer" - power_range: [-1500.0, 0.0] - typical_power: -500.0 - power_variation: 0.4 - relay_behavior: "non_controllable" - priority: "MUST_HAVE" - time_of_day_profile: - enabled: true - peak_hours: [11, 12, 13, 14, 15] - peak_multiplier: 1.0 - off_peak_multiplier: 0.0 - -simulation_params: - enable_realistic_behaviors: true - noise_factor: 0.02 - time_acceleration: 1.0 - update_interval: 5 - # Advanced battery behavior - battery_behaviors: - charge_efficiency: 0.95 - discharge_efficiency: 0.90 - self_discharge_rate: 0.001 # 0.1% per hour diff --git a/custom_components/span_panel/simulation_factory.py b/custom_components/span_panel/simulation_factory.py deleted file mode 100644 index 97414194..00000000 --- a/custom_components/span_panel/simulation_factory.py +++ /dev/null @@ -1,138 +0,0 @@ -"""Simulation factory for SPAN Panel integration. - -This module provides a factory pattern for handling simulation mode setup -without polluting the main integration code. It allows the integration to -treat simulation mode as if it were a real panel for YAML generation. -""" - -from __future__ import annotations - -import logging -import os -from typing import Any - -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant - -from .coordinator import SpanPanelCoordinator -from .synthetic_sensors import SyntheticSensorCoordinator - -_LOGGER = logging.getLogger(__name__) - - -class SimulationModeFactory: - """Factory for handling simulation mode setup and configuration.""" - - @staticmethod - def is_simulation_mode() -> bool: - """Check if we're running in simulation mode. - - Returns: - True if SPAN_USE_REAL_SIMULATION environment variable is set - - """ - return os.environ.get("SPAN_USE_REAL_SIMULATION", "").lower() in ("1", "true", "yes") - - @staticmethod - def create_simulation_coordinator( - coordinator: SyntheticSensorCoordinator, - ) -> SimulationCoordinator: - """Create a simulation coordinator that wraps the synthetic coordinator. - - Args: - coordinator: The synthetic sensor coordinator to wrap - - Returns: - SimulationCoordinator that provides simulation-specific behavior - - """ - return SimulationCoordinator(coordinator) - - @staticmethod - def setup_simulation_logging() -> None: - """Set up enhanced logging for simulation mode.""" - if SimulationModeFactory.is_simulation_mode(): - _LOGGER.info("🔧 Simulation mode enabled - enhanced logging active") - # Set debug level for simulation-related components - logging.getLogger("custom_components.span_panel.synthetic_sensors").setLevel( - logging.DEBUG - ) - logging.getLogger("custom_components.span_panel.synthetic_panel_circuits").setLevel( - logging.DEBUG - ) - logging.getLogger("custom_components.span_panel.synthetic_named_circuits").setLevel( - logging.DEBUG - ) - - -class SimulationCoordinator: - """Coordinator that provides simulation-specific behavior for synthetic sensors. - - This class wraps the main SyntheticSensorCoordinator and provides - simulation-specific setup and logging without modifying the core logic. - """ - - def __init__(self, coordinator: SyntheticSensorCoordinator): - """Initialize the simulation coordinator. - - Args: - coordinator: The synthetic sensor coordinator to wrap - - """ - self.coordinator = coordinator - self._is_simulation = SimulationModeFactory.is_simulation_mode() - - async def setup_configuration(self, config_entry: ConfigEntry) -> Any: - """Set up configuration with simulation-specific behavior. - - This method delegates to the main coordinator but adds simulation-specific - logging and behavior. - """ - if not self._is_simulation: - # Delegate to normal coordinator behavior - return await self.coordinator._setup_live_configuration(config_entry) - - # Simulation mode: use the same logic but with enhanced logging - _LOGGER.info("🔧 Simulation mode: Generating YAML from simulated panel data") - - # For simulation mode, we use the same configuration logic as live mode - # but with enhanced logging to show it's working with simulated data - result = await self.coordinator._setup_live_configuration(config_entry) - - _LOGGER.info("🎯 Simulation mode: YAML generation completed successfully") - return result - - def get_coordinator(self) -> SyntheticSensorCoordinator: - """Get the underlying synthetic sensor coordinator.""" - return self.coordinator - - -def create_synthetic_coordinator_with_simulation_support( - hass: HomeAssistant, coordinator: SpanPanelCoordinator, device_name: str -) -> SimulationCoordinator | SyntheticSensorCoordinator: - """Create a synthetic coordinator with optional simulation support. - - This factory function creates the appropriate coordinator based on whether - simulation mode is enabled, keeping the main integration code clean. - - Args: - hass: Home Assistant instance - coordinator: SPAN panel coordinator - device_name: Device name for the coordinator - - Returns: - Either a SimulationCoordinator (if simulation mode) or SyntheticSensorCoordinator - - """ - # Set up simulation logging if needed - SimulationModeFactory.setup_simulation_logging() - - # Create the base coordinator - base_coordinator = SyntheticSensorCoordinator(hass, coordinator, device_name) - - # Wrap with simulation coordinator if in simulation mode - if SimulationModeFactory.is_simulation_mode(): - return SimulationModeFactory.create_simulation_coordinator(base_coordinator) - - # Return the base coordinator for normal operation - return base_coordinator diff --git a/custom_components/span_panel/simulation_generator.py b/custom_components/span_panel/simulation_generator.py deleted file mode 100644 index 7bfa5206..00000000 --- a/custom_components/span_panel/simulation_generator.py +++ /dev/null @@ -1,322 +0,0 @@ -"""Build simulation YAML from a live panel snapshot. - -This module inspects the current coordinator data and produces a YAML dict -that matches span_panel_api's simulation reference. It infers templates from -names and seeds energy profiles from current power readings. -""" - -from __future__ import annotations - -from dataclasses import dataclass -from typing import Any - - -@dataclass -class SimulationYamlGenerator: - """Generate YAML from live panel data.""" - - hass: Any - coordinator: Any - solar_leg1: int | None = None - solar_leg2: int | None = None - - async def build_yaml_from_live_panel(self) -> tuple[dict[str, Any], int]: - """Build YAML from live panel data.""" - data = getattr(self.coordinator, "data", None) - circuits_obj = getattr(data, "circuits", None) - - # Prepare containers - circuit_templates: dict[str, Any] = {} - circuits: list[dict[str, Any]] = [] - mapped_tabs: set[int] = set() - - # Iterate circuits - iter_dict: dict[str, Any] = {} - # Safely extract iterable circuits mapping without accessing attributes on None - if isinstance(circuits_obj, dict): - iter_dict = circuits_obj - elif circuits_obj is not None: - inner_circuits = getattr(circuits_obj, "circuits", None) - if isinstance(inner_circuits, dict): - iter_dict = inner_circuits - - for cid, c in iter_dict.items(): - name = str(getattr(c, "name", cid)) - power_w = float(getattr(c, "instant_power_w", 0.0) or 0.0) - raw_tabs = getattr(c, "tabs", []) if hasattr(c, "tabs") else [] - tabs = ( - list(raw_tabs) - if isinstance(raw_tabs, (list | tuple)) - else ([] if raw_tabs in (None, "UNSET") else [int(raw_tabs)]) - ) - mapped_tabs.update(tabs) - - # Skip unmapped tab circuits - they should be handled as unmapped tabs, not circuits - if str(cid).startswith("unmapped_tab_"): - continue - - template_key = self._infer_template_key(name, power_w, tabs) - if template_key not in circuit_templates: - circuit_templates[template_key] = self._make_template(template_key, power_w, name) - - entry: dict[str, Any] = { - "id": str(cid), - "name": name, - "tabs": tabs, - "template": template_key, - } - if power_w != 0.0: - entry["overrides"] = {"energy_profile": {"typical_power": power_w}} - - circuits.append(entry) - - # Compute total tabs - num_tabs = 32 - if mapped_tabs: - max_tab = max(mapped_tabs) - if max_tab <= 8: - num_tabs = 8 - elif max_tab <= 32: - num_tabs = 32 - else: - num_tabs = 40 - - # Panel config - serial = ( - getattr(getattr(data, "status", None), "serial_number", None) or "span_panel_simulation" - ) - snapshot_yaml: dict[str, Any] = { - "panel_config": { - "serial_number": str(serial), - "total_tabs": num_tabs, - "main_size": 200, - }, - "circuit_templates": circuit_templates, - "circuits": circuits, - "unmapped_tabs": sorted(set(range(1, num_tabs + 1)) - mapped_tabs), - "simulation_params": { - "update_interval": 5, - "time_acceleration": 1.0, - "noise_factor": 0.02, - }, - } - - # Add solar configuration if legs provided and valid - self._maybe_add_solar(snapshot_yaml) - - return snapshot_yaml, num_tabs - - def _maybe_add_solar(self, yaml_doc: dict[str, Any]) -> None: - l1 = int(self.solar_leg1 or 0) - l2 = int(self.solar_leg2 or 0) - if l1 <= 0 or l2 <= 0 or l1 == l2: - return - - # Ensure solar template exists - templates = yaml_doc.setdefault("circuit_templates", {}) - if "solar_production" not in templates: - templates["solar_production"] = { - "energy_profile": { - "mode": "producer", - "power_range": [-2000.0, 0.0], - "typical_power": -1500.0, - "power_variation": 0.2, - "efficiency": 0.85, - }, - "relay_behavior": "non_controllable", - "priority": "MUST_HAVE", - "time_of_day_profile": { - "enabled": True, - "peak_hours": [11, 12, 13, 14, 15], - }, - } - - # Unmapped tab templates for the two solar legs - unmapped = yaml_doc.setdefault("unmapped_tab_templates", {}) - for tab in (l1, l2): - key = str(tab) - if key not in unmapped: - unmapped[key] = templates["solar_production"] - - # Synchronization group for the two legs - tab_syncs: list[dict[str, Any]] = yaml_doc.setdefault("tab_synchronizations", []) - tab_syncs.append( - { - "tabs": [l1, l2], - "behavior": "240v_split_phase", - "power_split": "equal", - "energy_sync": True, - "template": "solar_production", - } - ) - - # Ensure legs listed as unmapped - yaml_doc["unmapped_tabs"] = sorted(set(yaml_doc.get("unmapped_tabs", [])) | {l1, l2}) - - def _infer_template_key(self, name: str, power_w: float, tabs: list[int]) -> str: - lname = name.lower() - if any(k in lname for k in ("light", "lights")): - return "lighting" - if "kitchen" in lname and "outlet" in lname: - return "kitchen_outlets" - if any(k in lname for k in ("hvac", "furnace", "air conditioner", "ac", "heat pump")): - return "hvac" - if any(k in lname for k in ("fridge", "refrigerator", "wine fridge")): - return "refrigerator" - if any(k in lname for k in ("ev", "charger")): - return "ev_charger" - if any(k in lname for k in ("pool", "spa", "fountain")): - return "pool_equipment" - if any(k in lname for k in ("internet", "router", "network", "modem")): - return "always_on" - if len(tabs) >= 2: - return "major_appliance" - if "outlet" in lname: - return "outlets" - if power_w < 0: - return "producer" - return "major_appliance" - - def _make_template(self, key: str, typical: float, name: str) -> dict[str, Any]: - # Base ranges derived from snapshot - if key == "producer" or typical < 0: - pr_min = min(typical * 2.0, -50.0) - profile = { - "mode": "producer", - "power_range": [pr_min, 0.0], - "typical_power": typical, - "power_variation": 0.3, - } - return { - "energy_profile": profile, - "relay_behavior": "non_controllable", - "priority": "MUST_HAVE", - } - - if key == "ev_charger": - profile = { - "mode": "consumer", - "power_range": [0.0, max(abs(typical) * 2.0, 7200.0)], - "typical_power": max(typical, 3000.0), - "power_variation": 0.15, - } - return { - "energy_profile": profile, - "relay_behavior": "controllable", - "priority": "NON_ESSENTIAL", - # Prefer night charging and respond to grid stress - "time_of_day_profile": { - "enabled": True, - "peak_hours": [22, 23, 0, 1, 2, 3, 4, 5, 6], - }, - "smart_behavior": {"responds_to_grid": True, "max_power_reduction": 0.6}, - } - - if key == "refrigerator": - profile = { - "mode": "consumer", - "power_range": [50.0, 200.0], - "typical_power": max(typical, 120.0), - "power_variation": 0.2, - } - return { - "energy_profile": profile, - "relay_behavior": "non_controllable", - "priority": "MUST_HAVE", - "cycling_pattern": {"on_duration": 600, "off_duration": 1800}, - } - - if key == "hvac": - profile = { - "mode": "consumer", - "power_range": [0.0, max(abs(typical) * 2.0, 2800.0)], - "typical_power": max(typical, 1800.0), - "power_variation": 0.15, - } - return { - "energy_profile": profile, - "relay_behavior": "controllable", - "priority": "MUST_HAVE", - "cycling_pattern": {"on_duration": 1200, "off_duration": 2400}, - } - - if key == "lighting": - profile = { - "mode": "consumer", - "power_range": [0.0, max(abs(typical) * 2.0, 300.0)], - "typical_power": max(typical, 40.0), - "power_variation": 0.1, - } - return { - "energy_profile": profile, - "relay_behavior": "controllable", - "priority": "NON_ESSENTIAL", - "time_of_day_profile": {"enabled": True, "peak_hours": [18, 19, 20, 21, 22]}, - } - - if key == "kitchen_outlets": - profile = { - "mode": "consumer", - "power_range": [0.0, max(abs(typical) * 2.0, 2400.0)], - "typical_power": max(typical, 300.0), - "power_variation": 0.4, - } - return { - "energy_profile": profile, - "relay_behavior": "controllable", - "priority": "MUST_HAVE", - } - - if key == "outlets": - profile = { - "mode": "consumer", - "power_range": [0.0, max(abs(typical) * 2.0, 1800.0)], - "typical_power": max(typical, 150.0), - "power_variation": 0.4, - } - return { - "energy_profile": profile, - "relay_behavior": "controllable", - "priority": "MUST_HAVE", - } - - if key == "always_on": - profile = { - "mode": "consumer", - "power_range": [40.0, 100.0], - "typical_power": max(typical, 60.0), - "power_variation": 0.1, - } - return { - "energy_profile": profile, - "relay_behavior": "controllable", - "priority": "MUST_HAVE", - } - - if key == "pool_equipment": - profile = { - "mode": "consumer", - "power_range": [0.0, max(abs(typical) * 2.0, 1200.0)], - "typical_power": max(typical, 800.0), - "power_variation": 0.1, - } - return { - "energy_profile": profile, - "relay_behavior": "controllable", - "priority": "NON_ESSENTIAL", - # Typical pump run: 2h on, 4h off, repeating - "cycling_pattern": {"on_duration": 7200, "off_duration": 14400}, - } - - # major_appliance and fallback - profile = { - "mode": "consumer", - "power_range": [0.0, max(abs(typical) * 2.0, 2500.0)], - "typical_power": max(typical, 800.0), - "power_variation": 0.3, - } - return { - "energy_profile": profile, - "relay_behavior": "controllable", - "priority": "NON_ESSENTIAL", - } diff --git a/custom_components/span_panel/simulation_utils.py b/custom_components/span_panel/simulation_utils.py deleted file mode 100644 index e52e3278..00000000 --- a/custom_components/span_panel/simulation_utils.py +++ /dev/null @@ -1,162 +0,0 @@ -"""Simulation utilities for SPAN Panel integration.""" - -from __future__ import annotations - -import logging -from pathlib import Path -from typing import Any - -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant -from homeassistant.util import slugify -import yaml - -from .const import ( - CONF_SIMULATION_CONFIG, - COORDINATOR, - DOMAIN, -) -from .options import ( - INVERTER_LEG1, - INVERTER_LEG2, -) -from .simulation_generator import SimulationYamlGenerator - -_LOGGER = logging.getLogger(__name__) - - -def infer_template_for(name: str, tabs: list[int]) -> str: - """Infer circuit template based on circuit name and tab configuration. - - Args: - name: Circuit name to analyze - tabs: List of tab numbers for this circuit - - Returns: - Template string identifier for the circuit type - - """ - lname = str(name).lower() - if any(k in lname for k in ["light", "lights"]): - return "lighting" - if "kitchen" in lname and "outlet" in lname: - return "kitchen_outlets" - if any(k in lname for k in ["hvac", "furnace", "air conditioner", "ac", "heat pump"]): - return "hvac" - if any(k in lname for k in ["fridge", "refrigerator", "wine fridge"]): - return "refrigerator" - if any(k in lname for k in ["ev", "charger"]): - return "ev_charger" - if any(k in lname for k in ["pool", "spa", "fountain"]): - return "pool_equipment" - if any(k in lname for k in ["internet", "router", "network", "modem"]): - return "always_on" - # Heuristics: 240V multi-tab loads as major appliances - if len(tabs) >= 2: - return "major_appliance" - # Fallbacks - if "outlet" in lname: - return "outlets" - return "major_appliance" - - -async def clone_panel_to_simulation( - hass: HomeAssistant, - config_entry: ConfigEntry, - user_input: dict[str, Any] | None = None, -) -> tuple[Path, dict[str, str]]: - """Clone the live panel into a simulation YAML stored in simulation_configs. - - Args: - hass: Home Assistant instance - config_entry: Configuration entry for the SPAN panel - user_input: User input from the config flow form - - Returns: - Tuple of (destination_path, errors_dict) - - """ - errors: dict[str, str] = {} - - # Compute default filename first - device_name = config_entry.data.get("device_name", config_entry.title) - safe_device = slugify(device_name) if isinstance(device_name, str) else "span_panel" - - config_dir = Path(__file__).parent / "simulation_configs" - base_name = f"simulation_config_{safe_device}.yaml" - dest_path = config_dir / base_name - - # Resolve coordinator (live) - coordinator_data = hass.data.get(DOMAIN, {}).get(config_entry.entry_id, {}) - coordinator = coordinator_data.get(COORDINATOR) - if coordinator is None: - errors["base"] = "coordinator_unavailable" - return dest_path, errors - - # Suffix if exists: _2, _3, ... - suffix_index = 1 - if await hass.async_add_executor_job(dest_path.exists): - suffix_index = 2 - while True: - candidate = config_dir / f"simulation_config_{safe_device}_{suffix_index}.yaml" - if not await hass.async_add_executor_job(candidate.exists): - dest_path = candidate - break - suffix_index += 1 - - if user_input is not None: - try: - # Use a separate generator to build YAML purely from live data - # Pass solar leg selections from options if present - leg1_opt = config_entry.options.get(INVERTER_LEG1, 0) - leg2_opt = config_entry.options.get(INVERTER_LEG2, 0) - generator = SimulationYamlGenerator( - hass=hass, - coordinator=coordinator, - solar_leg1=int(leg1_opt) if leg1_opt else None, - solar_leg2=int(leg2_opt) if leg2_opt else None, - ) - snapshot_yaml, num_tabs = await generator.build_yaml_from_live_panel() - - # snapshot_yaml and num_tabs returned by generator - snapshot_yaml["panel_config"]["serial_number"] = f"{safe_device}_simulation" + ( - "" if suffix_index == 1 else f"_{suffix_index}" - ) - - # Use the same filename pattern (device name based, not tab count) - # The dest_path is already correctly set above - - # Ensure directory exists and write file - await hass.async_add_executor_job( - lambda: dest_path.parent.mkdir(parents=True, exist_ok=True) - ) - - def _write_yaml() -> None: - with dest_path.open("w", encoding="utf-8") as f: - yaml.safe_dump(snapshot_yaml, f, sort_keys=False) - - await hass.async_add_executor_job(_write_yaml) - _LOGGER.info("Cloned live panel to simulation YAML at %s", dest_path) - - # Update config entry to point to the new simulation config - try: - new_data = dict(config_entry.data) - new_data[CONF_SIMULATION_CONFIG] = dest_path.stem - hass.config_entries.async_update_entry(config_entry, data=new_data) - _LOGGER.debug("Set CONF_SIMULATION_CONFIG to %s", dest_path.stem) - except Exception as update_err: - _LOGGER.warning( - "Failed to set CONF_SIMULATION_CONFIG to %s: %s", - dest_path.stem, - update_err, - ) - - # Return success with no errors - return dest_path, {} - - except Exception as e: - _LOGGER.error("Clone to simulation failed: %s", e) - errors["base"] = f"Clone failed: {e}" - - # Return the destination path for the form - return dest_path, errors diff --git a/custom_components/span_panel/span_panel.py b/custom_components/span_panel/span_panel.py deleted file mode 100644 index e190a3a9..00000000 --- a/custom_components/span_panel/span_panel.py +++ /dev/null @@ -1,183 +0,0 @@ -"""Module to read production and consumption values from a Span panel.""" - -from datetime import datetime -import logging -from time import time as _epoch_time - -from .exceptions import SpanPanelReturnedEmptyData, SpanPanelSimulationOfflineError -from .options import Options -from .span_panel_api import SpanPanelApi -from .span_panel_circuit import SpanPanelCircuit -from .span_panel_data import SpanPanelData -from .span_panel_hardware_status import SpanPanelHardwareStatus -from .span_panel_storage_battery import SpanPanelStorageBattery - -STATUS_URL = "http://{}/api/v1/status" -SPACES_URL = "http://{}/api/v1/spaces" -CIRCUITS_URL = "http://{}/api/v1/circuits" -PANEL_URL = "http://{}/api/v1/panel" -REGISTER_URL = "http://{}/api/v1/auth/register" -STORAGE_BATTERY_URL = "http://{}/api/v1/storage/soe" - -_LOGGER: logging.Logger = logging.getLogger(__name__) - -SPAN_CIRCUITS = "circuits" -SPAN_SYSTEM = "system" -PANEL_POWER = "instantGridPowerW" -SYSTEM_DOOR_STATE = "doorState" -SYSTEM_DOOR_STATE_CLOSED = "CLOSED" -SYSTEM_DOOR_STATE_OPEN = "OPEN" -SYSTEM_ETHERNET_LINK = "eth0Link" -SYSTEM_CELLULAR_LINK = "wwanLink" -SYSTEM_WIFI_LINK = "wlanLink" - - -class SpanPanel: - """Class to manage the Span Panel.""" - - def __init__( - self, - host: str, - access_token: str | None = None, # nosec - options: Options | None = None, - use_ssl: bool = False, - scan_interval: int | None = None, - simulation_mode: bool = False, - simulation_config_path: str | None = None, - simulation_start_time: datetime | None = None, - simulation_offline_minutes: int = 0, - ) -> None: - """Initialize the Span Panel.""" - self._options = options - self.api = SpanPanelApi( - host, - access_token, - options, - use_ssl, - scan_interval, - simulation_mode, - simulation_config_path, - simulation_start_time, - simulation_offline_minutes, - ) - self._status: SpanPanelHardwareStatus | None = None - self._panel: SpanPanelData | None = None - self._circuits: dict[str, SpanPanelCircuit] = {} - self._storage_battery: SpanPanelStorageBattery | None = None - - def _get_hardware_status(self) -> SpanPanelHardwareStatus: - """Get hardware status with type checking.""" - if self._status is None: - raise RuntimeError("Hardware status not available") - return self._status - - def _get_data(self) -> SpanPanelData: - """Get data with type checking.""" - if self._panel is None: - raise RuntimeError("Panel data not available") - return self._panel - - def _get_storage_battery(self) -> SpanPanelStorageBattery: - """Get storage battery with type checking.""" - if self._storage_battery is None: - raise RuntimeError("Storage battery not available") - return self._storage_battery - - @property - def host(self) -> str: - """Return the host of the panel.""" - return self.api.host - - @property - def options(self) -> Options | None: - """Get options data atomically.""" - return self._options - - def _update_status(self, new_status: SpanPanelHardwareStatus) -> None: - """Atomic update of status data.""" - self._status = new_status - - def _update_panel(self, new_panel: SpanPanelData) -> None: - """Atomic update of panel data.""" - self._panel = new_panel - - def _update_circuits(self, new_circuits: dict[str, SpanPanelCircuit]) -> None: - """Atomic update of circuits data.""" - circuit_keys = list(new_circuits.keys()) - _LOGGER.debug("Updating circuits. Total: %s", len(circuit_keys)) - - self._circuits = new_circuits - - def _update_storage_battery(self, new_battery: SpanPanelStorageBattery) -> None: - """Atomic update of storage battery data.""" - self._storage_battery = new_battery - - async def update(self) -> None: - """Update all panel data atomically.""" - try: - # Start timing for API calls - api_start = _epoch_time() - - # Debug battery option status - battery_option_enabled = self._options and self._options.enable_battery_percentage - - # Use batch API call for true parallelization at the client level - # This makes concurrent HTTP requests when cache misses occur - all_data = await self.api.get_all_data(include_battery=bool(battery_option_enabled)) - - # Extract processed data from batch response - new_status = all_data.get("status") - new_panel = all_data.get("panel") - new_circuits = all_data.get("circuits") - new_battery = all_data.get("battery") if battery_option_enabled else None - - # Atomic updates - ensure we have the required data - if new_status is not None: - self._update_status(new_status) - if new_panel is not None: - self._update_panel(new_panel) - if new_circuits is not None: - self._update_circuits(new_circuits) - - if new_battery is not None: - self._update_storage_battery(new_battery) - - api_duration = _epoch_time() - api_start - - # INFO level logging for API call performance - _LOGGER.info("Panel API calls completed (CLIENT-PARALLEL) - Total: %.3fs", api_duration) - - _LOGGER.debug("Panel update completed successfully") - except SpanPanelReturnedEmptyData: - _LOGGER.warning("Span Panel returned empty data") - except SpanPanelSimulationOfflineError: # Debug logged in coordinator.py - raise - except Exception as err: - _LOGGER.error("Error updating panel: %s", err, exc_info=True) - raise - - @property - def status(self) -> SpanPanelHardwareStatus: - """Get status data atomically.""" - result = self._get_hardware_status() - return result - - @property - def panel(self) -> SpanPanelData: - """Get panel data atomically.""" - return self._get_data() - - @property - def circuits(self) -> dict[str, SpanPanelCircuit]: - """Get circuits data atomically.""" - return self._circuits - - @property - def storage_battery(self) -> SpanPanelStorageBattery: - """Get storage battery data atomically.""" - result = self._get_storage_battery() - return result - - async def close(self) -> None: - """Close the API client and clean up resources.""" - await self.api.close() diff --git a/custom_components/span_panel/span_panel_api.py b/custom_components/span_panel/span_panel_api.py deleted file mode 100644 index 14ae662b..00000000 --- a/custom_components/span_panel/span_panel_api.py +++ /dev/null @@ -1,674 +0,0 @@ -"""Span Panel API - Updated to use span-panel-api package.""" - -from copy import deepcopy -from datetime import datetime -import logging -import os -from typing import Any -import uuid - -from span_panel_api import SpanPanelClient, set_async_delay_func -from span_panel_api.exceptions import ( - SpanPanelAPIError, - SpanPanelAuthError, - SpanPanelConnectionError, - SpanPanelRetriableError, - SpanPanelServerError, - SpanPanelTimeoutError, -) - -from .const import ( - API_TIMEOUT, - DEFAULT_API_RETRIES, - DEFAULT_API_RETRY_BACKOFF_MULTIPLIER, - DEFAULT_API_RETRY_TIMEOUT, - PANEL_MAIN_RELAY_STATE_UNKNOWN_VALUE, - CircuitPriority, - CircuitRelayState, -) -from .exceptions import SpanPanelReturnedEmptyData, SpanPanelSimulationOfflineError -from .options import Options -from .span_panel_circuit import SpanPanelCircuit -from .span_panel_data import SpanPanelData -from .span_panel_hardware_status import SpanPanelHardwareStatus -from .span_panel_storage_battery import SpanPanelStorageBattery - -_LOGGER: logging.Logger = logging.getLogger(__name__) - - -class SpanPanelApi: - """Span Panel API - Now using span-panel-api package.""" - - def __init__( - self, - host: str, - access_token: str | None = None, # nosec - options: Options | None = None, - use_ssl: bool = False, - scan_interval: int | None = None, - simulation_mode: bool = False, - simulation_config_path: str | None = None, - simulation_start_time: datetime | None = None, - simulation_offline_minutes: int = 0, - ) -> None: - """Initialize the Span Panel API.""" - # For simulation mode, keep the original host (which should be the serial number) - # For real hardware, normalize to lowercase - self.host: str = host if simulation_mode else host.lower() - self.access_token: str | None = access_token - self.options: Options | None = options - self.scan_interval: int = scan_interval or 15 # Default to 15 seconds - self.use_ssl: bool = use_ssl - self.simulation_mode: bool = simulation_mode - self.simulation_config_path: str | None = simulation_config_path - self.simulation_start_time: datetime | None = simulation_start_time - self.simulation_offline_minutes: int = simulation_offline_minutes - # Separate timer for offline mode - when the offline period started - # Use simulation_start_time as the offline start time when offline mode is enabled - # Explicitly declare attribute type for mypy - self.offline_start_time: datetime | None = None - if simulation_mode and simulation_offline_minutes > 0 and simulation_start_time: - self.offline_start_time = simulation_start_time - - # Initialize client as None - will be created in setup() - self._authenticated = False - - # Store client parameters for lazy initialization - # Get retry configuration from options or use defaults - if options: - self._retries = options.api_retries - self._retry_timeout = options.api_retry_timeout - self._retry_backoff_multiplier = options.api_retry_backoff_multiplier - else: - self._retries = DEFAULT_API_RETRIES - self._retry_timeout = DEFAULT_API_RETRY_TIMEOUT - self._retry_backoff_multiplier = DEFAULT_API_RETRY_BACKOFF_MULTIPLIER - - # Initialize client as None - will be created in setup() - self._client: SpanPanelClient | None = None - - def _is_panel_offline(self) -> bool: - """Check if the panel should be offline based on simulation settings. - - Returns: - True if the panel should appear offline, False otherwise. - - """ - if not self.simulation_mode or self.simulation_offline_minutes <= 0: - _LOGGER.debug( - "[SpanPanelApi] Panel not offline: simulation_mode=%s, offline_minutes=%s", - self.simulation_mode, - self.simulation_offline_minutes, - ) - return False - - if not self.offline_start_time: - _LOGGER.debug("[SpanPanelApi] Panel not offline: no offline start time") - return False - - # Calculate how many minutes have passed since offline period started - now = datetime.now() - elapsed_minutes = (now - self.offline_start_time).total_seconds() / 60 - - is_offline = elapsed_minutes < self.simulation_offline_minutes - _LOGGER.debug( - "[SpanPanelApi] Panel offline check: offline_start_time=%s, elapsed_minutes=%.2f, offline_minutes=%s, is_offline=%s", - self.offline_start_time, - elapsed_minutes, - self.simulation_offline_minutes, - is_offline, - ) - - # Panel is offline for the specified number of minutes after offline period starts - return is_offline - - def set_simulation_offline_mode(self, offline_minutes: int) -> None: - """Set the offline simulation mode for simulators only. - - Args: - offline_minutes: Number of minutes the panel should appear offline (0 to disable) - - """ - _LOGGER.info( - "[SpanPanelApi] set_simulation_offline_mode called: offline_minutes=%s, simulation_mode=%s", - offline_minutes, - self.simulation_mode, - ) - - if not self.simulation_mode: - _LOGGER.warning("[SpanPanelApi] Cannot set offline mode: not in simulation mode") - return - - self.simulation_offline_minutes = offline_minutes - - # Set offline start time to "now" if offline mode is enabled, otherwise clear it - if offline_minutes > 0: - self.offline_start_time = datetime.now() - _LOGGER.info( - "[SpanPanelApi] Set simulation offline mode: %s minutes starting now (%s)", - offline_minutes, - self.offline_start_time, - ) - else: - self.offline_start_time = None - _LOGGER.info("[SpanPanelApi] Disabled simulation offline mode") - - def _create_client(self) -> None: - """Create the SpanPanelClient with stored parameters.""" - _LOGGER.debug("[SpanPanelApi] Creating SpanPanelClient for host=%s", self.host) - - # Determine simulation config path if in simulation mode - config_path = None - if self.simulation_mode: - if self.simulation_config_path: - config_path = self.simulation_config_path - else: - # Use default 32-circuit config relative to this file - current_dir = os.path.dirname(__file__) - config_path = os.path.join( - current_dir, "simulation_configs", "simulation_config_32_circuit.yaml" - ) - _LOGGER.debug("[SpanPanelApi] Using default simulation config: %s", config_path) - - # Create client with appropriate parameters based on mode - if self.simulation_mode: - # Convert datetime to ISO format string if provided - simulation_start_time_str = None - if self.simulation_start_time: - simulation_start_time_str = self.simulation_start_time.isoformat() - - self._client = SpanPanelClient( - host=self.host, - timeout=API_TIMEOUT, - use_ssl=self.use_ssl, - retries=self._retries, - retry_timeout=self._retry_timeout, - retry_backoff_multiplier=self._retry_backoff_multiplier, - simulation_mode=self.simulation_mode, - simulation_config_path=config_path, - simulation_start_time=simulation_start_time_str, - ) - else: - # For live panels, don't pass simulation parameters - self._client = SpanPanelClient( - host=self.host, - timeout=API_TIMEOUT, - use_ssl=self.use_ssl, - retries=self._retries, - retry_timeout=self._retry_timeout, - retry_backoff_multiplier=self._retry_backoff_multiplier, - ) - if self.access_token: - self._client.set_access_token(self.access_token) - # Mark as authenticated since we have a token - avoid unnecessary re-auth - self._authenticated = True - - def _ensure_client_open(self) -> None: - # Check if client was explicitly closed (None and we've tried to create it before) - if self._client is None and hasattr(self, "_client_created"): - _LOGGER.debug( - "[SpanPanelApi] Client was closed, cannot recreate after explicit close for host=%s", - self.host, - ) - raise SpanPanelAPIError("API client has been closed") - - # Create client if it doesn't exist yet - if self._client is None: - self._create_client() - self._client_created = True - return - - client_obj = getattr(self._client, "_client", None) - if client_obj is not None and getattr(client_obj, "is_closed", False): - _LOGGER.debug( - "[SpanPanelApi] Underlying httpx client is closed for host=%s (SSL=%s), will be recreated on next use", - self.host, - self.use_ssl, - ) - # Let the SpanPanelClient handle closed connections internally - # Don't interfere with its connection management - it will create new connections as needed - - def _debug_check_client(self, method_name: str) -> None: - # Check if the client is in a closed or invalid state - if self._client is None: - _LOGGER.error( - "[SpanPanelApi] Client is None in %s! This indicates a bug in the lifecycle management.", - method_name, - ) - return - - client_obj = getattr(self._client, "_client", None) - is_closed = False - if client_obj is not None: - # httpx.AsyncClient has a .is_closed property - is_closed = getattr(client_obj, "is_closed", False) - if is_closed: - _LOGGER.error( - "[SpanPanelApi] Attempting to use a closed client in %s! This will cause runtime errors.", - method_name, - ) - - async def setup(self) -> None: - """Initialize the API client (Long-Lived Pattern setup).""" - _LOGGER.debug("[SpanPanelApi] Setting up API client for host=%s", self.host) - - # Create the client first - if self._client is None: - self._create_client() - self._client_created = True - - try: - # If we have a token, verify it works - if self.access_token: - _LOGGER.debug("[SpanPanelApi] Testing existing access token") - await self.get_panel_data() # Test authenticated endpoint - # _authenticated should already be True from _create_client - _LOGGER.debug("[SpanPanelApi] Existing access token is valid") - else: - _LOGGER.debug("[SpanPanelApi] No access token provided during setup") - except SpanPanelAuthError: - _LOGGER.warning( - "[SpanPanelApi] Access token invalid, will re-authenticate on first use" - ) - self._authenticated = False - except Exception as e: - _LOGGER.error("[SpanPanelApi] Setup failed: %s", e) - self._authenticated = False - await self.close() # Clean up on setup failure - raise - - async def _ensure_authenticated(self) -> None: - """Ensure we have valid authentication, re-authenticate if needed.""" - if not self._authenticated: - _LOGGER.debug("[SpanPanelApi] Re-authentication needed") - if self._client is None: - raise SpanPanelAPIError("API client has been closed") - try: - # Generate a unique client name for re-authentication - client_name = f"home-assistant-{uuid.uuid4()}" - auth_response = await self._client.authenticate( - client_name, "Home Assistant Local Span Integration" - ) - self.access_token = auth_response.access_token - self._authenticated = True - _LOGGER.debug("[SpanPanelApi] Re-authentication successful") - except Exception as e: - _LOGGER.error("[SpanPanelApi] Re-authentication failed: %s", e) - raise SpanPanelAuthError(f"Re-authentication failed: {e}") from e - - async def ping(self) -> bool: - """Ping the Span Panel API.""" - # Check if panel should be offline in simulation mode - if self._is_panel_offline(): - raise SpanPanelSimulationOfflineError("Panel is offline in simulation mode") - - self._ensure_client_open() - if self._client is None: - return False - # status endpoint doesn't require auth. - try: - await self.get_status_data() - return True - except (SpanPanelConnectionError, SpanPanelTimeoutError, SpanPanelAPIError): - return False - - async def ping_with_auth(self) -> bool: - """Test connection and authentication.""" - # Check if panel should be offline in simulation mode - if self._is_panel_offline(): - raise SpanPanelSimulationOfflineError("Panel is offline in simulation mode") - - self._ensure_client_open() - if self._client is None: - return False - try: - # Use get_panel_data() since it requires authentication - await self.get_panel_data() - return True - except SpanPanelAuthError: - return False - except ( - SpanPanelConnectionError, - SpanPanelTimeoutError, - SpanPanelReturnedEmptyData, - ): - return False - except Exception as e: - # Catch any other authentication-related errors from span-panel-api - _LOGGER.warning("Unexpected error during authentication test: %s", e) - return False - - async def get_access_token(self) -> str: - """Get the access token.""" - self._ensure_client_open() - if self._client is None: - raise SpanPanelAPIError("API client has been closed") - try: - # Generate a unique client name - client_name = f"home-assistant-{uuid.uuid4()}" - auth_response = await self._client.authenticate( - client_name, "Home Assistant Local Span Integration" - ) - - # Store the token - self.access_token = auth_response.access_token - return str(auth_response.access_token) - - except (SpanPanelConnectionError, SpanPanelAPIError) as e: - raise SpanPanelReturnedEmptyData(f"Failed to get access token: {e}") from e - - async def get_status_data(self) -> SpanPanelHardwareStatus: - """Get the status data.""" - # Check if panel should be offline in simulation mode - if self._is_panel_offline(): - raise SpanPanelSimulationOfflineError("Panel is offline in simulation mode") - - self._ensure_client_open() - if self._client is None: - raise SpanPanelAPIError("API client has been closed") - self._debug_check_client("get_status_data") - try: - status_response = await self._client.get_status() - - # Convert the attrs model to dict and then to our data class - status_dict = status_response.to_dict() - status_data = SpanPanelHardwareStatus.from_dict(status_dict) - return status_data - - except SpanPanelRetriableError as e: - _LOGGER.warning("Retriable error getting status data (will retry): %s", e) - raise - except SpanPanelServerError as e: - _LOGGER.error("Server error getting status data (will not retry): %s", e) - raise - except ( - SpanPanelConnectionError, - SpanPanelTimeoutError, - SpanPanelAPIError, - ) as e: - _LOGGER.error("Failed to get status data: %s", e) - raise - - async def get_panel_data(self) -> SpanPanelData: - """Get the panel data.""" - # Check if panel should be offline in simulation mode - if self._is_panel_offline(): - raise SpanPanelSimulationOfflineError("Panel is offline in simulation mode") - - self._ensure_client_open() - if self._client is None: - raise SpanPanelAPIError("API client has been closed") - self._debug_check_client("get_panel_data") - try: - await self._ensure_authenticated() - panel_response = await self._client.get_panel_state() - - # Convert the attrs model to dict and deep copy before processing - raw_data: Any = deepcopy(panel_response.to_dict()) - panel_data: SpanPanelData = SpanPanelData.from_dict(raw_data, self.options) - - # Span Panel API might return empty result. - # We use relay state == UNKNOWN as an indication of that scenario. - if panel_data.main_relay_state == PANEL_MAIN_RELAY_STATE_UNKNOWN_VALUE: - raise SpanPanelReturnedEmptyData() - - return panel_data - - except SpanPanelRetriableError as e: - _LOGGER.warning("Retriable error getting panel data (will retry): %s", e) - raise - except SpanPanelServerError as e: - _LOGGER.error("Server error getting panel data (will not retry): %s", e) - raise - except SpanPanelAuthError as e: - # Reset auth flag and let coordinator handle retry - self._authenticated = False - _LOGGER.error("Authentication failed for panel data: %s", e) - raise - except ( - SpanPanelConnectionError, - SpanPanelTimeoutError, - SpanPanelAPIError, - ) as e: - _LOGGER.error("Failed to get panel data: %s", e) - raise - - async def get_circuits_data(self) -> dict[str, SpanPanelCircuit]: - """Get the circuits data.""" - # Check if panel should be offline in simulation mode - if self._is_panel_offline(): - raise SpanPanelSimulationOfflineError("Panel is offline in simulation mode") - - self._ensure_client_open() - if self._client is None: - raise SpanPanelAPIError("API client has been closed") - self._debug_check_client("get_circuits_data") - try: - await self._ensure_authenticated() - circuits_response = await self._client.get_circuits() - - # Extract circuits from the response - raw_circuits_data = circuits_response.circuits.additional_properties - - if not raw_circuits_data: - raise SpanPanelReturnedEmptyData() - - circuits_data: dict[str, SpanPanelCircuit] = {} - for circuit_id, raw_circuit_data in raw_circuits_data.items(): - # Convert attrs model to dict - try: - circuit_dict = raw_circuit_data.to_dict() - circuits_data[circuit_id] = SpanPanelCircuit.from_dict(circuit_dict) - except Exception as e: - if circuit_id.startswith("unmapped_tab_"): - _LOGGER.error("Failed to convert unmapped circuit %s: %s", circuit_id, e) - raise - - return circuits_data - - except SpanPanelRetriableError as e: - _LOGGER.warning("Retriable error getting circuits data (will retry): %s", e) - raise - except SpanPanelServerError as e: - _LOGGER.error("Server error getting circuits data (will not retry): %s", e) - raise - except SpanPanelAuthError as e: - # Reset auth flag and let coordinator handle retry - self._authenticated = False - _LOGGER.error("Authentication failed for circuits data: %s", e) - raise - except ( - SpanPanelConnectionError, - SpanPanelTimeoutError, - SpanPanelAPIError, - ) as e: - _LOGGER.error("Failed to get circuits data: %s", e) - raise - - async def get_storage_battery_data(self) -> SpanPanelStorageBattery: - """Get the storage battery data.""" - # Check if panel should be offline in simulation mode - if self._is_panel_offline(): - raise SpanPanelSimulationOfflineError("Panel is offline in simulation mode") - - self._ensure_client_open() - if self._client is None: - raise SpanPanelAPIError("API client has been closed") - self._debug_check_client("get_storage_battery_data") - try: - await self._ensure_authenticated() - storage_response = await self._client.get_storage_soe() - - # Extract SOE data from the response - storage_battery_data = storage_response.soe.to_dict() - - # Span Panel API might return empty result. - if not storage_battery_data: - raise SpanPanelReturnedEmptyData() - - return SpanPanelStorageBattery.from_dict(storage_battery_data) - - except SpanPanelRetriableError as e: - _LOGGER.warning("Retriable error getting storage battery data (will retry): %s", e) - raise - except SpanPanelServerError as e: - _LOGGER.error("Server error getting storage battery data (will not retry): %s", e) - raise - except SpanPanelAuthError as e: - # Reset auth flag and let coordinator handle retry - self._authenticated = False - _LOGGER.error("Authentication failed for storage battery data: %s", e) - raise - except ( - SpanPanelConnectionError, - SpanPanelTimeoutError, - SpanPanelAPIError, - ) as e: - _LOGGER.error("Failed to get storage battery data: %s", e) - raise - - async def set_relay(self, circuit: SpanPanelCircuit, state: CircuitRelayState) -> None: - """Set the relay state.""" - self._ensure_client_open() - if self._client is None: - raise SpanPanelAPIError("API client has been closed") - self._debug_check_client("set_relay") - try: - await self._ensure_authenticated() - await self._client.set_circuit_relay(circuit.circuit_id, state.name) - - except SpanPanelRetriableError as e: - _LOGGER.warning("Retriable error setting relay state (will retry): %s", e) - raise - except SpanPanelServerError as e: - _LOGGER.error("Server error setting relay state (will not retry): %s", e) - raise - except SpanPanelAuthError as e: - # Reset auth flag and let coordinator handle retry - self._authenticated = False - _LOGGER.error("Authentication failed for set relay: %s", e) - raise - except ( - SpanPanelConnectionError, - SpanPanelTimeoutError, - SpanPanelAPIError, - ) as e: - _LOGGER.error("Failed to set relay state: %s", e) - raise - - async def set_priority(self, circuit: SpanPanelCircuit, priority: CircuitPriority) -> None: - """Set the priority.""" - self._ensure_client_open() - if self._client is None: - raise SpanPanelAPIError("API client has been closed") - self._debug_check_client("set_priority") - try: - await self._ensure_authenticated() - await self._client.set_circuit_priority(circuit.circuit_id, priority.name) - - except SpanPanelRetriableError as e: - _LOGGER.warning("Retriable error setting priority (will retry): %s", e) - raise - except SpanPanelServerError as e: - _LOGGER.error("Server error setting priority (will not retry): %s", e) - raise - except SpanPanelAuthError as e: - # Reset auth flag and let coordinator handle retry - self._authenticated = False - _LOGGER.error("Authentication failed for set priority: %s", e) - raise - except ( - SpanPanelConnectionError, - SpanPanelTimeoutError, - SpanPanelAPIError, - ) as e: - _LOGGER.error("Failed to set priority: %s", e) - raise - - async def get_all_data(self, include_battery: bool = False) -> dict[str, Any]: - """Get all panel data in parallel for maximum performance. - - Args: - include_battery: Whether to include battery/storage data - - Returns: - Dictionary containing all panel data with proper processing applied - - """ - # Check if panel should be offline in simulation mode - if self._is_panel_offline(): - raise SpanPanelSimulationOfflineError("Panel is offline in simulation mode") - - self._ensure_client_open() - if self._client is None: - raise SpanPanelAPIError("API client has been closed") - - try: - # Use the client's parallel batch method - raw_data = await self._client.get_all_data(include_battery=include_battery) - - # Process data using the same logic as individual methods - result: dict[str, Any] = {} - - if raw_data.get("status"): - # Process status data (same as get_status_data) - status_out = raw_data["status"] - status_dict = status_out.to_dict() - result["status"] = SpanPanelHardwareStatus.from_dict(status_dict) - - if raw_data.get("panel_state"): - # Process panel data (same as get_panel_data) - panel_state = raw_data["panel_state"] - panel_dict = deepcopy(panel_state.to_dict()) - result["panel"] = SpanPanelData.from_dict(panel_dict, self.options) - - if raw_data.get("circuits"): - # Process circuits data (same as get_circuits_data) - circuits_out = raw_data["circuits"] - circuits_dict: dict[str, SpanPanelCircuit] = {} - if hasattr(circuits_out, "circuits") and hasattr( - circuits_out.circuits, "additional_properties" - ): - for ( - circuit_id, - raw_circuit_data, - ) in circuits_out.circuits.additional_properties.items(): - # Convert attrs model to dict (same as get_circuits_data) - circuit_dict = raw_circuit_data.to_dict() - circuits_dict[circuit_id] = SpanPanelCircuit.from_dict(circuit_dict) - result["circuits"] = circuits_dict - - if include_battery and raw_data.get("storage"): - # Process battery data (same as get_storage_battery_data) - battery_storage = raw_data["storage"] - storage_battery_data = battery_storage.soe.to_dict() - result["battery"] = SpanPanelStorageBattery.from_dict(storage_battery_data) - - return result - - except ( - SpanPanelConnectionError, - SpanPanelTimeoutError, - SpanPanelAPIError, - ) as e: - _LOGGER.error("Failed to get all panel data: %s", e) - raise - - async def close(self) -> None: - """Close the API client and clean up resources.""" - _LOGGER.debug("[SpanPanelApi] Closing API client for host=%s", self.host) - if self._client is not None: - try: - await self._client.close() - except Exception as e: - _LOGGER.warning("Error closing API client: %s", e) - finally: - # Reset client reference to prevent further use - self._client = None - - -# Re-export items that are imported by __init__.py -# Options is already imported above, SpanPanelAuthError comes from span_panel_api package - -# Export list for this module -__all__ = ["SpanPanelApi", "Options", "SpanPanelAuthError", "set_async_delay_func"] diff --git a/custom_components/span_panel/span_panel_circuit.py b/custom_components/span_panel/span_panel_circuit.py deleted file mode 100644 index 04280ff1..00000000 --- a/custom_components/span_panel/span_panel_circuit.py +++ /dev/null @@ -1,72 +0,0 @@ -"""Data models for Span Panel circuit information.""" - -from copy import deepcopy -from dataclasses import dataclass, field -from typing import Any - -from .const import CircuitRelayState - - -@dataclass -class SpanPanelCircuit: - """Class representing a Span Panel circuit.""" - - circuit_id: str - name: str - relay_state: str - instant_power: float - instant_power_update_time: int - produced_energy: float - consumed_energy: float - energy_accum_update_time: int - tabs: list[int] - priority: str - is_user_controllable: bool - is_sheddable: bool - is_never_backup: bool - breaker_positions: list[int] = field(default_factory=list) - metadata: dict[str, Any] = field(default_factory=dict) - circuit_config: dict[str, Any] = field(default_factory=dict) - state_config: dict[str, Any] = field(default_factory=dict) - raw_data: dict[str, Any] = field(default_factory=dict) - - @property - def is_relay_closed(self) -> bool: - """Return True if the relay is in closed state.""" - return self.relay_state == CircuitRelayState.CLOSED.name - - @staticmethod - def from_dict(data: dict[str, Any]) -> "SpanPanelCircuit": - """Create a SpanPanelCircuit instance from a dictionary. - - Args: - data: Dictionary containing circuit data from the Span Panel API. - - Returns: - A new SpanPanelCircuit instance. - - """ - data_copy: dict[str, Any] = deepcopy(data) - return SpanPanelCircuit( - circuit_id=data_copy["id"], - name=data_copy["name"], - relay_state=data_copy["relayState"], - instant_power=data_copy["instantPowerW"], - instant_power_update_time=data_copy["instantPowerUpdateTimeS"], - produced_energy=data_copy["producedEnergyWh"], - consumed_energy=data_copy["consumedEnergyWh"], - energy_accum_update_time=data_copy["energyAccumUpdateTimeS"], - tabs=data_copy["tabs"], - priority=data_copy["priority"], - is_user_controllable=data_copy["isUserControllable"], - is_sheddable=data_copy["isSheddable"], - is_never_backup=data_copy["isNeverBackup"], - circuit_config=data_copy.get("config", {}), - state_config=data_copy.get("state", {}), - raw_data=data_copy, - ) - - def copy(self) -> "SpanPanelCircuit": - """Create a deep copy for atomic operations.""" - # Circuit contains nested mutable objects, use deepcopy - return deepcopy(self) diff --git a/custom_components/span_panel/span_panel_data.py b/custom_components/span_panel/span_panel_data.py deleted file mode 100644 index 4742665d..00000000 --- a/custom_components/span_panel/span_panel_data.py +++ /dev/null @@ -1,101 +0,0 @@ -"""Span Panel Data.""" - -from copy import deepcopy -from dataclasses import dataclass, field -from typing import Any - -from .const import ( - CURRENT_RUN_CONFIG, - DSM_GRID_STATE, - DSM_STATE, - MAIN_RELAY_STATE, - PANEL_POWER, -) -from .options import Options - - -@dataclass -class SpanPanelData: - """Class representing the data from the Span Panel.""" - - main_relay_state: str - main_meter_energy_produced: float - main_meter_energy_consumed: float - instant_grid_power: float - feedthrough_power: float - feedthrough_energy_produced: float - feedthrough_energy_consumed: float - grid_sample_start_ms: int - grid_sample_end_ms: int - dsm_grid_state: str - dsm_state: str - current_run_config: str - main_meter_energy: dict[str, Any] = field(default_factory=dict) - feedthrough_energy: dict[str, Any] = field(default_factory=dict) - solar_data: dict[str, Any] = field(default_factory=dict) - inverter_data: dict[str, Any] = field(default_factory=dict) - relay_states: dict[str, Any] = field(default_factory=dict) - solar_inverter_data: dict[str, Any] = field(default_factory=dict) - state_data: dict[str, Any] = field(default_factory=dict) - raw_data: dict[str, Any] = field(default_factory=dict) - - @classmethod - def from_dict(cls, data: dict[str, Any], options: Options | None = None) -> "SpanPanelData": - """Create instance from dict with deep copy of input data.""" - data = deepcopy(data) - common_data: dict[str, Any] = { - "main_relay_state": str(data[MAIN_RELAY_STATE]), - "main_meter_energy_produced": float(data["mainMeterEnergy"]["producedEnergyWh"]), - "main_meter_energy_consumed": float(data["mainMeterEnergy"]["consumedEnergyWh"]), - "instant_grid_power": float(data[PANEL_POWER]), - "feedthrough_power": float(data["feedthroughPowerW"]), - "feedthrough_energy_produced": float(data["feedthroughEnergy"]["producedEnergyWh"]), - "feedthrough_energy_consumed": float(data["feedthroughEnergy"]["consumedEnergyWh"]), - "grid_sample_start_ms": int(data["gridSampleStartMs"]), - "grid_sample_end_ms": int(data["gridSampleEndMs"]), - "dsm_grid_state": str(data[DSM_GRID_STATE]), - "dsm_state": str(data[DSM_STATE]), - "current_run_config": str(data[CURRENT_RUN_CONFIG]), - "main_meter_energy": data.get("mainMeterEnergy", {}), - "feedthrough_energy": data.get("feedthroughEnergy", {}), - "solar_inverter_data": data.get("solarInverter", {}), - "state_data": data.get("state", {}), - "raw_data": data, - } - - return cls(**common_data) - - def copy(self) -> "SpanPanelData": - """Create a deep copy for atomic operations.""" - return deepcopy(self) - - # Add camelCase properties for panel sensor mappings - @property - def instantGridPowerW(self) -> float: - """Grid power in watts (camelCase for sensor mapping).""" - return self.instant_grid_power - - @property - def feedthroughPowerW(self) -> float: - """Feedthrough power in watts (camelCase for sensor mapping).""" - return self.feedthrough_power - - @property - def mainMeterEnergyProducedWh(self) -> float: - """Main meter produced energy in Wh (camelCase for sensor mapping).""" - return self.main_meter_energy_produced - - @property - def mainMeterEnergyConsumedWh(self) -> float: - """Main meter consumed energy in Wh (camelCase for sensor mapping).""" - return self.main_meter_energy_consumed - - @property - def feedthroughEnergyProducedWh(self) -> float: - """Feedthrough produced energy in Wh (camelCase for sensor mapping).""" - return self.feedthrough_energy_produced - - @property - def feedthroughEnergyConsumedWh(self) -> float: - """Feedthrough consumed energy in Wh (camelCase for sensor mapping).""" - return self.feedthrough_energy_consumed diff --git a/custom_components/span_panel/span_panel_hardware_status.py b/custom_components/span_panel/span_panel_hardware_status.py deleted file mode 100644 index 0605ec54..00000000 --- a/custom_components/span_panel/span_panel_hardware_status.py +++ /dev/null @@ -1,86 +0,0 @@ -"""Span Panel Hardware Status.""" - -from copy import deepcopy -from dataclasses import dataclass, field -import logging -from typing import Any - -from .const import SYSTEM_DOOR_STATE_CLOSED, SYSTEM_DOOR_STATE_OPEN - -_LOGGER = logging.getLogger(__name__) - - -@dataclass -class SpanPanelHardwareStatus: - """Class representing the hardware status of the Span Panel.""" - - firmware_version: str - update_status: str - env: str - manufacturer: str - serial_number: str - model: str - door_state: str | None - uptime: int - is_ethernet_connected: bool - is_wifi_connected: bool - is_cellular_connected: bool - proximity_proven: bool | None = None - remaining_auth_unlock_button_presses: int = 0 - _system_data: dict[str, Any] = field(default_factory=dict) - - # Door state has been known to return UNKNOWN if the door has not been - # operated recently Sensor is a tamper sensor not a door sensor - @property - def is_door_closed(self) -> bool | None: - """Return whether the door is closed, or None if state is unknown.""" - if self.door_state is None: - return None - if self.door_state not in (SYSTEM_DOOR_STATE_OPEN, SYSTEM_DOOR_STATE_CLOSED): - return None - return self.door_state == SYSTEM_DOOR_STATE_CLOSED - - @property - def system_data(self) -> dict[str, Any]: - """Return the system data.""" - return deepcopy(self._system_data) - - @classmethod - def from_dict(cls, data: dict[str, Any]) -> "SpanPanelHardwareStatus": - """Create a new instance with deep copied data.""" - data_copy = deepcopy(data) - system_data = data_copy.get("system", {}) - - # Handle proximity authentication for both new and old firmware - proximity_proven = None - remaining_auth_unlock_button_presses = 0 - - if "proximityProven" in system_data: - # New firmware (r202342 and newer) - proximity_proven = system_data["proximityProven"] - else: - # Old firmware (before r202342) - remaining_auth_unlock_button_presses = system_data.get( - "remainingAuthUnlockButtonPresses", 0 - ) - - return cls( - firmware_version=data_copy["software"]["firmwareVersion"], - update_status=data_copy["software"]["updateStatus"], - env=data_copy["software"]["env"], - manufacturer=data_copy["system"]["manufacturer"], - serial_number=data_copy["system"]["serial"], - model=data_copy["system"]["model"], - door_state=data_copy["system"]["doorState"], - uptime=data_copy["system"]["uptime"], - is_ethernet_connected=data_copy["network"]["eth0Link"], - is_wifi_connected=data_copy["network"]["wlanLink"], - is_cellular_connected=data_copy["network"]["wwanLink"], - proximity_proven=proximity_proven, - remaining_auth_unlock_button_presses=remaining_auth_unlock_button_presses, - _system_data=system_data, - ) - - def copy(self) -> "SpanPanelHardwareStatus": - """Create a deep copy of hardware status.""" - return deepcopy(self) diff --git a/custom_components/span_panel/span_panel_storage_battery.py b/custom_components/span_panel/span_panel_storage_battery.py deleted file mode 100644 index 2edbe3f5..00000000 --- a/custom_components/span_panel/span_panel_storage_battery.py +++ /dev/null @@ -1,18 +0,0 @@ -"""span_panel_storage_battery.""" - -from dataclasses import dataclass, field -from typing import Any - - -@dataclass -class SpanPanelStorageBattery: - """Class to manage the storage battery data.""" - - storage_battery_percentage: int - # Any nested mutable structures should use field with default_factory - raw_data: dict[str, Any] = field(default_factory=dict) - - @staticmethod - def from_dict(data: dict[str, Any]) -> "SpanPanelStorageBattery": - """Read the data from the dictionary.""" - return SpanPanelStorageBattery(storage_battery_percentage=data.get("percentage", 0)) diff --git a/custom_components/span_panel/strings.json b/custom_components/span_panel/strings.json index edb45254..e374babf 100644 --- a/custom_components/span_panel/strings.json +++ b/custom_components/span_panel/strings.json @@ -1,179 +1,697 @@ { "config": { "abort": { - "no_devices_found": "No devices found on the network", "already_configured": "Span Panel already configured. Only a single configuration is possible.", - "reauth_successful": "Authentication successful." + "cannot_connect": "Failed to connect to Span Panel", + "no_devices_found": "No devices found on the network", + "no_host": "No host address was provided by the add-on.", + "no_serial": "Could not determine the panel serial number.", + "reauth_successful": "Authentication successful.", + "reconfigure_successful": "Host updated successfully.", + "unique_id_mismatch": "The panel at this address has a different serial number. You cannot switch to a different panel during reconfiguration.", + "v1_not_supported": "v1 firmware is not supported. Please upgrade your panel firmware to v2." }, "error": { "cannot_connect": "Failed to connect to Span Panel", - "host_required": "Host is required for non-simulator mode", + "fqdn_registration_failed": "Could not register the domain name with the panel or the TLS certificate was not updated in time.", + "host_required": "Host is required", "invalid_auth": "Invalid authentication", + "proximity_failed": "Proximity not proven. Please open and close the panel door 3 times and try again.", "unknown": "Unexpected error" }, "flow_title": "Span Panel ({host})", + "progress": { + "registering_fqdn": "Registering domain name with the panel and waiting for TLS certificate update. This may take up to 60 seconds..." + }, "step": { + "auth_passphrase": { + "data": { + "hop_passphrase": "Passphrase" + }, + "data_description": { + "hop_passphrase": "The passphrase used to authenticate with the SPAN Panel v2 API" + }, + "description": "Enter the passphrase for your SPAN Panel. This is printed on the panel label or set by the panel owner.", + "title": "Panel Passphrase" + }, + "auth_proximity": { + "description": "Open and close the SPAN Panel door 3 times, then select the confirmation below.", + "menu_options": { + "auth_passphrase": "Use passphrase instead", + "auth_proximity_confirm": "I have opened and closed the door" + }, + "title": "Proximity Authentication" + }, + "auth_proximity_confirm": { + "description": "Verifying proximity with the panel.", + "title": "Proximity Authentication" + }, + "choose_entity_naming_initial": { + "data": { + "entity_naming_pattern": "Naming pattern" + }, + "data_description": { + "entity_naming_pattern": "Friendly names use circuit labels from the panel. Tab-based names use breaker tab numbers for stable entity IDs." + }, + "description": "Choose how circuit-related entities are named in Home Assistant.", + "title": "Circuit entity naming" + }, + "choose_v2_auth": { + "menu_options": { + "auth_passphrase": "Enter Panel Passphrase", + "auth_proximity": "Proof of Proximity (open/close door)" + }, + "title": "Choose Authentication Method" + }, "confirm_discovery": { "description": "Do you want to setup Span Panel at {host}?", "title": "Connect to the Span Panel" }, + "fqdn_failed": { + "description": "The domain name could not be registered with the panel, or the TLS certificate was not updated in time. You may continue, but the MQTT connection may fail if the panel's certificate does not include your domain name.", + "title": "Domain Registration" + }, + "reauth_confirm": { + "description": "The Span Panel at {host} requires re-authentication.", + "menu_options": { + "auth_passphrase": "Enter Panel Passphrase", + "auth_proximity": "Proof of Proximity (open/close door)" + }, + "title": "Re-authenticate Span Panel" + }, + "reconfigure": { + "data": { + "host": "Host" + }, + "data_description": { + "host": "IP address or hostname of the SPAN Panel" + }, + "description": "Update the host address for this SPAN Panel. The panel serial number must match.", + "title": "Reconfigure SPAN Panel" + }, + "reconfigure_fqdn_done": { + "title": "Reconfiguration Complete" + }, + "reconfigure_fqdn_failed": { + "description": "The domain name could not be registered with the panel, or the TLS certificate was not updated in time. You may continue, but the MQTT connection may fail if the panel's certificate does not include your domain name.", + "title": "Domain Registration" + }, + "reconfigure_register_fqdn": { + "title": "Registering Domain Name" + }, + "register_fqdn": { + "title": "Registering Domain Name" + }, "user": { "data": { + "enable_energy_dip_compensation": "Auto-Compensate Energy Dips", + "energy_display_precision": "Energy Display Precision", "host": "Host", - "use_ssl": "Use SSL", - "simulator_mode": "Simulator Mode", - "power_display_precision": "Power Display Precision", - "energy_display_precision": "Energy Display Precision" + "http_port": "HTTP Port", + "power_display_precision": "Power Display Precision" }, "data_description": { - "host": "IP address or hostname of SPAN Panel (required for real hardware, optional for simulation mode)", - "use_ssl": "Enable secure HTTPS connection to the SPAN Panel", - "simulator_mode": "Enable simulator mode for testing (no hardware required). Host field becomes optional when simulation mode is enabled.", - "power_display_precision": "Number of decimal places for power values (0-3)", - "energy_display_precision": "Number of decimal places for energy values (0-3)" + "enable_energy_dip_compensation": "Automatically compensate when the panel reports lower energy readings, preventing spikes in the energy dashboard.", + "energy_display_precision": "Number of decimal places for energy values (0-3)", + "host": "IP address or hostname of SPAN Panel", + "http_port": "HTTP port for the panel API (default: 80)", + "power_display_precision": "Number of decimal places for power values (0-3)" }, - "description": "Enter the connection details for your SPAN Panel. For simulation mode, check the simulator checkbox and the host field becomes optional.", + "description": "Enter the connection details for your SPAN Panel.", "title": "Connect to the Span Panel" + } + } + }, + "entity": { + "binary_sensor": { + "bess_connected": { + "name": "BESS Connected" }, - "choose_auth_type": { - "title": "Choose Authentication Options", - "menu_options": { - "auth_proximity": "Authenticate through your physical Span Panel", - "auth_token": "Authenticate using an access token" + "door_state": { + "name": "Door State" + }, + "ethernet_link": { + "name": "Ethernet Link" + }, + "evse_charging": { + "name": "Charging" + }, + "evse_ev_connected": { + "name": "EV Connected" + }, + "grid_islandable": { + "name": "Grid Islandable" + }, + "panel_status": { + "name": "Panel Status" + }, + "wifi_link": { + "name": "Wi-Fi Link" + } + }, + "button": { + "gfe_override": { + "name": "GFE Override: Grid Connected" + } + }, + "select": { + "circuit_priority": { + "name": "Circuit Priority", + "state": { + "never": "Stays on in an outage", + "off_grid": "Turns off in an outage", + "soc_threshold": "Stays on until battery threshold" } + } + }, + "sensor": { + "battery_level": { + "name": "Battery Level" }, - "auth_proximity": { - "title": "Proximity Authentication", - "description": "Please open and close the Span Panel door 3 times." + "battery_power": { + "name": "Battery Power" }, - "auth_token": { - "title": "Manual Token Authentication", - "description": "Please enter your access token (empty to start over):", - "data": { - "access_token": "Access Token" + "bess_firmware_version": { + "name": "Firmware Version" + }, + "bess_model": { + "name": "Model" + }, + "bess_nameplate_capacity": { + "name": "Nameplate Capacity" + }, + "bess_serial_number": { + "name": "Serial Number" + }, + "bess_soe_kwh": { + "name": "State of Energy" + }, + "bess_vendor": { + "name": "Vendor" + }, + "current_run_config": { + "name": "Current Run Config", + "state": { + "panel_backup": "Backup", + "panel_off_grid": "Off Grid", + "panel_on_grid": "On Grid", + "unknown": "Unknown" } }, - "simulator_config": { - "title": "Simulator Configuration", - "description": "Select a simulation configuration and optionally set a custom start time. {config_count} configurations available.", - "data": { - "simulation_config": "Simulation Configuration", - "host": "Host (Serial Number)", - "simulation_start_time": "Simulation Start Time (Optional)", - "entity_naming_pattern": "Entity Naming Pattern" - }, - "data_description": { - "simulation_config": "Choose a simulation configuration that defines circuit templates and energy profiles", - "host": "Custom serial number for the simulated panel (optional, will use config default if empty)", - "simulation_start_time": "Time format: HH:MM (24h), H:MM (12h), or full ISO datetime (YYYY-MM-DDTHH:MM:SS). Leave empty to use current time.", - "entity_naming_pattern": "Choose how circuit entities are named in the simulation" + "downstream_l1_current": { + "name": "Downstream L1 Current" + }, + "downstream_l2_current": { + "name": "Downstream L2 Current" + }, + "dsm_grid_state": { + "name": "DSM Grid State", + "state": { + "dsm_off_grid": "Off Grid", + "dsm_on_grid": "On Grid", + "unknown": "Unknown" + } + }, + "dsm_state": { + "name": "DSM State", + "state": { + "dsm_off_grid": "Off Grid", + "dsm_on_grid": "On Grid", + "unknown": "Unknown" + } + }, + "evse_advertised_current": { + "name": "Advertised Current" + }, + "evse_lock_state": { + "name": "Lock State", + "state": { + "locked": "Locked", + "unknown": "Unknown", + "unlocked": "Unlocked" + } + }, + "evse_status": { + "name": "Charger Status", + "state": { + "available": "Available", + "charging": "Charging", + "faulted": "Faulted", + "finishing": "Finishing", + "preparing": "Preparing", + "reserved": "Reserved", + "suspended_ev": "Suspended by Vehicle", + "suspended_evse": "Suspended by Charger", + "unavailable": "Unavailable", + "unknown": "Unknown" + } + }, + "feedthrough_consumed_energy": { + "name": "Feed Through Consumed Energy" + }, + "feedthrough_net_energy": { + "name": "Feed Through Net Energy" + }, + "feedthrough_power": { + "name": "Feed Through Power" + }, + "feedthrough_produced_energy": { + "name": "Feed Through Produced Energy" + }, + "grid_forming_entity": { + "name": "Grid Forming Entity", + "state": { + "battery": "Battery", + "generator": "Generator", + "grid": "Grid", + "none": "None", + "pv": "PV", + "unknown": "Unknown" + } + }, + "grid_power_flow": { + "name": "Grid Power" + }, + "instant_grid_power": { + "name": "Current Power" + }, + "l1_voltage": { + "name": "L1 Voltage" + }, + "l2_voltage": { + "name": "L2 Voltage" + }, + "main_breaker_rating": { + "name": "Main Breaker Rating" + }, + "main_meter_consumed_energy": { + "name": "Main Meter Consumed Energy" + }, + "main_meter_net_energy": { + "name": "Main Meter Net Energy" + }, + "main_meter_produced_energy": { + "name": "Main Meter Produced Energy" + }, + "main_relay_state": { + "name": "Main Relay State", + "state": { + "closed": "Closed", + "open": "Open", + "unknown": "Unknown" + } + }, + "pv_nameplate_capacity": { + "name": "PV Nameplate Capacity" + }, + "pv_power": { + "name": "PV Power" + }, + "pv_product": { + "name": "PV Product" + }, + "pv_vendor": { + "name": "PV Vendor" + }, + "site_power": { + "name": "Site Power" + }, + "software_version": { + "name": "Software Version" + }, + "upstream_l1_current": { + "name": "Upstream L1 Current" + }, + "upstream_l2_current": { + "name": "Upstream L2 Current" + }, + "vendor_cloud": { + "name": "Vendor Cloud", + "state": { + "connected": "Connected", + "unconnected": "Disconnected", + "unknown": "Unknown" } } } }, - "options": { - "error": { - "Both solar legs must be selected. Single leg configuration is not supported for proper 240V measurement.": "Both solar legs must be selected. Single leg configuration is not supported for proper 240V measurement.", - "Solar legs cannot use the same tab ({tab}). Two different tabs are required for 240V measurement.": "Solar legs cannot use the same tab. Two different tabs are required for 240V measurement.", - "Tab {tab} is not available or is already mapped to a circuit.": "Selected tab is not available or is already mapped to a circuit.", - "Solar tabs {tab1} and {tab2} are both on {phase}. For proper 240V measurement, tabs must be on opposite phases.": "Selected solar tabs are both on the same electrical phase. For proper 240V measurement, tabs must be on opposite phases (L1 and L2).", - "Invalid date/time format. Use ISO format (YYYY-MM-DDTHH:MM:SS)": "Invalid time format. Use HH:MM (24h), H:MM (12h), or full ISO datetime (YYYY-MM-DDTHH:MM:SS)", - "directory": "Directory path is required", - "base": "Export failed: {error}" + "exceptions": { + "circuit_priority_failed": { + "message": "Failed to set circuit priority for {circuit}." + }, + "circuit_relay_failed": { + "message": "Failed to set circuit relay state for {circuit}." + }, + "export_manifest_no_entries": { + "message": "No SPAN panel configuration entries are loaded. Add and configure a SPAN panel before calling this service." + }, + "favorite_not_span_entity": { + "message": "Entity {entity_id} is not a SPAN Panel entity. Pick a circuit, BESS, or EVSE sensor." + }, + "favorite_no_device": { + "message": "Entity {entity_id} is not attached to a device. Pick a SPAN circuit or sub-device sensor." + }, + "favorite_subdevice_no_span_parent": { + "message": "Sub-device {entity_id} has no SPAN Panel parent device — its via_device_id does not point at a SPAN panel." + }, + "favorite_no_unique_id": { + "message": "Entity {entity_id} has no unique id and cannot be resolved to a SPAN target." + }, + "favorite_no_circuit_uuid": { + "message": "Could not derive a favorite target from entity {entity_id}. Pick a circuit sensor (current/power) or a sub-device sensor." }, + "gfe_override_failed": { + "message": "Failed to send GFE override to the panel." + }, + "graph_horizon_not_available": { + "message": "Graph horizon manager is not available for the selected SPAN panel." + }, + "monitoring_not_enabled": { + "message": "No SPAN panel with current monitoring enabled." + }, + "panel_auth_failed": { + "message": "Authentication with the SPAN Panel failed. Please reauthenticate." + }, + "panel_connection_failed": { + "message": "Failed to connect to the SPAN Panel at {host}." + }, + "panel_not_ready": { + "message": "The SPAN Panel is not ready. Please check that the panel is online." + } + }, + "options": { "step": { "init": { - "title": "Options Menu", - "menu_options": { - "general_options": "General Options", - "entity_naming_options": "Entity Naming Options", - "entity_naming": "Entity Naming Pattern", - "export_config": "Export Synthetic Sensor Config", - "simulation_start_time": "Simulation Start Time", - "simulation_offline_minutes": "Simulation Offline Minutes" - } - }, - "general_options": { - "title": "General Options", - "description": "Configure SPAN Panel integration settings. For solar configuration, select two unmapped tabs that are on opposite electrical phases (L1 and L2) to ensure proper 240V measurement.", "data": { - "scan_interval": "Scan interval in seconds", - "enable_battery_percentage": "Enable Battery Percentage Sensor", - "enable_solar_circuit": "Enable Solar Inverter Sensors", - "leg1": "Solar Leg 1 (must be unmapped tab on opposite phase from Leg 2)", - "leg2": "Solar Leg 2 (must be unmapped tab on opposite phase from Leg 1)", - "enable_panel_net_energy_sensors": "Panel Net Energy", + "show_panel": "Show Dashboard in Sidebar", + "panel_admin_only": "Admin Users Only", "enable_circuit_net_energy_sensors": "Circuit Net Energy", - "enable_solar_net_energy_sensors": "Solar Net Energy", - "api_retries": "API Retries (0-10)", - "api_retry_timeout": "API Retry Timeout (seconds)", - "api_retry_backoff_multiplier": "API Retry Backoff Multiplier" + "enable_energy_dip_compensation": "Auto-Compensate Energy Dips", + "enable_panel_net_energy_sensors": "Panel Net Energy", + "enable_unmapped_circuit_sensors": "Unmapped Circuit Sensors", + "energy_reporting_grace_period": "Energy Sensor Grace Period (minutes)", + "snapshot_update_interval": "Snapshot Update Interval (seconds)" }, "data_description": { - "enable_solar_circuit": "Enable synthetic sensors for solar inverter monitoring. Requires selection of two unmapped tabs on opposite electrical phases.", - "leg1": "Select the first solar leg from available unmapped tabs. This tab must be on the opposite electrical phase from Leg 2 for proper 240V measurement.", - "leg2": "Select the second solar leg from available unmapped tabs. This tab must be on the opposite electrical phase from Leg 1 for proper 240V measurement.", - "enable_panel_net_energy_sensors": "Create net energy sensors for panel-level energy flows (main meter and feed-through). Shows true energy balance accounting for bidirectional flows.", - "enable_circuit_net_energy_sensors": "Create net energy sensors for individual circuits. Shows true energy consumption accounting for reactive power and regenerative flows.", - "enable_solar_net_energy_sensors": "Create net energy sensors for solar generation (only available when solar is configured). Shows net solar production accounting for any consumption on solar circuits." + "show_panel": "Display the SPAN Panel dashboard as a sidebar entry.", + "panel_admin_only": "Restrict the dashboard sidebar entry to admin users.", + "enable_circuit_net_energy_sensors": "Net energy per circuit, accounting for reactive power.", + "enable_energy_dip_compensation": "Compensate when panel reports lower energy readings. Disabling clears offsets.", + "enable_panel_net_energy_sensors": "Net energy for main meter and feed-through, accounting for bidirectional flows.", + "enable_unmapped_circuit_sensors": "Backing sensors for tabs not assigned to named breakers.", + "energy_reporting_grace_period": "Minutes sensors hold last value when panel is offline (0\u201360). Default: 15.", + "snapshot_update_interval": "Seconds between snapshot rebuilds (0\u201315). Lower = faster, more CPU. 0 = no debounce." + }, + "description": "Configure SPAN Panel integration settings.", + "title": "SPAN Panel Options" + } + } + }, + "selector": { + "entity_naming_pattern": { + "options": { + "circuit_numbers": "Circuit Numbers (e.g., span_panel_circuit_15_power)", + "friendly_names": "Friendly Names (e.g., span_panel_kitchen_outlets_power)" + } + } + }, + "services": { + "export_circuit_manifest": { + "description": "Returns a structured circuit manifest for all configured SPAN panels: serial, host, and per-circuit power sensor entity IDs, layout template identifiers (clone_{{tab}}), device types, and breaker tab numbers. Intended for dashboard cards and automations that need panel topology without querying the entity registry. This is a response-only service; the response may list zero panels if nothing can be resolved.", + "name": "Export circuit manifest" + }, + "set_circuit_threshold": { + "name": "Set circuit threshold", + "description": "Set current monitoring thresholds for a specific circuit.", + "fields": { + "circuit_id": { + "name": "Circuit", + "description": "The circuit power sensor entity." + }, + "continuous_threshold_pct": { + "name": "Continuous threshold (%)", + "description": "Percentage of breaker rating for continuous load alerts." + }, + "spike_threshold_pct": { + "name": "Spike threshold (%)", + "description": "Percentage of breaker rating for instantaneous spike alerts." + }, + "window_duration_m": { + "name": "Window duration (minutes)", + "description": "How long current must stay above threshold before alerting." + }, + "cooldown_duration_m": { + "name": "Cooldown duration (minutes)", + "description": "Minutes between repeated alerts for the same circuit." + }, + "monitoring_enabled": { + "name": "Monitoring enabled", + "description": "Enable or disable monitoring for this circuit." + }, + "config_entry_id": { + "name": "Config entry", + "description": "Optional config entry id. Only needed when more than one SPAN panel is configured." } - }, - "entity_naming_options": { - "title": "Entity Naming Options", - "description": "Configure how entity IDs are named. Choose between friendly names (e.g., kitchen_outlets_power) or circuit tab numbers (e.g., circuit_15_power).", - "data": { - "legacy_upgrade_to_friendly": "Upgrade Legacy Installation to Friendly Names", - "entity_naming_pattern": "Entity Naming Pattern" + } + }, + "clear_circuit_threshold": { + "name": "Clear circuit threshold", + "description": "Remove per-circuit threshold overrides, reverting to global defaults.", + "fields": { + "circuit_id": { + "name": "Circuit", + "description": "The circuit power sensor entity." }, - "data_description": { - "legacy_upgrade_to_friendly": "Upgrade this legacy installation to use friendly entity names with device prefix. This will rename all existing entities.", - "entity_naming_pattern": "Choose how entity IDs are constructed. Friendly names use circuit names (e.g., kitchen_outlets_power), while circuit numbers use tab positions (e.g., circuit_15_power)." + "config_entry_id": { + "name": "Config entry", + "description": "Optional config entry id. Only needed when more than one SPAN panel is configured." } - }, - "simulation_start_time": { - "title": "Simulation Start Time", - "description": "Configure the simulation start time. Changes to simulation time will reload the integration to apply the new time.", - "data": { - "simulation_start_time": "Simulation Start Time" + } + }, + "set_mains_threshold": { + "name": "Set mains threshold", + "description": "Set current monitoring thresholds for a specific mains leg.", + "fields": { + "leg": { + "name": "Mains leg", + "description": "The mains current sensor entity." }, - "data_description": { - "simulation_start_time": "Time format: HH:MM (24h), H:MM (12h), or full ISO datetime (YYYY-MM-DDTHH:MM:SS). Leave empty to use current time. Integration will reload to apply changes." + "continuous_threshold_pct": { + "name": "Continuous threshold (%)", + "description": "Percentage of breaker rating for continuous load alerts." + }, + "spike_threshold_pct": { + "name": "Spike threshold (%)", + "description": "Percentage of breaker rating for instantaneous spike alerts." + }, + "window_duration_m": { + "name": "Window duration (minutes)", + "description": "How long current must stay above threshold before alerting." + }, + "cooldown_duration_m": { + "name": "Cooldown duration (minutes)", + "description": "Minutes between repeated alerts for the same mains leg." + }, + "monitoring_enabled": { + "name": "Monitoring enabled", + "description": "Enable or disable monitoring for this mains leg." + }, + "config_entry_id": { + "name": "Config entry", + "description": "Optional config entry id. Only needed when more than one SPAN panel is configured." } - }, - "simulation_offline_minutes": { - "title": "Simulation Offline Minutes", - "description": "Configure how long the panel should appear offline during simulation.", - "data": { - "simulation_offline_minutes": "Offline Minutes" + } + }, + "clear_mains_threshold": { + "name": "Clear mains threshold", + "description": "Remove per-mains-leg threshold overrides, reverting to global defaults.", + "fields": { + "leg": { + "name": "Mains leg", + "description": "The mains current sensor entity." }, - "data_description": { - "simulation_offline_minutes": "Number of minutes the panel should appear offline during simulation. Set to 0 to disable offline simulation." + "config_entry_id": { + "name": "Config entry", + "description": "Optional config entry id. Only needed when more than one SPAN panel is configured." } - }, - "export_config": { - "title": "Export Synthetic Sensor Config", - "description": "Export the current synthetic sensor configuration to a YAML file. You can specify either a directory (file will be named automatically) or a full file path. Default filename: {filename}", - "data": { - "directory": "File Path" + } + }, + "get_monitoring_status": { + "name": "Get monitoring status", + "description": "Returns current monitoring state for all tracked circuits and mains legs.", + "fields": { + "config_entry_id": { + "name": "Config entry", + "description": "Optional config entry id. Only needed when more than one SPAN panel is configured." + } + } + }, + "set_global_monitoring": { + "name": "Set Global Monitoring", + "description": "Update global monitoring thresholds and notification settings.", + "fields": { + "enabled": { + "name": "Enabled", + "description": "Enable or disable current monitoring globally for this SPAN panel." }, - "data_description": { - "directory": "Either a directory path (file will be named automatically) or a full file path ending in .yaml where the configuration will be saved." + "continuous_threshold_pct": { + "name": "Continuous Threshold", + "description": "Percentage of breaker rating for continuous overload detection." + }, + "spike_threshold_pct": { + "name": "Spike Threshold", + "description": "Percentage of breaker rating for spike detection." + }, + "window_duration_m": { + "name": "Window Duration", + "description": "Minutes of sustained overload before alerting." + }, + "cooldown_duration_m": { + "name": "Cooldown Duration", + "description": "Minutes between repeated alerts for the same point." + }, + "notify_targets": { + "name": "Notification Targets", + "description": "Comma-separated list of notify service targets." + }, + "notification_title_template": { + "name": "Notification Title Template", + "description": "Template for notification title. Placeholders: {name}, {entity_id}, {alert_type}, {current_a}, {breaker_rating_a}, {threshold_pct}, {utilization_pct}, {window_m}." + }, + "notification_message_template": { + "name": "Notification Message Template", + "description": "Template for notification message body. Placeholders: {name}, {entity_id}, {alert_type}, {current_a}, {breaker_rating_a}, {threshold_pct}, {utilization_pct}, {window_m}." + }, + "notification_priority": { + "name": "Notification Priority", + "description": "Push notification priority level. Controls interruption behavior on mobile devices (iOS: interruption-level, Android: priority/channel)." + }, + "config_entry_id": { + "name": "Config entry", + "description": "Optional config entry id. Only needed when more than one SPAN panel is configured." } - }, - "entity_naming": { - "title": "Entity Naming Pattern", - "description": "Choose how circuit entities are named. **Changing this will rename your existing entities.**\n\n{friendly_example}\n\n{circuit_example}\n\n⚠️ **Important**: \n• Entity history will be preserved during renaming\n• Consider backing up your configuration before proceeding\n• Automations and scripts may need manual updates to use new entity IDs", - "data": { - "entity_naming_pattern": "Entity Naming Pattern" + } + }, + "test_notification": { + "name": "Test Notification", + "description": "Send a test notification to all configured targets using sample values.", + "fields": { + "config_entry_id": { + "name": "Config entry", + "description": "Optional config entry id. Only needed when more than one SPAN panel is configured." } } - } - }, - "selector": { - "entity_naming_pattern": { - "options": { - "friendly_names": "Friendly Names (e.g., span_panel_kitchen_outlets_power)", - "circuit_numbers": "Circuit Numbers (e.g., span_panel_circuit_15_power)" + }, + "set_graph_time_horizon": { + "name": "Set graph time horizon", + "description": "Set the global default time horizon for circuit graphs.", + "fields": { + "horizon": { + "name": "Horizon", + "description": "Time window preset for graph display." + }, + "config_entry_id": { + "name": "Config entry", + "description": "Optional config entry id. Only needed when more than one SPAN panel is configured." + } + } + }, + "set_circuit_graph_horizon": { + "name": "Set circuit graph horizon", + "description": "Set a per-circuit time horizon override for graphs.", + "fields": { + "circuit_id": { + "name": "Circuit ID", + "description": "The circuit identifier to override." + }, + "horizon": { + "name": "Horizon", + "description": "Time window preset for graph display." + }, + "config_entry_id": { + "name": "Config entry", + "description": "Optional config entry id. Only needed when more than one SPAN panel is configured." + } + } + }, + "clear_circuit_graph_horizon": { + "name": "Clear circuit graph horizon", + "description": "Remove a per-circuit graph horizon override, reverting to the global default.", + "fields": { + "circuit_id": { + "name": "Circuit ID", + "description": "The circuit identifier to clear the override for." + }, + "config_entry_id": { + "name": "Config entry", + "description": "Optional config entry id. Only needed when more than one SPAN panel is configured." + } + } + }, + "set_subdevice_graph_horizon": { + "name": "Set sub-device graph horizon", + "description": "Set a per-sub-device time horizon override for graphs.", + "fields": { + "subdevice_id": { + "name": "Sub-device ID", + "description": "The sub-device identifier to override." + }, + "horizon": { + "name": "Horizon", + "description": "Time window preset for graph display." + }, + "config_entry_id": { + "name": "Config entry", + "description": "Optional config entry id. Only needed when more than one SPAN panel is configured." + } + } + }, + "clear_subdevice_graph_horizon": { + "name": "Clear sub-device graph horizon", + "description": "Remove a per-sub-device graph horizon override, reverting to the global default.", + "fields": { + "subdevice_id": { + "name": "Sub-device ID", + "description": "The sub-device identifier to clear the override for." + }, + "config_entry_id": { + "name": "Config entry", + "description": "Optional config entry id. Only needed when more than one SPAN panel is configured." + } + } + }, + "get_graph_settings": { + "name": "Get graph settings", + "description": "Returns the current global and per-circuit graph horizon settings.", + "fields": { + "config_entry_id": { + "name": "Config entry", + "description": "Optional config entry id. Only needed when more than one SPAN panel is configured." + } + } + }, + "get_favorites": { + "name": "Get favorites", + "description": "Returns the saved cross-panel favorites map keyed by SPAN panel device id." + }, + "add_favorite": { + "name": "Add favorite", + "description": "Mark a SPAN circuit, battery (BESS), or EV charger (EVSE) as a favorite so it appears in the dashboard's Favorites view.", + "fields": { + "entity_id": { + "name": "Entity", + "description": "Any sensor of the circuit or sub-device to favorite (current, power, SoC, etc.)." + } + } + }, + "remove_favorite": { + "name": "Remove favorite", + "description": "Remove a circuit or sub-device from the cross-panel Favorites view.", + "fields": { + "entity_id": { + "name": "Entity", + "description": "Any sensor of the circuit or sub-device to remove from favorites." + } } } } diff --git a/custom_components/span_panel/switch.py b/custom_components/span_panel/switch.py index 585a6875..88427934 100644 --- a/custom_components/span_panel/switch.py +++ b/custom_components/span_panel/switch.py @@ -1,55 +1,67 @@ """Control switches.""" +from collections.abc import Mapping import logging -from typing import Any, Literal +from typing import Any from homeassistant.components.switch import SwitchEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from span_panel_api import SpanCircuitSnapshot, SpanPanelSnapshot -from custom_components.span_panel.span_panel_circuit import SpanPanelCircuit - -from .const import ( - COORDINATOR, - DOMAIN, - USE_CIRCUIT_NUMBERS, - CircuitRelayState, -) +from . import SpanPanelConfigEntry +from .const import DOMAIN, USE_CIRCUIT_NUMBERS, CircuitRelayState from .coordinator import SpanPanelCoordinator +from .entity import SpanPanelEntity from .helpers import ( build_switch_unique_id_for_entry, + construct_circuit_identifier_from_tabs, + construct_single_circuit_entity_id, + construct_tabs_attribute, + construct_voltage_attribute, ) -from .span_panel import SpanPanel -from .util import panel_to_device_info - -ICON: Literal["mdi:toggle-switch"] = "mdi:toggle-switch" _LOGGER: logging.Logger = logging.getLogger(__name__) +PARALLEL_UPDATES = 1 -class SpanPanelCircuitsSwitch(CoordinatorEntity[SpanPanelCoordinator], SwitchEntity): - """Represent a switch entity.""" +# Sentinel value to distinguish "never synced" from "circuit name is None" +_NAME_UNSET: object = object() - _attr_has_entity_name = True +# Device types that use "Solar" as the fallback identifier when unnamed. +_SOLAR_DEVICE_TYPES: frozenset[str] = frozenset({"pv"}) + + +def _unnamed_switch_fallback(circuit: SpanCircuitSnapshot, circuit_id: str) -> str: + """Return a descriptive identifier for an unnamed circuit switch.""" + if getattr(circuit, "device_type", "circuit") in _SOLAR_DEVICE_TYPES: + return "Solar" + return construct_circuit_identifier_from_tabs(circuit.tabs, circuit_id) + + +class SpanPanelCircuitsSwitch(SpanPanelEntity, SwitchEntity): + """Represent a switch entity.""" def __init__( - self, coordinator: SpanPanelCoordinator, circuit_id: str, name: str, device_name: str + self, + coordinator: SpanPanelCoordinator, + circuit_id: str, + name: str, + device_name: str, ) -> None: """Initialize the values.""" - span_panel: SpanPanel = coordinator.data + snapshot: SpanPanelSnapshot = coordinator.data - circuit = span_panel.circuits.get(circuit_id) + circuit = snapshot.circuits.get(circuit_id) if not circuit: raise ValueError(f"Circuit {circuit_id} not found") self._circuit_id: str = circuit_id self._device_name = device_name - self._attr_icon = "mdi:toggle-switch" - self._attr_unique_id = self._construct_switch_unique_id(coordinator, span_panel, circuit_id) - self._attr_device_info = panel_to_device_info(span_panel, device_name) + self._attr_unique_id = self._construct_switch_unique_id(coordinator, snapshot, circuit_id) + + self._attr_device_info = self._build_device_info(coordinator, snapshot) # Check if entity already exists in registry entity_registry = er.async_get(coordinator.hass) @@ -57,89 +69,144 @@ def __init__( "switch", DOMAIN, self._attr_unique_id ) - if existing_entity_id: - # Entity exists - always use panel name for sync - circuit_identifier = circuit.name - self._attr_name = f"{circuit_identifier} Breaker" - else: - # Initial install - use flag-based name for entity_id generation - use_circuit_numbers = coordinator.config_entry.options.get(USE_CIRCUIT_NUMBERS, False) + use_circuit_numbers = coordinator.config_entry.options.get(USE_CIRCUIT_NUMBERS, False) + if existing_entity_id: + # Entity exists - use circuit-based name when configured, else panel name if use_circuit_numbers: - # Use circuit number format: "Circuit 15 Breaker" - if circuit.tabs and len(circuit.tabs) == 2: - sorted_tabs = sorted(circuit.tabs) - circuit_identifier = f"Circuit {sorted_tabs[0]} {sorted_tabs[1]}" - elif circuit.tabs and len(circuit.tabs) == 1: - circuit_identifier = f"Circuit {circuit.tabs[0]}" - else: - circuit_identifier = f"Circuit {circuit_id}" + circuit_identifier = construct_circuit_identifier_from_tabs( + circuit.tabs, circuit_id + ) + self._attr_name = f"{circuit_identifier} Breaker" + elif circuit.name: + self._attr_name = f"{circuit.name} Breaker" else: - # Use friendly name format: "Kitchen Outlets Breaker" - circuit_identifier = name + fallback = _unnamed_switch_fallback(circuit, circuit_id) + self._attr_name = f"{fallback} Breaker" + + # Sync the panel friendly name to the entity registry display name + # so the UI shows e.g. "Air Conditioner Breaker" while the entity_id + # stays circuit-based (e.g. switch.span_panel_circuit_15_breaker). + if existing_entity_id and use_circuit_numbers and circuit.name: + entity_entry = entity_registry.async_get(existing_entity_id) + if entity_entry: + expected_name = f"{circuit.name} Breaker" + if entity_entry.name is None or entity_entry.name == expected_name: + entity_registry.async_update_entity(existing_entity_id, name=expected_name) - self._attr_name = f"{circuit_identifier} Breaker" + if not existing_entity_id: + # Initial install - use flag-based name for entity_id generation + if use_circuit_numbers: + circuit_identifier = construct_circuit_identifier_from_tabs( + circuit.tabs, circuit_id + ) + self._attr_name = f"{circuit_identifier} Breaker" + elif name: + self._attr_name = f"{name} Breaker" + else: + # v1 behavior: None lets HA handle default naming + self._attr_name = None super().__init__(coordinator) - self._update_is_on() + # Explicitly set entity_id using construct_single_circuit_entity_id + # which correctly handles 240V two-tab circuits. + # Only pass unique_id for existing entities (registry lookup); + # for new entities pass None to get the constructed default. + constructed_id = construct_single_circuit_entity_id( + coordinator, + snapshot, + "switch", + "breaker", + circuit, + unique_id=self._attr_unique_id if existing_entity_id else None, + ) + if constructed_id: + self.entity_id = constructed_id - # Use standard coordinator pattern - entities will update automatically - # when coordinator data changes + self._update_is_on() # Store initial circuit name for change detection in auto-sync - # Only set to None if entity doesn't exist in registry (true first time) if not existing_entity_id: - self._previous_circuit_name = None + self._previous_circuit_name: str | None | object = _NAME_UNSET _LOGGER.info("Switch entity not in registry, will sync on first update") else: self._previous_circuit_name = circuit.name _LOGGER.info( - "Switch entity exists in registry, previous name set to '%s'", circuit.name + "Switch entity exists in registry, previous name set to '%s'", + circuit.name, ) async def async_will_remove_from_hass(self) -> None: """Clean up when entity is removed.""" - # Call parent cleanup await super().async_will_remove_from_hass() def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" - # Check for circuit name changes - span_panel: SpanPanel = self.coordinator.data - circuit = span_panel.circuits.get(self._circuit_id) + snapshot: SpanPanelSnapshot = self.coordinator.data + circuit = snapshot.circuits.get(self._circuit_id) if circuit: current_circuit_name = circuit.name + use_circuit_numbers = self.coordinator.config_entry.options.get( + USE_CIRCUIT_NUMBERS, False + ) - # Only request reload if the circuit name has actually changed - if self._previous_circuit_name is None: - # First update - sync to panel name - _LOGGER.info( - "First update: syncing entity name to panel name '%s' for switch, requesting reload", - current_circuit_name, - ) - # Update stored previous name for next comparison - self._previous_circuit_name = current_circuit_name - # Request integration reload to persist name change - self.coordinator.request_reload() - elif current_circuit_name != self._previous_circuit_name: - _LOGGER.info( - "Name change detected: previous='%s', current='%s' for switch", - self._previous_circuit_name, - current_circuit_name, - ) - _LOGGER.info( - "Auto-sync detected circuit name change from '%s' to '%s' for " - "switch, requesting integration reload", - self._previous_circuit_name, - current_circuit_name, - ) + if use_circuit_numbers: + # Circuit-numbers mode: update registry display name, no reload + if self.entity_id and current_circuit_name: + entity_registry = er.async_get(self.hass) + entity_entry = entity_registry.async_get(self.entity_id) + if entity_entry: + # Compute old expected display BEFORE updating + # _previous_circuit_name + old_display = ( + f"{self._previous_circuit_name} Breaker" + if isinstance(self._previous_circuit_name, str) + else None + ) + new_display = f"{current_circuit_name} Breaker" + + # User override: registry name differs from both old + # and new expected display names + user_has_override = ( + entity_entry.name is not None + and entity_entry.name not in {old_display, new_display} + ) + + if not user_has_override and ( + self._previous_circuit_name is _NAME_UNSET + or current_circuit_name != self._previous_circuit_name + ): + entity_registry.async_update_entity(self.entity_id, name=new_display) - # Update stored previous name for next comparison self._previous_circuit_name = current_circuit_name - - # Request integration reload for next update cycle - self.coordinator.request_reload() + else: + # Friendly-names mode: existing reload behavior + user_has_override = False + if self.entity_id: + entity_registry = er.async_get(self.hass) + entity_entry = entity_registry.async_get(self.entity_id) + if entity_entry and entity_entry.name: + user_has_override = True + + if user_has_override: + self._previous_circuit_name = current_circuit_name + elif self._previous_circuit_name is _NAME_UNSET: + _LOGGER.info( + "First update: syncing entity name to panel name '%s' for switch, requesting reload", + current_circuit_name, + ) + self._previous_circuit_name = current_circuit_name + self.coordinator.request_reload() + elif current_circuit_name != self._previous_circuit_name: + _LOGGER.info( + "Auto-sync detected circuit name change from '%s' to '%s' for " + "switch, requesting integration reload", + self._previous_circuit_name, + current_circuit_name, + ) + self._previous_circuit_name = current_circuit_name + self.coordinator.request_reload() self._update_is_on() super()._handle_coordinator_update() @@ -154,22 +221,58 @@ def available(self) -> bool: return False return super().available + @property + def extra_state_attributes(self) -> Mapping[str, Any] | None: + """Return panel position attributes for this circuit.""" + if not self.coordinator.data: + return None + + circuit = self.coordinator.data.circuits.get(self._circuit_id) + if not circuit: + return None + + attributes: dict[str, Any] = {} + + tabs_result = construct_tabs_attribute(circuit) + if tabs_result is not None: + attributes["tabs"] = tabs_result + + voltage = construct_voltage_attribute(circuit) or 240 + attributes["voltage"] = voltage + + if circuit.relay_state_target is not None: + attributes["relay_state_target"] = circuit.relay_state_target + + return attributes or None + def _update_is_on(self) -> None: - """Update the is_on state based on the circuit state.""" - span_panel: SpanPanel = self.coordinator.data - # Get atomic snapshot of circuits data - circuits: dict[str, SpanPanelCircuit] = span_panel.circuits - circuit: SpanPanelCircuit | None = circuits.get(self._circuit_id) - if circuit: - # Use copy to ensure atomic state - circuit = circuit.copy() - self._attr_is_on = circuit.relay_state == CircuitRelayState.CLOSED.name - else: + """Update the is_on state based on the circuit state. + + Uses the panel's ``relay_state_target`` (from Homie ``$target``) to + show the desired state while a relay command is pending. When the + target differs from the actual relay state, the panel has not yet + confirmed the command and we display the target to prevent UI bounce. + """ + snapshot: SpanPanelSnapshot = self.coordinator.data + circuit = snapshot.circuits.get(self._circuit_id) + if not circuit: self._attr_is_on = None + return + + actual_is_on = circuit.relay_state == CircuitRelayState.CLOSED.name + + # When the panel publishes a $target that differs from the confirmed + # relay state, a command is pending — show the target to avoid bounce. + if ( + circuit.relay_state_target is not None + and circuit.relay_state_target != circuit.relay_state + ): + self._attr_is_on = circuit.relay_state_target == CircuitRelayState.CLOSED.name + else: + self._attr_is_on = actual_is_on def turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" - self.hass.create_task(self.async_turn_on(**kwargs)) def turn_off(self, **kwargs: Any) -> None: @@ -178,74 +281,65 @@ def turn_off(self, **kwargs: Any) -> None: async def async_turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" - span_panel: SpanPanel = self.coordinator.data - circuits: dict[str, SpanPanelCircuit] = ( - span_panel.circuits - ) # Get atomic snapshot of circuits - if self._circuit_id in circuits: - # Create a copy of the circuit for the operation - curr_circuit: SpanPanelCircuit = circuits[self._circuit_id].copy() - # Perform the state change - await span_panel.api.set_relay(curr_circuit, CircuitRelayState.CLOSED) - # Optimistically update local state to prevent UI bouncing - self._attr_is_on = True - if self.hass is not None: - self.async_write_ha_state() - # Request refresh to get the actual new state from panel - await self.coordinator.async_request_refresh() + client = self.coordinator.client + if not hasattr(client, "set_circuit_relay"): + _LOGGER.warning("Client does not support relay control") + return + + await client.set_circuit_relay(self._circuit_id, "CLOSED") + self._attr_is_on = True + self.async_write_ha_state() async def async_turn_off(self, **kwargs: Any) -> None: """Turn the switch off.""" - span_panel: SpanPanel = self.coordinator.data - circuits: dict[str, SpanPanelCircuit] = ( - span_panel.circuits - ) # Get atomic snapshot of circuits - if self._circuit_id in circuits: - # Create a copy of the circuit for the operation - curr_circuit: SpanPanelCircuit = circuits[self._circuit_id].copy() - # Perform the state change - await span_panel.api.set_relay(curr_circuit, CircuitRelayState.OPEN) - # Optimistically update local state to prevent UI bouncing - self._attr_is_on = False - # Only write state if hass is available - if self.hass is not None: - self.async_write_ha_state() - # Request refresh to get the actual new state from panel - await self.coordinator.async_request_refresh() + client = self.coordinator.client + if not hasattr(client, "set_circuit_relay"): + _LOGGER.warning("Client does not support relay control") + return + + await client.set_circuit_relay(self._circuit_id, "OPEN") + self._attr_is_on = False + self.async_write_ha_state() def _construct_switch_unique_id( self, coordinator: SpanPanelCoordinator, - span_panel: SpanPanel, + snapshot: SpanPanelSnapshot, circuit_id: str, ) -> str: """Construct unique ID for switch entities.""" return build_switch_unique_id_for_entry( - coordinator, span_panel, circuit_id, self._device_name + coordinator, snapshot, circuit_id, self._device_name ) async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + config_entry: SpanPanelConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up sensor platform.""" - data: dict[str, Any] = hass.data[DOMAIN][config_entry.entry_id] - - coordinator: SpanPanelCoordinator = data[COORDINATOR] - span_panel: SpanPanel = coordinator.data + coordinator = config_entry.runtime_data.coordinator + snapshot: SpanPanelSnapshot = coordinator.data # Get device name from config entry data _device_name = config_entry.data.get("device_name", config_entry.title) entities: list[SpanPanelCircuitsSwitch] = [] - for circuit_id, circuit_data in span_panel.circuits.items(): - if circuit_data.is_user_controllable: - entities.append( - SpanPanelCircuitsSwitch(coordinator, circuit_id, circuit_data.name, _device_name) - ) + for circuit_id, circuit_data in snapshot.circuits.items(): + if not circuit_data.is_user_controllable: + continue + # PV/EVSE circuits only get switches if they have a physical breaker + # (relative_position == "DOWNSTREAM" means connected at a breaker slot) + if ( + circuit_data.device_type in ("pv", "evse") + and circuit_data.relative_position != "DOWNSTREAM" + ): + continue + entities.append( + SpanPanelCircuitsSwitch(coordinator, circuit_id, circuit_data.name, _device_name) + ) async_add_entities(entities) diff --git a/custom_components/span_panel/threshold_evaluator.py b/custom_components/span_panel/threshold_evaluator.py new file mode 100644 index 00000000..70c2509e --- /dev/null +++ b/custom_components/span_panel/threshold_evaluator.py @@ -0,0 +1,174 @@ +"""Pure threshold evaluation logic for current monitoring. + +Functions receive state as parameters and return results (AlertEvent or None) +instead of dispatching notifications directly. This decouples threshold +evaluation from notification delivery. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +from typing import TYPE_CHECKING + +from .const import ( + DEFAULT_CONTINUOUS_THRESHOLD_PCT, + DEFAULT_COOLDOWN_DURATION_M, + DEFAULT_SPIKE_THRESHOLD_PCT, + DEFAULT_WINDOW_DURATION_M, +) +from .options import ( + CONTINUOUS_THRESHOLD_PCT, + COOLDOWN_DURATION_M, + SPIKE_THRESHOLD_PCT, + WINDOW_DURATION_M, +) + +if TYPE_CHECKING: + from .current_monitor import MonitoredPointState + + +# Monitoring settings / overrides hold only int, bool, and str values. +# Keyed by the named constants in options.py (`"continuous_threshold_pct"`, +# `"monitoring_enabled"`, …) rather than arbitrary strings; a TypedDict would +# express that more precisely but requires Literal-typed constants throughout +# the call chain, so we keep the lighter-weight alias and rely on the named +# keys for discoverability. +type MonitoringValue = int | bool | str +type MonitoringSettings = dict[str, MonitoringValue] + + +@dataclass +class AlertEvent: + """Describes an alert to be dispatched.""" + + alert_type: str # "spike" or "continuous_overload" + current_a: float + breaker_rating_a: float + threshold_pct: int + utilization_pct: float + window_duration_s: int | None = None + over_threshold_since: str | None = None + + +def resolve_thresholds( + override: MonitoringSettings, + global_settings: MonitoringSettings, +) -> tuple[int, int, int, int]: + """Return (continuous_pct, spike_pct, window_m, cooldown_m) for a monitored point. + + Merges per-point overrides with global settings, falling back to built-in + defaults when neither layer provides a value. + """ + return ( + int( + override.get( + CONTINUOUS_THRESHOLD_PCT, + global_settings.get(CONTINUOUS_THRESHOLD_PCT, DEFAULT_CONTINUOUS_THRESHOLD_PCT), + ) + ), + int( + override.get( + SPIKE_THRESHOLD_PCT, + global_settings.get(SPIKE_THRESHOLD_PCT, DEFAULT_SPIKE_THRESHOLD_PCT), + ) + ), + int( + override.get( + WINDOW_DURATION_M, + global_settings.get(WINDOW_DURATION_M, DEFAULT_WINDOW_DURATION_M), + ) + ), + int( + override.get( + COOLDOWN_DURATION_M, + global_settings.get(COOLDOWN_DURATION_M, DEFAULT_COOLDOWN_DURATION_M), + ) + ), + ) + + +def is_monitoring_disabled(override: MonitoringSettings) -> bool: + """Check if monitoring is disabled via per-point override.""" + return override.get("monitoring_enabled") is False + + +def check_spike( + state: MonitoredPointState, + current: float, + rating: float, + threshold_pct: int, + cooldown_m: int, +) -> AlertEvent | None: + """Check for instantaneous spike condition. + + Returns an AlertEvent if the spike threshold is exceeded and the cooldown + period has elapsed, otherwise None. Updates state.last_spike_alert on alert. + """ + limit = rating * threshold_pct / 100.0 + if current < limit: + return None + + now = datetime.now(UTC) + if state.last_spike_alert is not None and now - state.last_spike_alert < timedelta( + minutes=cooldown_m + ): + return None + + state.last_spike_alert = now + utilization = round(current / rating * 100, 1) + + return AlertEvent( + alert_type="spike", + current_a=current, + breaker_rating_a=rating, + threshold_pct=threshold_pct, + utilization_pct=utilization, + ) + + +def check_continuous( + state: MonitoredPointState, + current: float, + rating: float, + threshold_pct: int, + window_m: int, + cooldown_m: int, +) -> AlertEvent | None: + """Check for sustained continuous overload condition. + + Returns an AlertEvent if the continuous threshold has been exceeded for + longer than the configured window and the cooldown period has elapsed, + otherwise None. Updates state tracking fields on transitions. + """ + limit = rating * threshold_pct / 100.0 + now = datetime.now(UTC) + + if current < limit: + state.over_threshold_since = None + return None + + if state.over_threshold_since is None: + state.over_threshold_since = now + + elapsed = now - state.over_threshold_since + if elapsed < timedelta(minutes=window_m): + return None + + if state.last_continuous_alert is not None and now - state.last_continuous_alert < timedelta( + minutes=cooldown_m + ): + return None + + state.last_continuous_alert = now + utilization = round(current / rating * 100, 1) + + return AlertEvent( + alert_type="continuous_overload", + current_a=current, + breaker_rating_a=rating, + threshold_pct=threshold_pct, + utilization_pct=utilization, + window_duration_s=int(elapsed.total_seconds()), + over_threshold_since=state.over_threshold_since.isoformat(), + ) diff --git a/custom_components/span_panel/translations/en.json b/custom_components/span_panel/translations/en.json index 542fda91..040332a9 100644 --- a/custom_components/span_panel/translations/en.json +++ b/custom_components/span_panel/translations/en.json @@ -1,180 +1,697 @@ { "config": { "abort": { - "no_devices_found": "No devices found on the network", "already_configured": "Span Panel already configured. Only a single configuration is possible.", - "reauth_successful": "Authentication successful." + "cannot_connect": "Failed to connect to Span Panel", + "no_devices_found": "No devices found on the network", + "no_host": "No host address was provided by the add-on.", + "no_serial": "Could not determine the panel serial number.", + "reauth_successful": "Authentication successful.", + "reconfigure_successful": "Host updated successfully.", + "unique_id_mismatch": "The panel at this address has a different serial number. You cannot switch to a different panel during reconfiguration.", + "v1_not_supported": "v1 firmware is not supported. Please upgrade your panel firmware to v2." }, "error": { "cannot_connect": "Failed to connect to Span Panel", + "fqdn_registration_failed": "Could not register the domain name with the panel or the TLS certificate was not updated in time.", + "host_required": "Host is required", "invalid_auth": "Invalid authentication", - "unknown": "Unexpected error", - "host_required": "Host is required for live panel connections" + "proximity_failed": "Proximity not proven. Please open and close the panel door 3 times and try again.", + "unknown": "Unexpected error" }, "flow_title": "Span Panel ({host})", + "progress": { + "registering_fqdn": "Registering domain name with the panel and waiting for TLS certificate update. This may take up to 60 seconds..." + }, "step": { + "auth_passphrase": { + "data": { + "hop_passphrase": "Passphrase" + }, + "data_description": { + "hop_passphrase": "The passphrase used to authenticate with the SPAN Panel v2 API" + }, + "description": "Enter the passphrase for your SPAN Panel. This is printed on the panel label or set by the panel owner.", + "title": "Panel Passphrase" + }, + "auth_proximity": { + "description": "Open and close the SPAN Panel door 3 times, then select the confirmation below.", + "menu_options": { + "auth_passphrase": "Use passphrase instead", + "auth_proximity_confirm": "I have opened and closed the door" + }, + "title": "Proximity Authentication" + }, + "auth_proximity_confirm": { + "description": "Verifying proximity with the panel.", + "title": "Proximity Authentication" + }, + "choose_entity_naming_initial": { + "data": { + "entity_naming_pattern": "Naming pattern" + }, + "data_description": { + "entity_naming_pattern": "Friendly names use circuit labels from the panel. Tab-based names use breaker tab numbers for stable entity IDs." + }, + "description": "Choose how circuit-related entities are named in Home Assistant.", + "title": "Circuit entity naming" + }, + "choose_v2_auth": { + "menu_options": { + "auth_passphrase": "Enter Panel Passphrase", + "auth_proximity": "Proof of Proximity (open/close door)" + }, + "title": "Choose Authentication Method" + }, "confirm_discovery": { "description": "Do you want to setup Span Panel at {host}?", "title": "Connect to the Span Panel" }, - "user": { + "fqdn_failed": { + "description": "The domain name could not be registered with the panel, or the TLS certificate was not updated in time. You may continue, but the MQTT connection may fail if the panel's certificate does not include your domain name.", + "title": "Domain Registration" + }, + "reauth_confirm": { + "description": "The Span Panel at {host} requires re-authentication.", + "menu_options": { + "auth_passphrase": "Enter Panel Passphrase", + "auth_proximity": "Proof of Proximity (open/close door)" + }, + "title": "Re-authenticate Span Panel" + }, + "reconfigure": { "data": { - "host": "Host", - "use_ssl": "Use SSL", - "simulator_mode": "Simulator Mode", - "power_display_precision": "Power Display Precision", - "energy_display_precision": "Energy Display Precision" + "host": "Host" }, "data_description": { - "host": "IP address or hostname of SPAN Panel (required for live panels, optional for simulation)", - "use_ssl": "Enable secure HTTPS connection to the SPAN Panel", - "simulator_mode": "Enable simulator mode for testing (no hardware required). Host can be left empty for default simulation.", - "power_display_precision": "Number of decimal places for power values (0-3)", - "energy_display_precision": "Number of decimal places for energy values (0-3)" + "host": "IP address or hostname of the SPAN Panel" }, - "description": "Enter the connection details for your SPAN Panel. For simulation mode, check the simulator checkbox and leave host empty (or enter a custom serial number).", - "title": "Connect to the Span Panel" + "description": "Update the host address for this SPAN Panel. The panel serial number must match.", + "title": "Reconfigure SPAN Panel" }, - "choose_auth_type": { - "title": "Choose Authentication Options", - "menu_options": { - "auth_proximity": "Authenticate through your physical Span Panel", - "auth_token": "Authenticate using an access token" - } + "reconfigure_fqdn_done": { + "title": "Reconfiguration Complete" }, - "auth_proximity": { - "title": "Proximity Authentication", - "description": "Please open and close the Span Panel door 3 times." + "reconfigure_fqdn_failed": { + "description": "The domain name could not be registered with the panel, or the TLS certificate was not updated in time. You may continue, but the MQTT connection may fail if the panel's certificate does not include your domain name.", + "title": "Domain Registration" }, - "auth_token": { - "title": "Manual Token Authentication", - "description": "Please enter your access token (Empty to start over)", - "data": { - "access_token": "Access Token" - } + "reconfigure_register_fqdn": { + "title": "Registering Domain Name" }, - "simulator_config": { - "title": "Simulator Configuration", - "description": "Choose the simulation configuration for your SPAN Panel. This determines the number of circuits and behaviors. {config_count} configurations are available.", + "register_fqdn": { + "title": "Registering Domain Name" + }, + "user": { "data": { - "simulation_config": "Simulation Configuration", - "host": "Custom Serial Number (optional)", - "simulation_start_time": "Simulation Start Time (Optional)", - "entity_naming_pattern": "Entity Naming Pattern" + "enable_energy_dip_compensation": "Auto-Compensate Energy Dips", + "energy_display_precision": "Energy Display Precision", + "host": "Host", + "http_port": "HTTP Port", + "power_display_precision": "Power Display Precision" }, "data_description": { - "simulation_config": "Select the panel configuration to simulate", - "host": "Leave empty to use the default serial number from the selected configuration", - "simulation_start_time": "Time format: HH:MM (24h), H:MM (12h), or full ISO datetime (YYYY-MM-DDTHH:MM:SS). Leave empty to use current time.", - "entity_naming_pattern": "Choose how circuit entities are named in the simulation" - } + "enable_energy_dip_compensation": "Automatically compensate when the panel reports lower energy readings, preventing spikes in the energy dashboard.", + "energy_display_precision": "Number of decimal places for energy values (0-3)", + "host": "IP address or hostname of SPAN Panel", + "http_port": "HTTP port for the panel API (default: 80)", + "power_display_precision": "Number of decimal places for power values (0-3)" + }, + "description": "Enter the connection details for your SPAN Panel.", + "title": "Connect to the Span Panel" } } }, - "options": { - "error": { - "Both solar legs must be selected. Single leg configuration is not supported for proper 240V measurement.": "Both solar legs must be selected. Single leg configuration is not supported for proper 240V measurement.", - "Solar legs cannot use the same tab ({tab}). Two different tabs are required for 240V measurement.": "Solar legs cannot use the same tab. Two different tabs are required for 240V measurement.", - "Tab {tab} is not available or is already mapped to a circuit.": "Selected tab is not available or is already mapped to a circuit.", - "Solar tabs {tab1} and {tab2} are both on {phase}. For proper 240V measurement, tabs must be on opposite phases.": "Selected solar tabs are both on the same electrical phase. For proper 240V measurement, tabs must be on opposite phases (L1 and L2).", - "Invalid date/time format. Use ISO format (YYYY-MM-DDTHH:MM:SS)": "Invalid time format. Use HH:MM (24h), H:MM (12h), or full ISO datetime (YYYY-MM-DDTHH:MM:SS)", - "directory": "File path is required", - "base": "Export failed: {error}" + "entity": { + "binary_sensor": { + "bess_connected": { + "name": "BESS Connected" + }, + "door_state": { + "name": "Door State" + }, + "ethernet_link": { + "name": "Ethernet Link" + }, + "evse_charging": { + "name": "Charging" + }, + "evse_ev_connected": { + "name": "EV Connected" + }, + "grid_islandable": { + "name": "Grid Islandable" + }, + "panel_status": { + "name": "Panel Status" + }, + "wifi_link": { + "name": "Wi-Fi Link" + } }, - "step": { - "init": { - "title": "Options Menu", - "menu_options": { - "general_options": "General Options", - "entity_naming_options": "Entity Naming Options", - "entity_naming": "Entity Naming Pattern", - "export_config": "Export Synthetic Sensor Config", - "simulation_start_time": "Simulation Start Time", - "simulation_offline_minutes": "Simulation Offline Minutes" + "button": { + "gfe_override": { + "name": "GFE Override: Grid Connected" + } + }, + "select": { + "circuit_priority": { + "name": "Circuit Priority", + "state": { + "never": "Stays on in an outage", + "off_grid": "Turns off in an outage", + "soc_threshold": "Stays on until battery threshold" } + } + }, + "sensor": { + "battery_level": { + "name": "Battery Level" }, - "general_options": { - "title": "General Options", - "data": { - "scan_interval": "Scan interval in seconds", - "enable_battery_percentage": "Enable Battery Percentage Sensor", - "enable_solar_circuit": "Enable Solar Inverter Sensors", - "leg1": "Solar Leg 1", - "leg2": "Solar Leg 2", - "api_retries": "API Retries (0-10)", - "api_retry_timeout": "API Retry Timeout (seconds)", - "api_retry_backoff_multiplier": "API Retry Backoff Multiplier", - "energy_reporting_grace_period": "Energy Sensor Grace Period (minutes)", - "legacy_upgrade_to_friendly": "Add Device Name Prefix to Entity ID's", - "enable_panel_net_energy_sensors": "Panel Net Energy", - "enable_circuit_net_energy_sensors": "Circuit Net Energy", - "enable_solar_net_energy_sensors": "Solar Net Energy" - }, - "data_description": { - "enable_solar_circuit": "Enable synthetic sensors for solar inverter monitoring. Requires selection of two unmapped tabs on opposite electrical phases.", - "energy_reporting_grace_period": "How long energy sensors maintain their last known value when the panel becomes unavailable (0-60 minutes). Helps preserve energy statistics integrity during brief outages. Default: 15 minutes.", - "legacy_upgrade_to_friendly": "Warning: This irreversible operation will change your circuit entity IDs allowing support for more than one panel", - "enable_panel_net_energy_sensors": "Create net energy sensors for panel-level energy flows (main meter and feed-through). Shows true energy balance accounting for bidirectional flows.", - "enable_circuit_net_energy_sensors": "Create net energy sensors for individual circuits. Shows true energy consumption accounting for reactive power and regenerative flows.", - "enable_solar_net_energy_sensors": "Create net energy sensors for solar generation (only available when solar is configured). Shows net solar production accounting for any consumption on solar circuits." + "battery_power": { + "name": "Battery Power" + }, + "bess_firmware_version": { + "name": "Firmware Version" + }, + "bess_model": { + "name": "Model" + }, + "bess_nameplate_capacity": { + "name": "Nameplate Capacity" + }, + "bess_serial_number": { + "name": "Serial Number" + }, + "bess_soe_kwh": { + "name": "State of Energy" + }, + "bess_vendor": { + "name": "Vendor" + }, + "current_run_config": { + "name": "Current Run Config", + "state": { + "panel_backup": "Backup", + "panel_off_grid": "Off Grid", + "panel_on_grid": "On Grid", + "unknown": "Unknown" } }, - "entity_naming_options": { - "title": "Entity Naming Options", - "description": "Configure how entity IDs are named. Choose between friendly names (e.g., kitchen_outlets_power) or circuit tab numbers (e.g., circuit_15_power).", - "data": { - "legacy_upgrade_to_friendly": "Upgrade Legacy Installation to Friendly Names", - "entity_naming_pattern": "Entity Naming Pattern" - }, - "data_description": { - "legacy_upgrade_to_friendly": "Upgrade this legacy installation to use friendly entity names with device prefix. This will rename all existing entities.", - "entity_naming_pattern": "Choose how entity IDs are constructed. Friendly names use circuit names (e.g., kitchen_outlets_power), while circuit numbers use tab positions (e.g., circuit_15_power)." + "downstream_l1_current": { + "name": "Downstream L1 Current" + }, + "downstream_l2_current": { + "name": "Downstream L2 Current" + }, + "dsm_grid_state": { + "name": "DSM Grid State", + "state": { + "dsm_off_grid": "Off Grid", + "dsm_on_grid": "On Grid", + "unknown": "Unknown" } }, - "entity_naming": { - "title": "Entity Naming Pattern", - "description": "Choose how circuit entities are named. **Changing this will rename your existing entities.**\n\n{friendly_example}\n\n{circuit_example}\n\n⚠️ **Important**: \n• Entity history will be preserved during renaming\n• Consider backing up your configuration before proceeding\n• Automations and scripts may need manual updates to use new entity IDs", - "data": { - "entity_naming_pattern": "Entity Naming Pattern" + "dsm_state": { + "name": "DSM State", + "state": { + "dsm_off_grid": "Off Grid", + "dsm_on_grid": "On Grid", + "unknown": "Unknown" } }, - "export_config": { - "title": "Export Synthetic Sensor Config", - "description": "Export the current synthetic sensor configuration to a YAML file. You can specify either a directory (file will be named automatically) or a full file path. Default filename: {filename}", - "data": { - "directory": "File Path" - }, - "data_description": { - "directory": "Either a directory path (file will be named automatically) or a full file path ending in .yaml where the configuration will be saved." + "evse_advertised_current": { + "name": "Advertised Current" + }, + "evse_lock_state": { + "name": "Lock State", + "state": { + "locked": "Locked", + "unknown": "Unknown", + "unlocked": "Unlocked" } }, - "simulation_start_time": { - "title": "Simulation Start Time", - "description": "Configure the simulation start time. Changes to simulation time will reload the integration to apply the new time.", - "data": { - "simulation_start_time": "Simulation Start Time" - }, - "data_description": { - "simulation_start_time": "Time format: HH:MM (24h), H:MM (12h), or full ISO datetime (YYYY-MM-DDTHH:MM:SS). Leave empty to use current time. Integration will reload to apply changes." + "evse_status": { + "name": "Charger Status", + "state": { + "available": "Available", + "charging": "Charging", + "faulted": "Faulted", + "finishing": "Finishing", + "preparing": "Preparing", + "reserved": "Reserved", + "suspended_ev": "Suspended by Vehicle", + "suspended_evse": "Suspended by Charger", + "unavailable": "Unavailable", + "unknown": "Unknown" + } + }, + "feedthrough_consumed_energy": { + "name": "Feed Through Consumed Energy" + }, + "feedthrough_net_energy": { + "name": "Feed Through Net Energy" + }, + "feedthrough_power": { + "name": "Feed Through Power" + }, + "feedthrough_produced_energy": { + "name": "Feed Through Produced Energy" + }, + "grid_forming_entity": { + "name": "Grid Forming Entity", + "state": { + "battery": "Battery", + "generator": "Generator", + "grid": "Grid", + "none": "None", + "pv": "PV", + "unknown": "Unknown" + } + }, + "grid_power_flow": { + "name": "Grid Power" + }, + "instant_grid_power": { + "name": "Current Power" + }, + "l1_voltage": { + "name": "L1 Voltage" + }, + "l2_voltage": { + "name": "L2 Voltage" + }, + "main_breaker_rating": { + "name": "Main Breaker Rating" + }, + "main_meter_consumed_energy": { + "name": "Main Meter Consumed Energy" + }, + "main_meter_net_energy": { + "name": "Main Meter Net Energy" + }, + "main_meter_produced_energy": { + "name": "Main Meter Produced Energy" + }, + "main_relay_state": { + "name": "Main Relay State", + "state": { + "closed": "Closed", + "open": "Open", + "unknown": "Unknown" } }, - "simulation_offline_minutes": { - "title": "Simulation Offline Minutes", - "description": "Configure how long the panel should appear offline during simulation.", + "pv_nameplate_capacity": { + "name": "PV Nameplate Capacity" + }, + "pv_power": { + "name": "PV Power" + }, + "pv_product": { + "name": "PV Product" + }, + "pv_vendor": { + "name": "PV Vendor" + }, + "site_power": { + "name": "Site Power" + }, + "software_version": { + "name": "Software Version" + }, + "upstream_l1_current": { + "name": "Upstream L1 Current" + }, + "upstream_l2_current": { + "name": "Upstream L2 Current" + }, + "vendor_cloud": { + "name": "Vendor Cloud", + "state": { + "connected": "Connected", + "unconnected": "Disconnected", + "unknown": "Unknown" + } + } + } + }, + "exceptions": { + "circuit_priority_failed": { + "message": "Failed to set circuit priority for {circuit}." + }, + "circuit_relay_failed": { + "message": "Failed to set circuit relay state for {circuit}." + }, + "export_manifest_no_entries": { + "message": "No SPAN panel configuration entries are loaded. Add and configure a SPAN panel before calling this service." + }, + "favorite_not_span_entity": { + "message": "Entity {entity_id} is not a SPAN Panel entity. Pick a circuit, BESS, or EVSE sensor." + }, + "favorite_no_device": { + "message": "Entity {entity_id} is not attached to a device. Pick a SPAN circuit or sub-device sensor." + }, + "favorite_subdevice_no_span_parent": { + "message": "Sub-device {entity_id} has no SPAN Panel parent device — its via_device_id does not point at a SPAN panel." + }, + "favorite_no_unique_id": { + "message": "Entity {entity_id} has no unique id and cannot be resolved to a SPAN target." + }, + "favorite_no_circuit_uuid": { + "message": "Could not derive a favorite target from entity {entity_id}. Pick a circuit sensor (current/power) or a sub-device sensor." + }, + "gfe_override_failed": { + "message": "Failed to send GFE override to the panel." + }, + "graph_horizon_not_available": { + "message": "Graph horizon manager is not available for the selected SPAN panel." + }, + "monitoring_not_enabled": { + "message": "No SPAN panel with current monitoring enabled." + }, + "panel_auth_failed": { + "message": "Authentication with the SPAN Panel failed. Please reauthenticate." + }, + "panel_connection_failed": { + "message": "Failed to connect to the SPAN Panel at {host}." + }, + "panel_not_ready": { + "message": "The SPAN Panel is not ready. Please check that the panel is online." + } + }, + "options": { + "step": { + "init": { "data": { - "simulation_offline_minutes": "Offline Minutes" + "show_panel": "Show Dashboard in Sidebar", + "panel_admin_only": "Admin Users Only", + "enable_circuit_net_energy_sensors": "Circuit Net Energy", + "enable_energy_dip_compensation": "Auto-Compensate Energy Dips", + "enable_panel_net_energy_sensors": "Panel Net Energy", + "enable_unmapped_circuit_sensors": "Unmapped Circuit Sensors", + "energy_reporting_grace_period": "Energy Sensor Grace Period (minutes)", + "snapshot_update_interval": "Snapshot Update Interval (seconds)" }, "data_description": { - "simulation_offline_minutes": "Number of minutes the panel should appear offline during simulation. Set to 0 to disable offline simulation." - } + "show_panel": "Display the SPAN Panel dashboard as a sidebar entry.", + "panel_admin_only": "Restrict the dashboard sidebar entry to admin users.", + "enable_circuit_net_energy_sensors": "Net energy per circuit, accounting for reactive power.", + "enable_energy_dip_compensation": "Compensate when panel reports lower energy readings. Disabling clears offsets.", + "enable_panel_net_energy_sensors": "Net energy for main meter and feed-through, accounting for bidirectional flows.", + "enable_unmapped_circuit_sensors": "Backing sensors for tabs not assigned to named breakers.", + "energy_reporting_grace_period": "Minutes sensors hold last value when panel is offline (0–60). Default: 15.", + "snapshot_update_interval": "Seconds between snapshot rebuilds (0–15). Lower = faster, more CPU. 0 = no debounce." + }, + "description": "Configure SPAN Panel integration settings.", + "title": "SPAN Panel Options" } } }, "selector": { "entity_naming_pattern": { "options": { - "friendly_names": "Friendly Names (e.g., span_panel_kitchen_outlets_power)", - "circuit_numbers": "Circuit Numbers (e.g., span_panel_circuit_15_power)" + "circuit_numbers": "Circuit Numbers (e.g., span_panel_circuit_15_power)", + "friendly_names": "Friendly Names (e.g., span_panel_kitchen_outlets_power)" + } + } + }, + "services": { + "export_circuit_manifest": { + "description": "Returns a structured circuit manifest for all configured SPAN panels: serial, host, and per-circuit power sensor entity IDs, layout template identifiers (clone_{{tab}}), device types, and breaker tab numbers. Intended for dashboard cards and automations that need panel topology without querying the entity registry. This is a response-only service; the response may list zero panels if nothing can be resolved.", + "name": "Export circuit manifest" + }, + "set_circuit_threshold": { + "name": "Set circuit threshold", + "description": "Set current monitoring thresholds for a specific circuit.", + "fields": { + "circuit_id": { + "name": "Circuit", + "description": "The circuit power sensor entity." + }, + "continuous_threshold_pct": { + "name": "Continuous threshold (%)", + "description": "Percentage of breaker rating for continuous load alerts." + }, + "spike_threshold_pct": { + "name": "Spike threshold (%)", + "description": "Percentage of breaker rating for instantaneous spike alerts." + }, + "window_duration_m": { + "name": "Window duration (minutes)", + "description": "How long current must stay above threshold before alerting." + }, + "cooldown_duration_m": { + "name": "Cooldown duration (minutes)", + "description": "Minutes between repeated alerts for the same circuit." + }, + "monitoring_enabled": { + "name": "Monitoring enabled", + "description": "Enable or disable monitoring for this circuit." + }, + "config_entry_id": { + "name": "Config entry", + "description": "Optional config entry id. Only needed when more than one SPAN panel is configured." + } + } + }, + "clear_circuit_threshold": { + "name": "Clear circuit threshold", + "description": "Remove per-circuit threshold overrides, reverting to global defaults.", + "fields": { + "circuit_id": { + "name": "Circuit", + "description": "The circuit power sensor entity." + }, + "config_entry_id": { + "name": "Config entry", + "description": "Optional config entry id. Only needed when more than one SPAN panel is configured." + } + } + }, + "set_mains_threshold": { + "name": "Set mains threshold", + "description": "Set current monitoring thresholds for a specific mains leg.", + "fields": { + "leg": { + "name": "Mains leg", + "description": "The mains current sensor entity." + }, + "continuous_threshold_pct": { + "name": "Continuous threshold (%)", + "description": "Percentage of breaker rating for continuous load alerts." + }, + "spike_threshold_pct": { + "name": "Spike threshold (%)", + "description": "Percentage of breaker rating for instantaneous spike alerts." + }, + "window_duration_m": { + "name": "Window duration (minutes)", + "description": "How long current must stay above threshold before alerting." + }, + "cooldown_duration_m": { + "name": "Cooldown duration (minutes)", + "description": "Minutes between repeated alerts for the same mains leg." + }, + "monitoring_enabled": { + "name": "Monitoring enabled", + "description": "Enable or disable monitoring for this mains leg." + }, + "config_entry_id": { + "name": "Config entry", + "description": "Optional config entry id. Only needed when more than one SPAN panel is configured." + } + } + }, + "clear_mains_threshold": { + "name": "Clear mains threshold", + "description": "Remove per-mains-leg threshold overrides, reverting to global defaults.", + "fields": { + "leg": { + "name": "Mains leg", + "description": "The mains current sensor entity." + }, + "config_entry_id": { + "name": "Config entry", + "description": "Optional config entry id. Only needed when more than one SPAN panel is configured." + } + } + }, + "get_monitoring_status": { + "name": "Get monitoring status", + "description": "Returns current monitoring state for all tracked circuits and mains legs.", + "fields": { + "config_entry_id": { + "name": "Config entry", + "description": "Optional config entry id. Only needed when more than one SPAN panel is configured." + } + } + }, + "set_global_monitoring": { + "name": "Set Global Monitoring", + "description": "Update global monitoring thresholds and notification settings.", + "fields": { + "enabled": { + "name": "Enabled", + "description": "Enable or disable current monitoring globally for this SPAN panel." + }, + "continuous_threshold_pct": { + "name": "Continuous Threshold", + "description": "Percentage of breaker rating for continuous overload detection." + }, + "spike_threshold_pct": { + "name": "Spike Threshold", + "description": "Percentage of breaker rating for spike detection." + }, + "window_duration_m": { + "name": "Window Duration", + "description": "Minutes of sustained overload before alerting." + }, + "cooldown_duration_m": { + "name": "Cooldown Duration", + "description": "Minutes between repeated alerts for the same point." + }, + "notify_targets": { + "name": "Notification Targets", + "description": "Comma-separated list of notify service targets." + }, + "notification_title_template": { + "name": "Notification Title Template", + "description": "Template for notification title. Placeholders: {name}, {entity_id}, {alert_type}, {current_a}, {breaker_rating_a}, {threshold_pct}, {utilization_pct}, {window_m}." + }, + "notification_message_template": { + "name": "Notification Message Template", + "description": "Template for notification message body. Placeholders: {name}, {entity_id}, {alert_type}, {current_a}, {breaker_rating_a}, {threshold_pct}, {utilization_pct}, {window_m}." + }, + "notification_priority": { + "name": "Notification Priority", + "description": "Push notification priority level. Controls interruption behavior on mobile devices (iOS: interruption-level, Android: priority/channel)." + }, + "config_entry_id": { + "name": "Config entry", + "description": "Optional config entry id. Only needed when more than one SPAN panel is configured." + } + } + }, + "test_notification": { + "name": "Test Notification", + "description": "Send a test notification to all configured targets using sample values.", + "fields": { + "config_entry_id": { + "name": "Config entry", + "description": "Optional config entry id. Only needed when more than one SPAN panel is configured." + } + } + }, + "set_graph_time_horizon": { + "name": "Set graph time horizon", + "description": "Set the global default time horizon for circuit graphs.", + "fields": { + "horizon": { + "name": "Horizon", + "description": "Time window preset for graph display." + }, + "config_entry_id": { + "name": "Config entry", + "description": "Optional config entry id. Only needed when more than one SPAN panel is configured." + } + } + }, + "set_circuit_graph_horizon": { + "name": "Set circuit graph horizon", + "description": "Set a per-circuit time horizon override for graphs.", + "fields": { + "circuit_id": { + "name": "Circuit ID", + "description": "The circuit identifier to override." + }, + "horizon": { + "name": "Horizon", + "description": "Time window preset for graph display." + }, + "config_entry_id": { + "name": "Config entry", + "description": "Optional config entry id. Only needed when more than one SPAN panel is configured." + } + } + }, + "clear_circuit_graph_horizon": { + "name": "Clear circuit graph horizon", + "description": "Remove a per-circuit graph horizon override, reverting to the global default.", + "fields": { + "circuit_id": { + "name": "Circuit ID", + "description": "The circuit identifier to clear the override for." + }, + "config_entry_id": { + "name": "Config entry", + "description": "Optional config entry id. Only needed when more than one SPAN panel is configured." + } + } + }, + "set_subdevice_graph_horizon": { + "name": "Set sub-device graph horizon", + "description": "Set a per-sub-device time horizon override for graphs.", + "fields": { + "subdevice_id": { + "name": "Sub-device ID", + "description": "The sub-device identifier to override." + }, + "horizon": { + "name": "Horizon", + "description": "Time window preset for graph display." + }, + "config_entry_id": { + "name": "Config entry", + "description": "Optional config entry id. Only needed when more than one SPAN panel is configured." + } + } + }, + "clear_subdevice_graph_horizon": { + "name": "Clear sub-device graph horizon", + "description": "Remove a per-sub-device graph horizon override, reverting to the global default.", + "fields": { + "subdevice_id": { + "name": "Sub-device ID", + "description": "The sub-device identifier to clear the override for." + }, + "config_entry_id": { + "name": "Config entry", + "description": "Optional config entry id. Only needed when more than one SPAN panel is configured." + } + } + }, + "get_graph_settings": { + "name": "Get graph settings", + "description": "Returns the current global and per-circuit graph horizon settings.", + "fields": { + "config_entry_id": { + "name": "Config entry", + "description": "Optional config entry id. Only needed when more than one SPAN panel is configured." + } + } + }, + "get_favorites": { + "name": "Get favorites", + "description": "Returns the saved cross-panel favorites map keyed by SPAN panel device id." + }, + "add_favorite": { + "name": "Add favorite", + "description": "Mark a SPAN circuit, battery (BESS), or EV charger (EVSE) as a favorite so it appears in the dashboard's Favorites view.", + "fields": { + "entity_id": { + "name": "Entity", + "description": "Any sensor of the circuit or sub-device to favorite (current, power, SoC, etc.)." + } + } + }, + "remove_favorite": { + "name": "Remove favorite", + "description": "Remove a circuit or sub-device from the cross-panel Favorites view.", + "fields": { + "entity_id": { + "name": "Entity", + "description": "Any sensor of the circuit or sub-device to remove from favorites." + } } } } diff --git a/custom_components/span_panel/translations/es.json b/custom_components/span_panel/translations/es.json index 5d9d99c1..3bb65e2e 100644 --- a/custom_components/span_panel/translations/es.json +++ b/custom_components/span_panel/translations/es.json @@ -1,175 +1,697 @@ { "config": { "abort": { - "no_devices_found": "No se hallaron aparatos en la red", "already_configured": "Span Panel ya esta configurado. Solo una configuración es posible.", - "reauth_successful": "Autenticación exitosa." + "cannot_connect": "No se logró establecer conexión con Span Panel", + "no_devices_found": "No se hallaron aparatos en la red", + "no_host": "No se proporcionó una dirección de host desde el complemento.", + "no_serial": "No se pudo determinar el número de serie del panel.", + "reauth_successful": "Autenticación exitosa.", + "reconfigure_successful": "Host actualizado exitosamente.", + "unique_id_mismatch": "El panel en esta dirección tiene un número de serie diferente. No puede cambiar a un panel diferente durante la reconfiguración.", + "v1_not_supported": "El firmware v1 no es compatible. Actualice el firmware de su panel a v2." }, "error": { "cannot_connect": "No se logró establecer conexión con Span Panel", + "fqdn_registration_failed": "No se pudo registrar el nombre de dominio en el panel o el certificado TLS no se actualizó a tiempo.", + "host_required": "Se requiere host", "invalid_auth": "Autenticación invalida", - "unknown": "Error inesperado", - "host_required": "Se requiere host para conexiones de panel en vivo" + "proximity_failed": "Proximidad no comprobada. Por favor abra y cierre la puerta del panel 3 veces e intente de nuevo.", + "unknown": "Error inesperado" }, "flow_title": "Span Panel ({host})", + "progress": { + "registering_fqdn": "Registrando el nombre de dominio en el panel y esperando la actualización del certificado TLS. Esto puede tardar hasta 60 segundos..." + }, "step": { + "auth_passphrase": { + "data": { + "hop_passphrase": "Contraseña" + }, + "data_description": { + "hop_passphrase": "La contraseña utilizada para autenticar con la API v2 del SPAN Panel" + }, + "description": "Ingrese la contraseña de su SPAN Panel. Está impresa en la etiqueta del panel o establecida por el propietario del panel.", + "title": "Contraseña del Panel" + }, + "auth_proximity": { + "description": "Abra y cierre la puerta del SPAN Panel 3 veces, luego seleccione la confirmación a continuación.", + "menu_options": { + "auth_passphrase": "Usar contraseña en su lugar", + "auth_proximity_confirm": "He abierto y cerrado la puerta" + }, + "title": "Autenticación de proximidad" + }, + "auth_proximity_confirm": { + "description": "Verificando proximidad con el panel.", + "title": "Autenticación de proximidad" + }, + "choose_entity_naming_initial": { + "data": { + "entity_naming_pattern": "Patrón de nomenclatura" + }, + "data_description": { + "entity_naming_pattern": "Los nombres amigables usan etiquetas de circuito del panel. Los nombres basados en pestañas usan números de pestaña de interruptor para IDs de entidad estables." + }, + "description": "Elija cómo se nombran las entidades relacionadas con circuitos en Home Assistant.", + "title": "Nomenclatura de entidades de circuito" + }, + "choose_v2_auth": { + "menu_options": { + "auth_passphrase": "Ingresar Contraseña del Panel", + "auth_proximity": "Prueba de Proximidad (abrir/cerrar puerta)" + }, + "title": "Elegir Método de Autenticación" + }, "confirm_discovery": { "description": "¿Deseas configurar Span Panel en {host}?", "title": "Establecer conexión al Span Panel" }, - "user": { + "fqdn_failed": { + "description": "No se pudo registrar el nombre de dominio en el panel, o el certificado TLS no se actualizó a tiempo. Puede continuar, pero la conexión MQTT puede fallar si el certificado del panel no incluye su nombre de dominio.", + "title": "Registro de Dominio" + }, + "reauth_confirm": { + "description": "El Span Panel en {host} requiere re-autenticación.", + "menu_options": { + "auth_passphrase": "Ingresar Contraseña del Panel", + "auth_proximity": "Prueba de Proximidad (abrir/cerrar puerta)" + }, + "title": "Re-autenticar Span Panel" + }, + "reconfigure": { "data": { - "host": "Host", - "use_ssl": "Usar SSL", - "simulator_mode": "Modo Simulador", - "power_display_precision": "Precisión de Visualización de Potencia", - "energy_display_precision": "Precisión de Visualización de Energía" + "host": "Host" }, "data_description": { - "host": "Dirección IP o nombre de host del SPAN Panel (para simulación: use número de serie como 'sp3-simulation-001' o déjelo vacío para el predeterminado)", - "use_ssl": "Habilitar conexión HTTPS segura al SPAN Panel", - "simulator_mode": "Habilitar modo simulador para pruebas (no se requiere hardware). Deje el host vacío para simulación predeterminada, o ingrese un número de serie personalizado.", - "power_display_precision": "Número de lugares decimales para valores de potencia (0-3)", - "energy_display_precision": "Número de lugares decimales para valores de energía (0-3)" + "host": "Dirección IP o nombre de host del SPAN Panel" }, - "description": "Ingrese los detalles de conexión para su SPAN Panel. Para modo simulador, marque la casilla del simulador y opcionalmente ingrese un número de serie personalizado.", - "title": "Establecer conexión al Span Panel" + "description": "Actualice la dirección de host para este SPAN Panel. El número de serie del panel debe coincidir.", + "title": "Reconfigurar SPAN Panel" }, - "choose_auth_type": { - "title": "Escoga Opciones de Autenticación", - "menu_options": { - "auth_proximity": "Autenticar a través de su Span Panel físicamente", - "auth_token": "Autenticar usando un token de acceso" - } + "reconfigure_fqdn_done": { + "title": "Reconfiguración Completada" }, - "auth_proximity": { - "title": "Autenticación de proximidad", - "description": "Por favor abra y cierre la puerta de Span Panel {remaining} veces." + "reconfigure_fqdn_failed": { + "description": "No se pudo registrar el nombre de dominio en el panel, o el certificado TLS no se actualizó a tiempo. Puede continuar, pero la conexión MQTT puede fallar si el certificado del panel no incluye su nombre de dominio.", + "title": "Registro de Dominio" }, - "auth_token": { - "title": "Autenticación de Token Manualmente", - "description": "Por favor, introduce tu token de acceso (deja en blanco para empezar de nuevo):", - "data": { - "access_token": "Token de Acceso" - } + "reconfigure_register_fqdn": { + "title": "Registrando Nombre de Dominio" }, - "simulator_config": { - "title": "Configuración del Simulador", - "description": "Seleccione una configuración de simulación y opcionalmente establezca un tiempo de inicio personalizado. {config_count} configuraciones disponibles.", + "register_fqdn": { + "title": "Registrando Nombre de Dominio" + }, + "user": { "data": { - "simulation_config": "Configuración de Simulación", - "host": "Host (Número de Serie)", - "simulation_start_time": "Tiempo de Inicio de Simulación (Opcional)", - "entity_naming_pattern": "Patrón de Nomenclatura de Entidades" + "enable_energy_dip_compensation": "Compensar Caídas de Energía Automáticamente", + "energy_display_precision": "Precisión de Visualización de Energía", + "host": "Host", + "http_port": "Puerto HTTP", + "power_display_precision": "Precisión de Visualización de Potencia" }, "data_description": { - "simulation_config": "Elija una configuración de simulación que defina plantillas de circuito y perfiles de energía", - "host": "Número de serie personalizado para el panel simulado (opcional, usará el predeterminado de la configuración si está vacío)", - "simulation_start_time": "Formato de tiempo: HH:MM (24h), H:MM (12h), o datetime ISO completo (YYYY-MM-DDTHH:MM:SS). Deje vacío para usar el tiempo actual.", - "entity_naming_pattern": "Elija cómo se nombran las entidades de circuito en la simulación" - } + "enable_energy_dip_compensation": "Compensar automáticamente cuando el panel reporta lecturas de energía más bajas, evitando picos en el panel de energía.", + "energy_display_precision": "Número de lugares decimales para valores de energía (0-3)", + "host": "Dirección IP o nombre de host del SPAN Panel", + "http_port": "Puerto HTTP para la API del panel (predeterminado: 80)", + "power_display_precision": "Número de lugares decimales para valores de potencia (0-3)" + }, + "description": "Ingrese los detalles de conexión para su SPAN Panel.", + "title": "Establecer conexión al Span Panel" } } }, - "options": { - "error": { - "directory": "Se requiere la ruta del archivo", - "base": "Falló la exportación: {error}" + "entity": { + "binary_sensor": { + "bess_connected": { + "name": "BESS Conectado" + }, + "door_state": { + "name": "Estado de la Puerta" + }, + "ethernet_link": { + "name": "Enlace Ethernet" + }, + "evse_charging": { + "name": "Cargando" + }, + "evse_ev_connected": { + "name": "VE Conectado" + }, + "grid_islandable": { + "name": "Aislable de la Red" + }, + "panel_status": { + "name": "Estado del Panel" + }, + "wifi_link": { + "name": "Enlace Wi-Fi" + } }, - "step": { - "init": { - "title": "Menú de Opciones", - "menu_options": { - "general_options": "Opciones Generales", - "entity_naming_options": "Opciones de Nomenclatura de Entidades", - "entity_naming": "Patrón de Nomenclatura de Entidades", - "export_config": "Exportar Configuración de Sensores Sintéticos", - "simulation_start_time": "Tiempo de Inicio de Simulación", - "simulation_offline_minutes": "Minutos Sin Conexión de Simulación" + "button": { + "gfe_override": { + "name": "Anulación GFE: Conectado a Red" + } + }, + "select": { + "circuit_priority": { + "name": "Prioridad del Circuito", + "state": { + "never": "Permanece encendido en un apagón", + "off_grid": "Se apaga en un apagón", + "soc_threshold": "Permanece encendido hasta el umbral de batería" } + } + }, + "sensor": { + "battery_level": { + "name": "Nivel de Batería" }, - "general_options": { - "title": "Opciones Generales", - "data": { - "scan_interval": "Intervalo de escaneo en segundos", - "enable_battery_percentage": "Habilitar Sensor de Porcentaje de Batería", - "enable_solar_circuit": "Habilitar Sensores de Inversor Solar", - "leg1": "Pata solar 1", - "leg2": "Pata solar 2", - "api_retries": "Reintentos de API (0-10)", - "api_retry_timeout": "Tiempo de espera de reintento de API (segundos)", - "api_retry_backoff_multiplier": "Multiplicador de retraso de reintento de API", - "energy_reporting_grace_period": "Período de Gracia del Sensor de Energía (minutos)", - "legacy_upgrade_to_friendly": "Add Device Name Prefix to Entity ID's", - "enable_panel_net_energy_sensors": "Energía Neta del Panel", - "enable_circuit_net_energy_sensors": "Energía Neta del Circuito", - "enable_solar_net_energy_sensors": "Energía Neta Solar" - }, - "data_description": { - "enable_solar_circuit": "Habilitar sensores sintéticos para monitoreo de inversor solar. Requiere selección de dos pestañas no mapeadas en fases eléctricas opuestas.", - "energy_reporting_grace_period": "Cuánto tiempo los sensores de energía mantienen su último valor conocido cuando el panel no está disponible (0-60 minutos). Ayuda a preservar la integridad de las estadísticas de energía durante cortes breves. Predeterminado: 15 minutos.", - "legacy_upgrade_to_friendly": "Warning: This irreversible operation will change your circuit entity IDs allowing support for more than one panel", - "enable_panel_net_energy_sensors": "Crear sensores de energía neta para flujos de energía a nivel de panel (medidor principal y derivación). Muestra el verdadero balance de energía contabilizando flujos bidireccionales.", - "enable_circuit_net_energy_sensors": "Crear sensores de energía neta para circuitos individuales. Muestra el verdadero consumo de energía contabilizando potencia reactiva y flujos regenerativos.", - "enable_solar_net_energy_sensors": "Crear sensores de energía neta para generación solar. Muestra la producción solar neta contabilizando cualquier consumo en circuitos solares." + "battery_power": { + "name": "Potencia de Batería" + }, + "bess_firmware_version": { + "name": "Versión de Firmware" + }, + "bess_model": { + "name": "Modelo" + }, + "bess_nameplate_capacity": { + "name": "Capacidad Nominal" + }, + "bess_serial_number": { + "name": "Número de Serie" + }, + "bess_soe_kwh": { + "name": "Estado de Energía" + }, + "bess_vendor": { + "name": "Fabricante" + }, + "current_run_config": { + "name": "Configuración de Ejecución Actual", + "state": { + "panel_backup": "Respaldo", + "panel_off_grid": "Desconectado de Red", + "panel_on_grid": "Conectado a Red", + "unknown": "Desconocido" } }, - "entity_naming_options": { - "title": "Opciones de Nomenclatura de Entidades", - "description": "Configure cómo se nombran los IDs de entidad. Elija entre nombres amigables (ej., kitchen_outlets_power) o números de pestañas de circuito (ej., circuit_15_power).", - "data": { - "legacy_upgrade_to_friendly": "Actualizar Instalación Legacy a Nombres Amigables", - "entity_naming_pattern": "Patrón de Nomenclatura de Entidades" - }, - "data_description": { - "legacy_upgrade_to_friendly": "Actualizar esta instalación legacy para usar nombres de entidad amigables con prefijo de dispositivo. Esto renombrará todas las entidades existentes.", - "entity_naming_pattern": "Elija cómo se construyen los IDs de entidad. Los nombres amigables usan nombres de circuito (ej., kitchen_outlets_power), mientras que los números de circuito usan posiciones de pestaña (ej., circuit_15_power)." + "downstream_l1_current": { + "name": "Corriente Descendente L1" + }, + "downstream_l2_current": { + "name": "Corriente Descendente L2" + }, + "dsm_grid_state": { + "name": "Estado de Red DSM", + "state": { + "dsm_off_grid": "Desconectado de Red", + "dsm_on_grid": "Conectado a Red", + "unknown": "Desconocido" } }, - "entity_naming": { - "title": "Patrón de Nomenclatura de Entidades", - "description": "Elija cómo se nombran las entidades de circuito. **Cambiar esto renombrará sus entidades existentes.**\n\n{friendly_example}\n\n{circuit_example}\n\n⚠️ **Importante**: \n• El historial de entidades se preservará durante el renombrado\n• Considere hacer una copia de seguridad de su configuración antes de proceder\n• Las automatizaciones y scripts pueden necesitar actualizaciones manuales para usar los nuevos IDs de entidad", - "data": { - "entity_naming_pattern": "Patrón de Nomenclatura de Entidades" + "dsm_state": { + "name": "Estado DSM", + "state": { + "dsm_off_grid": "Desconectado de Red", + "dsm_on_grid": "Conectado a Red", + "unknown": "Desconocido" } }, - "export_config": { - "title": "Exportar Configuración de Sensores Sintéticos", - "description": "Exportar la configuración actual de sensores sintéticos a un archivo YAML. Puede especificar un directorio (el archivo se nombrará automáticamente) o una ruta de archivo completa. Nombre de archivo predeterminado: {filename}", - "data": { - "directory": "Ruta del Archivo" - }, - "data_description": { - "directory": "Una ruta de directorio (el archivo se nombrará automáticamente) o una ruta de archivo completa que termine en .yaml donde se guardará la configuración." + "evse_advertised_current": { + "name": "Corriente Anunciada" + }, + "evse_lock_state": { + "name": "Estado de Bloqueo", + "state": { + "locked": "Bloqueado", + "unknown": "Desconocido", + "unlocked": "Desbloqueado" } }, - "simulation_start_time": { - "title": "Tiempo de Inicio de Simulación", - "description": "Configurar el tiempo de inicio de simulación. Los cambios en el tiempo de simulación recargarán la integración para aplicar el nuevo tiempo.", - "data": { - "simulation_start_time": "Tiempo de Inicio de Simulación" - }, - "data_description": { - "simulation_start_time": "Formato de tiempo: HH:MM (24h), H:MM (12h), o datetime ISO completo (YYYY-MM-DDTHH:MM:SS). Deje vacío para usar el tiempo actual. La integración se recargará para aplicar los cambios." + "evse_status": { + "name": "Estado del Cargador", + "state": { + "available": "Disponible", + "charging": "Cargando", + "faulted": "Averiado", + "finishing": "Finalizando", + "preparing": "Preparando", + "reserved": "Reservado", + "suspended_ev": "Suspendido por Vehículo", + "suspended_evse": "Suspendido por Cargador", + "unavailable": "No Disponible", + "unknown": "Desconocido" + } + }, + "feedthrough_consumed_energy": { + "name": "Energía Consumida de Derivación" + }, + "feedthrough_net_energy": { + "name": "Energía Neta de Derivación" + }, + "feedthrough_power": { + "name": "Potencia de Derivación" + }, + "feedthrough_produced_energy": { + "name": "Energía Producida de Derivación" + }, + "grid_forming_entity": { + "name": "Entidad Formadora de Red", + "state": { + "battery": "Batería", + "generator": "Generador", + "grid": "Red", + "none": "Ninguno", + "pv": "PV", + "unknown": "Desconocido" + } + }, + "grid_power_flow": { + "name": "Potencia de Red" + }, + "instant_grid_power": { + "name": "Potencia Actual" + }, + "l1_voltage": { + "name": "Voltaje L1" + }, + "l2_voltage": { + "name": "Voltaje L2" + }, + "main_breaker_rating": { + "name": "Capacidad del Interruptor Principal" + }, + "main_meter_consumed_energy": { + "name": "Energía Consumida del Medidor Principal" + }, + "main_meter_net_energy": { + "name": "Energía Neta del Medidor Principal" + }, + "main_meter_produced_energy": { + "name": "Energía Producida del Medidor Principal" + }, + "main_relay_state": { + "name": "Estado del Relé Principal", + "state": { + "closed": "Cerrado", + "open": "Abierto", + "unknown": "Desconocido" } }, - "simulation_offline_minutes": { - "title": "Minutos Sin Conexión de Simulación", - "description": "Configurar cuánto tiempo debe aparecer sin conexión el panel durante la simulación.", + "pv_nameplate_capacity": { + "name": "Capacidad Nominal PV" + }, + "pv_power": { + "name": "Potencia PV" + }, + "pv_product": { + "name": "Producto PV" + }, + "pv_vendor": { + "name": "Fabricante PV" + }, + "site_power": { + "name": "Potencia del Sitio" + }, + "software_version": { + "name": "Versión de Software" + }, + "upstream_l1_current": { + "name": "Corriente Ascendente L1" + }, + "upstream_l2_current": { + "name": "Corriente Ascendente L2" + }, + "vendor_cloud": { + "name": "Nube del Fabricante", + "state": { + "connected": "Conectado", + "unconnected": "Desconectado", + "unknown": "Desconocido" + } + } + } + }, + "exceptions": { + "circuit_priority_failed": { + "message": "Error al establecer la prioridad del circuito para {circuit}." + }, + "circuit_relay_failed": { + "message": "Error al establecer el estado del relé del circuito para {circuit}." + }, + "export_manifest_no_entries": { + "message": "No hay entradas de configuración de SPAN Panel cargadas. Agregue y configure un SPAN Panel antes de llamar a este servicio." + }, + "favorite_no_circuit_uuid": { + "message": "No se pudo derivar un objetivo favorito de la entidad {entity_id}. Seleccione un sensor de circuito (corriente/potencia) o un sensor de subdispositivo." + }, + "favorite_no_device": { + "message": "La entidad {entity_id} no está asociada a un dispositivo. Seleccione un sensor de circuito o subdispositivo SPAN." + }, + "favorite_no_unique_id": { + "message": "La entidad {entity_id} no tiene un id único y no se puede resolver a un objetivo SPAN." + }, + "favorite_not_span_entity": { + "message": "La entidad {entity_id} no es una entidad de SPAN Panel. Seleccione un sensor de circuito, batería (BESS) o cargador EV (EVSE)." + }, + "favorite_subdevice_no_span_parent": { + "message": "El subdispositivo {entity_id} no tiene un dispositivo SPAN Panel principal — su via_device_id no apunta a un panel SPAN." + }, + "gfe_override_failed": { + "message": "Error al enviar la anulación GFE al panel." + }, + "graph_horizon_not_available": { + "message": "El gestor del horizonte del gráfico no está disponible para el SPAN Panel seleccionado." + }, + "monitoring_not_enabled": { + "message": "Ningún SPAN Panel tiene el monitoreo de corriente habilitado." + }, + "panel_auth_failed": { + "message": "La autenticación con el SPAN Panel falló. Por favor vuelva a autenticarse." + }, + "panel_connection_failed": { + "message": "Error al conectar con el SPAN Panel en {host}." + }, + "panel_not_ready": { + "message": "El SPAN Panel no está listo. Por favor verifique que el panel esté en línea." + } + }, + "options": { + "step": { + "init": { "data": { - "simulation_offline_minutes": "Minutos Sin Conexión" + "show_panel": "Mostrar Panel en la Barra Lateral", + "panel_admin_only": "Solo Usuarios Administradores", + "enable_circuit_net_energy_sensors": "Energía Neta del Circuito", + "enable_energy_dip_compensation": "Compensar Caídas de Energía Automáticamente", + "enable_panel_net_energy_sensors": "Energía Neta del Panel", + "enable_unmapped_circuit_sensors": "Sensores de Circuitos No Asignados", + "energy_reporting_grace_period": "Período de Gracia del Sensor de Energía (minutos)", + "snapshot_update_interval": "Intervalo de Actualización de Snapshot (segundos)" }, "data_description": { - "simulation_offline_minutes": "Número de minutos que el panel debe aparecer sin conexión durante la simulación. Establezca en 0 para deshabilitar la simulación sin conexión." - } + "show_panel": "Mostrar el panel de control de SPAN como entrada de barra lateral.", + "panel_admin_only": "Restringir la entrada de la barra lateral del panel de control solo a usuarios administradores.", + "enable_circuit_net_energy_sensors": "Energía neta por circuito, contabilizando potencia reactiva.", + "enable_energy_dip_compensation": "Compensar cuando el panel reporta lecturas de energía más bajas. Al deshabilitar se borran las compensaciones.", + "enable_panel_net_energy_sensors": "Energía neta para medidor principal y derivación, contabilizando flujos bidireccionales.", + "enable_unmapped_circuit_sensors": "Sensores de respaldo para pestañas no asignadas a interruptores con nombre.", + "energy_reporting_grace_period": "Minutos que los sensores mantienen el último valor cuando el panel está fuera de línea (0–60). Predeterminado: 15.", + "snapshot_update_interval": "Segundos entre reconstrucciones de snapshot (0–15). Menor = más rápido, más CPU. 0 = sin debounce." + }, + "description": "Configurar los ajustes de la integración SPAN Panel.", + "title": "Opciones del Panel SPAN" } } }, "selector": { "entity_naming_pattern": { "options": { - "friendly_names": "Nombres Amigables (ej., span_panel_cocina_tomacorrientes_power)", - "circuit_numbers": "Números de Circuito (ej., span_panel_circuit_15_power)" + "circuit_numbers": "Números de Circuito (ej., span_panel_circuit_15_power)", + "friendly_names": "Nombres Amigables (ej., span_panel_cocina_tomacorrientes_power)" + } + } + }, + "services": { + "export_circuit_manifest": { + "description": "Devuelve un manifiesto estructurado de circuitos para todos los paneles SPAN configurados: serial, host e IDs de entidad de sensores de potencia por circuito, identificadores de plantilla de diseño (clone_{{tab}}), tipos de dispositivo y números de pestaña de interruptor. Destinado a tarjetas de panel y automatizaciones que necesitan la topología del panel sin consultar el registro de entidades. Este es un servicio de solo respuesta; la respuesta puede listar cero paneles si no se puede resolver nada.", + "name": "Exportar manifiesto de circuitos" + }, + "set_circuit_threshold": { + "name": "Establecer umbral de circuito", + "description": "Establecer umbrales de monitoreo de corriente para un circuito específico.", + "fields": { + "circuit_id": { + "name": "Circuito", + "description": "La entidad del sensor de potencia del circuito." + }, + "continuous_threshold_pct": { + "name": "Umbral continuo (%)", + "description": "Porcentaje de la capacidad del interruptor para alertas de carga continua." + }, + "spike_threshold_pct": { + "name": "Umbral de pico (%)", + "description": "Porcentaje de la capacidad del interruptor para alertas de pico instantáneo." + }, + "window_duration_m": { + "name": "Duración de ventana (minutos)", + "description": "Cuánto tiempo debe la corriente permanecer por encima del umbral antes de alertar." + }, + "cooldown_duration_m": { + "name": "Duración de enfriamiento (minutos)", + "description": "Minutos entre alertas repetidas para el mismo circuito." + }, + "monitoring_enabled": { + "name": "Monitoreo habilitado", + "description": "Habilitar o deshabilitar el monitoreo para este circuito." + }, + "config_entry_id": { + "name": "Entrada de configuración", + "description": "Id de entrada de configuración opcional. Solo necesario cuando hay más de un SPAN Panel configurado." + } + } + }, + "clear_circuit_threshold": { + "name": "Borrar umbral de circuito", + "description": "Eliminar las anulaciones de umbral por circuito, volviendo a los valores predeterminados globales.", + "fields": { + "circuit_id": { + "name": "Circuito", + "description": "La entidad del sensor de potencia del circuito." + }, + "config_entry_id": { + "name": "Entrada de configuración", + "description": "Id de entrada de configuración opcional. Solo necesario cuando hay más de un SPAN Panel configurado." + } + } + }, + "set_mains_threshold": { + "name": "Establecer umbral de alimentación principal", + "description": "Establecer umbrales de monitoreo de corriente para una fase de alimentación principal específica.", + "fields": { + "leg": { + "name": "Fase de alimentación principal", + "description": "La entidad del sensor de corriente de alimentación principal." + }, + "continuous_threshold_pct": { + "name": "Umbral continuo (%)", + "description": "Porcentaje de la capacidad del interruptor para alertas de carga continua." + }, + "spike_threshold_pct": { + "name": "Umbral de pico (%)", + "description": "Porcentaje de la capacidad del interruptor para alertas de pico instantáneo." + }, + "window_duration_m": { + "name": "Duración de ventana (minutos)", + "description": "Cuánto tiempo debe la corriente permanecer por encima del umbral antes de alertar." + }, + "cooldown_duration_m": { + "name": "Duración de enfriamiento (minutos)", + "description": "Minutos entre alertas repetidas para la misma fase de alimentación principal." + }, + "monitoring_enabled": { + "name": "Monitoreo habilitado", + "description": "Habilitar o deshabilitar el monitoreo para esta fase de alimentación principal." + }, + "config_entry_id": { + "name": "Entrada de configuración", + "description": "Id de entrada de configuración opcional. Solo necesario cuando hay más de un SPAN Panel configurado." + } + } + }, + "clear_mains_threshold": { + "name": "Borrar umbral de alimentación principal", + "description": "Eliminar las anulaciones de umbral por fase de alimentación principal, volviendo a los valores predeterminados globales.", + "fields": { + "leg": { + "name": "Fase de alimentación principal", + "description": "La entidad del sensor de corriente de alimentación principal." + }, + "config_entry_id": { + "name": "Entrada de configuración", + "description": "Id de entrada de configuración opcional. Solo necesario cuando hay más de un SPAN Panel configurado." + } + } + }, + "get_monitoring_status": { + "name": "Obtener estado de monitoreo", + "description": "Devuelve el estado actual de monitoreo para todos los circuitos y fases de alimentación principal rastreados.", + "fields": { + "config_entry_id": { + "name": "Entrada de configuración", + "description": "Id de entrada de configuración opcional. Solo necesario cuando hay más de un SPAN Panel configurado." + } + } + }, + "set_global_monitoring": { + "name": "Configurar monitoreo global", + "description": "Actualizar los umbrales de monitoreo global y la configuración de notificaciones.", + "fields": { + "enabled": { + "name": "Habilitado", + "description": "Habilitar o deshabilitar el monitoreo de corriente globalmente para este SPAN Panel." + }, + "continuous_threshold_pct": { + "name": "Umbral continuo", + "description": "Porcentaje de la capacidad del interruptor para la detección de sobrecarga continua." + }, + "spike_threshold_pct": { + "name": "Umbral de pico", + "description": "Porcentaje de la capacidad del interruptor para la detección de picos." + }, + "window_duration_m": { + "name": "Duración de la ventana", + "description": "Minutos de sobrecarga sostenida antes de generar una alerta." + }, + "cooldown_duration_m": { + "name": "Duración de enfriamiento", + "description": "Minutos entre alertas repetidas para el mismo punto." + }, + "notify_targets": { + "name": "Destinos de notificación", + "description": "Lista separada por comas de destinos de servicios de notificación." + }, + "notification_title_template": { + "name": "Plantilla de título de notificación", + "description": "Plantilla para el título de la notificación. Marcadores de posición: {name}, {entity_id}, {alert_type}, {current_a}, {breaker_rating_a}, {threshold_pct}, {utilization_pct}, {window_m}." + }, + "notification_message_template": { + "name": "Plantilla de mensaje de notificación", + "description": "Plantilla para el cuerpo del mensaje de notificación. Marcadores de posición: {name}, {entity_id}, {alert_type}, {current_a}, {breaker_rating_a}, {threshold_pct}, {utilization_pct}, {window_m}." + }, + "notification_priority": { + "name": "Prioridad de notificación", + "description": "Nivel de prioridad de la notificación push. Controla el comportamiento de interrupción en dispositivos móviles (iOS: interruption-level, Android: priority/canal)." + }, + "config_entry_id": { + "name": "Entrada de configuración", + "description": "Id de entrada de configuración opcional. Solo necesario cuando hay más de un SPAN Panel configurado." + } + } + }, + "test_notification": { + "name": "Notificación de prueba", + "description": "Enviar una notificación de prueba a todos los destinos configurados con valores de ejemplo.", + "fields": { + "config_entry_id": { + "name": "Entrada de configuración", + "description": "Id de entrada de configuración opcional. Solo necesario cuando hay más de un SPAN Panel configurado." + } + } + }, + "set_graph_time_horizon": { + "name": "Establecer horizonte temporal del gráfico", + "description": "Establece el horizonte temporal predeterminado global para los gráficos de circuitos.", + "fields": { + "horizon": { + "name": "Horizonte", + "description": "Ventana de tiempo preestablecida para la visualización del gráfico." + }, + "config_entry_id": { + "name": "Entrada de configuración", + "description": "Id de entrada de configuración opcional. Solo necesario cuando hay más de un SPAN Panel configurado." + } + } + }, + "set_circuit_graph_horizon": { + "name": "Establecer horizonte de gráfico por circuito", + "description": "Establece una anulación de horizonte temporal por circuito para los gráficos.", + "fields": { + "circuit_id": { + "name": "ID del circuito", + "description": "El identificador del circuito a anular." + }, + "horizon": { + "name": "Horizonte", + "description": "Ventana de tiempo preestablecida para la visualización del gráfico." + }, + "config_entry_id": { + "name": "Entrada de configuración", + "description": "Id de entrada de configuración opcional. Solo necesario cuando hay más de un SPAN Panel configurado." + } + } + }, + "clear_circuit_graph_horizon": { + "name": "Borrar horizonte de gráfico del circuito", + "description": "Elimina la anulación del horizonte de gráfico por circuito, volviendo al valor predeterminado global.", + "fields": { + "circuit_id": { + "name": "ID del circuito", + "description": "El identificador del circuito para borrar la anulación." + }, + "config_entry_id": { + "name": "Entrada de configuración", + "description": "Id de entrada de configuración opcional. Solo necesario cuando hay más de un SPAN Panel configurado." + } + } + }, + "set_subdevice_graph_horizon": { + "name": "Establecer horizonte de gráfico por subdispositivo", + "description": "Establece una anulación de horizonte temporal por subdispositivo para los gráficos.", + "fields": { + "subdevice_id": { + "name": "ID del subdispositivo", + "description": "El identificador del subdispositivo a anular." + }, + "horizon": { + "name": "Horizonte", + "description": "Ventana de tiempo preestablecida para la visualización del gráfico." + }, + "config_entry_id": { + "name": "Entrada de configuración", + "description": "Id de entrada de configuración opcional. Solo necesario cuando hay más de un SPAN Panel configurado." + } + } + }, + "clear_subdevice_graph_horizon": { + "name": "Borrar horizonte de gráfico del subdispositivo", + "description": "Elimina la anulación del horizonte de gráfico por subdispositivo, volviendo al valor predeterminado global.", + "fields": { + "subdevice_id": { + "name": "ID del subdispositivo", + "description": "El identificador del subdispositivo para borrar la anulación." + }, + "config_entry_id": { + "name": "Entrada de configuración", + "description": "Id de entrada de configuración opcional. Solo necesario cuando hay más de un SPAN Panel configurado." + } + } + }, + "get_graph_settings": { + "name": "Obtener configuración de gráficos", + "description": "Devuelve la configuración actual de horizonte de gráficos global y por circuito.", + "fields": { + "config_entry_id": { + "name": "Entrada de configuración", + "description": "Id de entrada de configuración opcional. Solo necesario cuando hay más de un SPAN Panel configurado." + } + } + }, + "get_favorites": { + "name": "Obtener favoritos", + "description": "Devuelve el mapa de favoritos entre paneles, indexado por id de dispositivo del panel SPAN." + }, + "add_favorite": { + "name": "Añadir favorito", + "description": "Marca un circuito SPAN, batería (BESS) o cargador EV (EVSE) como favorito para que aparezca en la vista de Favoritos del dashboard.", + "fields": { + "entity_id": { + "name": "Entidad", + "description": "Cualquier sensor del circuito o subdispositivo a marcar como favorito (corriente, potencia, SoC, etc.)." + } + } + }, + "remove_favorite": { + "name": "Eliminar favorito", + "description": "Elimina un circuito o subdispositivo de la vista de Favoritos entre paneles.", + "fields": { + "entity_id": { + "name": "Entidad", + "description": "Cualquier sensor del circuito o subdispositivo a eliminar de favoritos." + } } } } diff --git a/custom_components/span_panel/translations/fr.json b/custom_components/span_panel/translations/fr.json index 14d56965..712bef06 100644 --- a/custom_components/span_panel/translations/fr.json +++ b/custom_components/span_panel/translations/fr.json @@ -1,175 +1,697 @@ { "config": { "abort": { - "no_devices_found": "Aucun appareil trouvé sur le réseau", "already_configured": "Span Panel déjà configuré. Une seule configuration est possible.", - "reauth_successful": "Authentification réussie." + "cannot_connect": "Échec de la connexion au Span Panel", + "no_devices_found": "Aucun appareil trouvé sur le réseau", + "no_host": "Aucune adresse d'hôte n'a été fournie par le module complémentaire.", + "no_serial": "Impossible de déterminer le numéro de série du panneau.", + "reauth_successful": "Authentification réussie.", + "reconfigure_successful": "Adresse de l'hôte mise à jour avec succès.", + "unique_id_mismatch": "Le panneau à cette adresse a un numéro de série différent. Vous ne pouvez pas passer à un panneau différent lors de la reconfiguration.", + "v1_not_supported": "Le micrologiciel v1 n'est pas pris en charge. Veuillez mettre à jour le micrologiciel de votre panneau vers la version v2." }, "error": { "cannot_connect": "Échec de la connexion au Span Panel", + "fqdn_registration_failed": "Impossible d'enregistrer le nom de domaine auprès du panneau ou le certificat TLS n'a pas été mis à jour à temps.", + "host_required": "L'hôte est requis", "invalid_auth": "Authentification invalide", - "unknown": "Erreur inattendue", - "host_required": "L'hôte est requis pour les connexions de panneau en direct" + "proximity_failed": "Proximité non prouvée. Veuillez ouvrir et fermer la porte du panneau 3 fois et réessayer.", + "unknown": "Erreur inattendue" }, "flow_title": "Span Panel ({host})", + "progress": { + "registering_fqdn": "Enregistrement du nom de domaine auprès du panneau et attente de la mise à jour du certificat TLS. Cela peut prendre jusqu'à 60 secondes..." + }, "step": { + "auth_passphrase": { + "data": { + "hop_passphrase": "Mot de passe" + }, + "data_description": { + "hop_passphrase": "Le mot de passe utilisé pour s'authentifier avec l'API v2 du SPAN Panel" + }, + "description": "Entrez le mot de passe de votre SPAN Panel. Il est imprimé sur l'étiquette du panneau ou défini par le propriétaire du panneau.", + "title": "Mot de passe du panneau" + }, + "auth_proximity": { + "description": "Ouvrez et fermez la porte du SPAN Panel 3 fois, puis sélectionnez la confirmation ci-dessous.", + "menu_options": { + "auth_passphrase": "Utiliser le mot de passe à la place", + "auth_proximity_confirm": "J'ai ouvert et fermé la porte" + }, + "title": "Authentification de proximité" + }, + "auth_proximity_confirm": { + "description": "Vérification de la proximité avec le panneau.", + "title": "Authentification de proximité" + }, + "choose_entity_naming_initial": { + "data": { + "entity_naming_pattern": "Modèle de nommage" + }, + "data_description": { + "entity_naming_pattern": "Les noms conviviaux utilisent les étiquettes de circuit du panneau. Les noms basés sur les onglets utilisent les numéros d'onglets de disjoncteur pour des identifiants d'entité stables." + }, + "description": "Choisissez comment les entités liées aux circuits sont nommées dans Home Assistant.", + "title": "Nommage des entités de circuit" + }, + "choose_v2_auth": { + "menu_options": { + "auth_passphrase": "Entrer le mot de passe du panneau", + "auth_proximity": "Preuve de proximité (ouvrir/fermer la porte)" + }, + "title": "Choisir la méthode d'authentification" + }, "confirm_discovery": { "description": "Voulez-vous configurer Span Panel sur {host} ?", "title": "Connectez-vous au Span Panel" }, - "user": { + "fqdn_failed": { + "description": "Le nom de domaine n'a pas pu être enregistré auprès du panneau, ou le certificat TLS n'a pas été mis à jour à temps. Vous pouvez continuer, mais la connexion MQTT peut échouer si le certificat du panneau n'inclut pas votre nom de domaine.", + "title": "Enregistrement du domaine" + }, + "reauth_confirm": { + "description": "Le Span Panel à {host} nécessite une ré-authentification.", + "menu_options": { + "auth_passphrase": "Entrer le mot de passe du panneau", + "auth_proximity": "Preuve de proximité (ouvrir/fermer la porte)" + }, + "title": "Ré-authentifier le Span Panel" + }, + "reconfigure": { "data": { - "host": "Host", - "use_ssl": "Utiliser SSL", - "simulator_mode": "Mode Simulateur", - "power_display_precision": "Précision d'Affichage de Puissance", - "energy_display_precision": "Précision d'Affichage d'Énergie" + "host": "Hôte" }, "data_description": { - "host": "Adresse IP ou nom d'hôte du SPAN Panel (pour la simulation : utilisez un numéro de série comme 'sp3-simulation-001' ou laissez vide pour la valeur par défaut)", - "use_ssl": "Activer la connexion HTTPS sécurisée au SPAN Panel", - "simulator_mode": "Activer le mode simulateur pour les tests (aucun matériel requis). Laissez l'hôte vide pour la simulation par défaut, ou entrez un numéro de série personnalisé.", - "power_display_precision": "Nombre de décimales pour les valeurs de puissance (0-3)", - "energy_display_precision": "Nombre de décimales pour les valeurs d'énergie (0-3)" + "host": "Adresse IP ou nom d'hôte du SPAN Panel" }, - "description": "Entrez les détails de connexion pour votre SPAN Panel. Pour le mode simulateur, cochez la case simulateur et entrez optionnellement un numéro de série personnalisé.", - "title": "Connectez-vous au Span Panel" + "description": "Mettez à jour l'adresse de l'hôte pour ce SPAN Panel. Le numéro de série du panneau doit correspondre.", + "title": "Reconfigurer le SPAN Panel" }, - "choose_auth_type": { - "title": "Choisir les options d'authentification", - "menu_options": { - "auth_proximity": "Authentifiez-vous via votre Span Panel physique", - "auth_token": "Authentifier à l'aide d'un jeton d'accès" - } + "reconfigure_fqdn_done": { + "title": "Reconfiguration terminée" }, - "auth_proximity": { - "title": "Authentification de proximité", - "description": "Veuillez ouvrir et fermer la porte du Span Panel les fois {remaining}." + "reconfigure_fqdn_failed": { + "description": "Le nom de domaine n'a pas pu être enregistré auprès du panneau, ou le certificat TLS n'a pas été mis à jour à temps. Vous pouvez continuer, mais la connexion MQTT peut échouer si le certificat du panneau n'inclut pas votre nom de domaine.", + "title": "Enregistrement du domaine" }, - "auth_token": { - "title": "Authentification manuelle du jeton", - "description": "Vieuillez entrer votre jeton d'accès (laissez vide pour recommencer):", - "data": { - "access_token": "Jeton d'accès" - } + "reconfigure_register_fqdn": { + "title": "Enregistrement du nom de domaine" + }, + "register_fqdn": { + "title": "Enregistrement du nom de domaine" }, - "simulator_config": { - "title": "Configuration du Simulateur", - "description": "Sélectionnez une configuration de simulation et définissez optionnellement un heure de début personnalisée. {config_count} configurations disponibles.", + "user": { "data": { - "simulation_config": "Configuration de Simulation", - "host": "Host (Numéro de Série)", - "simulation_start_time": "Heure de Début de Simulation (Optionnel)", - "entity_naming_pattern": "Modèle de Nommage des Entités" + "enable_energy_dip_compensation": "Compenser les Baisses d'Énergie Automatiquement", + "energy_display_precision": "Précision d'Affichage d'Énergie", + "host": "Hôte", + "http_port": "Port HTTP", + "power_display_precision": "Précision d'Affichage de Puissance" }, "data_description": { - "simulation_config": "Choisissez une configuration de simulation qui définit les modèles de circuit et les profils d'énergie", - "host": "Numéro de série personnalisé pour le panneau simulé (optionnel, utilisera la valeur par défaut de la configuration si vide)", - "simulation_start_time": "Format d'heure : HH:MM (24h), H:MM (12h), ou datetime ISO complet (YYYY-MM-DDTHH:MM:SS). Laissez vide pour utiliser l'heure actuelle.", - "entity_naming_pattern": "Choisissez comment les entités de circuit sont nommées dans la simulation" - } + "enable_energy_dip_compensation": "Compenser automatiquement lorsque le panneau signale des lectures d'énergie inférieures, évitant les pics dans le tableau de bord énergétique.", + "energy_display_precision": "Nombre de décimales pour les valeurs d'énergie (0-3)", + "host": "Adresse IP ou nom d'hôte du SPAN Panel", + "http_port": "Port HTTP pour l'API du panneau (par défaut : 80)", + "power_display_precision": "Nombre de décimales pour les valeurs de puissance (0-3)" + }, + "description": "Entrez les détails de connexion pour votre SPAN Panel.", + "title": "Connectez-vous au Span Panel" } } }, - "options": { - "error": { - "directory": "Le chemin du fichier est requis", - "base": "Échec de l'exportation : {error}" + "entity": { + "binary_sensor": { + "bess_connected": { + "name": "BESS Connecté" + }, + "door_state": { + "name": "État de la Porte" + }, + "ethernet_link": { + "name": "Liaison Ethernet" + }, + "evse_charging": { + "name": "En charge" + }, + "evse_ev_connected": { + "name": "VE Connecté" + }, + "grid_islandable": { + "name": "Îlotage Réseau Possible" + }, + "panel_status": { + "name": "État du Panneau" + }, + "wifi_link": { + "name": "Liaison Wi-Fi" + } }, - "step": { - "init": { - "title": "Menu des Options", - "menu_options": { - "general_options": "Options Générales", - "entity_naming_options": "Options de Nommage des Entités", - "entity_naming": "Modèle de Nommage des Entités", - "export_config": "Exporter la Configuration des Capteurs Synthétiques", - "simulation_start_time": "Heure de Début de Simulation", - "simulation_offline_minutes": "Minutes Hors Ligne de Simulation" + "button": { + "gfe_override": { + "name": "Forçage GFE : Connecté au Réseau" + } + }, + "select": { + "circuit_priority": { + "name": "Priorité du Circuit", + "state": { + "never": "Reste allumé en cas de panne", + "off_grid": "S'éteint en cas de panne", + "soc_threshold": "Reste allumé jusqu'au seuil de batterie" } + } + }, + "sensor": { + "battery_level": { + "name": "Niveau de Batterie" }, - "general_options": { - "title": "Options Générales", - "data": { - "scan_interval": "Intervalle d'analyse en secondes", - "enable_battery_percentage": "Activer le Capteur de Pourcentage de Batterie", - "enable_solar_circuit": "Activer les capteurs de l'onduleur solaire", - "leg1": "Jambe solaire 1", - "leg2": "Jambe solaire 2", - "api_retries": "Tentatives API (0-10)", - "api_retry_timeout": "Délai de tentative API (secondes)", - "api_retry_backoff_multiplier": "Multiplicateur de délai de tentative API", - "energy_reporting_grace_period": "Période de grâce du capteur d'énergie (minutes)", - "legacy_upgrade_to_friendly": "Add Device Name Prefix to Entity ID's", - "enable_panel_net_energy_sensors": "Énergie Nette du Panneau", - "enable_circuit_net_energy_sensors": "Énergie Nette du Circuit", - "enable_solar_net_energy_sensors": "Énergie Nette Solaire" - }, - "data_description": { - "enable_solar_circuit": "Activer les capteurs synthétiques pour la surveillance de l'onduleur solaire. Nécessite la sélection de deux onglets non mappés sur des phases électriques opposées.", - "energy_reporting_grace_period": "Combien de temps les capteurs d'énergie maintiennent leur dernière valeur connue lorsque le panneau devient indisponible (0-60 minutes). Aide à préserver l'intégrité des statistiques d'énergie pendant les pannes brèves. Par défaut : 15 minutes.", - "legacy_upgrade_to_friendly": "Warning: This irreversible operation will change your circuit entity IDs allowing support for more than one panel", - "enable_panel_net_energy_sensors": "Créer des capteurs d'énergie nette pour les flux d'énergie au niveau du panneau (compteur principal et dérivation). Affiche le vrai bilan énergétique en tenant compte des flux bidirectionnels.", - "enable_circuit_net_energy_sensors": "Créer des capteurs d'énergie nette pour les circuits individuels. Affiche la vraie consommation d'énergie en tenant compte de la puissance réactive et des flux régénératifs.", - "enable_solar_net_energy_sensors": "Créer des capteurs d'énergie nette pour la génération solaire. Affiche la production solaire nette en tenant compte de toute consommation sur les circuits solaires." + "battery_power": { + "name": "Puissance de la Batterie" + }, + "bess_firmware_version": { + "name": "Version du Micrologiciel" + }, + "bess_model": { + "name": "Modèle" + }, + "bess_nameplate_capacity": { + "name": "Capacité Nominale" + }, + "bess_serial_number": { + "name": "Numéro de Série" + }, + "bess_soe_kwh": { + "name": "État d'Énergie" + }, + "bess_vendor": { + "name": "Fournisseur" + }, + "current_run_config": { + "name": "Configuration Active", + "state": { + "panel_backup": "Secours", + "panel_off_grid": "Hors Réseau", + "panel_on_grid": "Sur Réseau", + "unknown": "Inconnu" } }, - "entity_naming_options": { - "title": "Options de Nommage des Entités", - "description": "Configurez comment les IDs d'entité sont nommés. Choisissez entre des noms conviviaux (ex., kitchen_outlets_power) ou des numéros d'onglets de circuit (ex., circuit_15_power).", - "data": { - "legacy_upgrade_to_friendly": "Mettre à niveau l'Installation Legacy vers les Noms Conviviaux", - "entity_naming_pattern": "Modèle de Nommage des Entités" - }, - "data_description": { - "legacy_upgrade_to_friendly": "Mettre à niveau cette installation legacy pour utiliser des noms d'entité conviviaux avec préfixe de dispositif. Cela renommera toutes les entités existantes.", - "entity_naming_pattern": "Choisissez comment les IDs d'entité sont construits. Les noms conviviaux utilisent les noms de circuit (ex., kitchen_outlets_power), tandis que les numéros de circuit utilisent les positions d'onglet (ex., circuit_15_power)." + "downstream_l1_current": { + "name": "Courant Aval L1" + }, + "downstream_l2_current": { + "name": "Courant Aval L2" + }, + "dsm_grid_state": { + "name": "État Réseau DSM", + "state": { + "dsm_off_grid": "Hors Réseau", + "dsm_on_grid": "Sur Réseau", + "unknown": "Inconnu" } }, - "entity_naming": { - "title": "Modèle de Nommage des Entités", - "description": "Choisissez comment les entités de circuit sont nommées. **Changer ceci renommera vos entités existantes.**\n\n{friendly_example}\n\n{circuit_example}\n\n⚠️ **Important**: \n• L'historique des entités sera préservé pendant le renommage\n• Considérez sauvegarder votre configuration avant de procéder\n• Les automatisations et scripts peuvent nécessiter des mises à jour manuelles pour utiliser les nouveaux IDs d'entité", - "data": { - "entity_naming_pattern": "Modèle de Nommage des Entités" + "dsm_state": { + "name": "État DSM", + "state": { + "dsm_off_grid": "Hors Réseau", + "dsm_on_grid": "Sur Réseau", + "unknown": "Inconnu" } }, - "export_config": { - "title": "Exporter la Configuration des Capteurs Synthétiques", - "description": "Exporter la configuration actuelle des capteurs synthétiques vers un fichier YAML. Vous pouvez spécifier un répertoire (le fichier sera nommé automatiquement) ou un chemin de fichier complet. Nom de fichier par défaut : {filename}", - "data": { - "directory": "Chemin du Fichier" - }, - "data_description": { - "directory": "Un chemin de répertoire (le fichier sera nommé automatiquement) ou un chemin de fichier complet se terminant par .yaml où la configuration sera sauvegardée." + "evse_advertised_current": { + "name": "Courant Annoncé" + }, + "evse_lock_state": { + "name": "État du Verrouillage", + "state": { + "locked": "Verrouillé", + "unknown": "Inconnu", + "unlocked": "Déverrouillé" } }, - "simulation_start_time": { - "title": "Heure de Début de Simulation", - "description": "Configurer l'heure de début de simulation. Les modifications de l'heure de simulation rechargeront l'intégration pour appliquer la nouvelle heure.", - "data": { - "simulation_start_time": "Heure de Début de Simulation" - }, - "data_description": { - "simulation_start_time": "Format d'heure : HH:MM (24h), H:MM (12h), ou datetime ISO complet (YYYY-MM-DDTHH:MM:SS). Laissez vide pour utiliser l'heure actuelle. L'intégration se rechargera pour appliquer les changements." + "evse_status": { + "name": "État du Chargeur", + "state": { + "available": "Disponible", + "charging": "En charge", + "faulted": "En défaut", + "finishing": "Finalisation", + "preparing": "En préparation", + "reserved": "Réservé", + "suspended_ev": "Suspendu par le Véhicule", + "suspended_evse": "Suspendu par le Chargeur", + "unavailable": "Indisponible", + "unknown": "Inconnu" + } + }, + "feedthrough_consumed_energy": { + "name": "Énergie Consommée en Dérivation" + }, + "feedthrough_net_energy": { + "name": "Énergie Nette en Dérivation" + }, + "feedthrough_power": { + "name": "Puissance en Dérivation" + }, + "feedthrough_produced_energy": { + "name": "Énergie Produite en Dérivation" + }, + "grid_forming_entity": { + "name": "Entité Formatrice du Réseau", + "state": { + "battery": "Batterie", + "generator": "Générateur", + "grid": "Réseau", + "none": "Aucun", + "pv": "PV", + "unknown": "Inconnu" + } + }, + "grid_power_flow": { + "name": "Puissance du Réseau" + }, + "instant_grid_power": { + "name": "Puissance Actuelle" + }, + "l1_voltage": { + "name": "Tension L1" + }, + "l2_voltage": { + "name": "Tension L2" + }, + "main_breaker_rating": { + "name": "Calibre du Disjoncteur Principal" + }, + "main_meter_consumed_energy": { + "name": "Énergie Consommée du Compteur Principal" + }, + "main_meter_net_energy": { + "name": "Énergie Nette du Compteur Principal" + }, + "main_meter_produced_energy": { + "name": "Énergie Produite du Compteur Principal" + }, + "main_relay_state": { + "name": "État du Relais Principal", + "state": { + "closed": "Fermé", + "open": "Ouvert", + "unknown": "Inconnu" } }, - "simulation_offline_minutes": { - "title": "Minutes Hors Ligne de Simulation", - "description": "Configurer combien de temps le panneau doit apparaître hors ligne pendant la simulation.", + "pv_nameplate_capacity": { + "name": "Capacité Nominale PV" + }, + "pv_power": { + "name": "Puissance PV" + }, + "pv_product": { + "name": "Produit PV" + }, + "pv_vendor": { + "name": "Fournisseur PV" + }, + "site_power": { + "name": "Puissance du Site" + }, + "software_version": { + "name": "Version du Logiciel" + }, + "upstream_l1_current": { + "name": "Courant Amont L1" + }, + "upstream_l2_current": { + "name": "Courant Amont L2" + }, + "vendor_cloud": { + "name": "Cloud du Fournisseur", + "state": { + "connected": "Connecté", + "unconnected": "Déconnecté", + "unknown": "Inconnu" + } + } + } + }, + "exceptions": { + "circuit_priority_failed": { + "message": "Échec de la définition de la priorité du circuit pour {circuit}." + }, + "circuit_relay_failed": { + "message": "Échec de la définition de l'état du relais du circuit pour {circuit}." + }, + "export_manifest_no_entries": { + "message": "Aucune entrée de configuration SPAN Panel n'est chargée. Ajoutez et configurez un panneau SPAN avant d'appeler ce service." + }, + "favorite_no_circuit_uuid": { + "message": "Impossible de dériver une cible favorite à partir de l'entité {entity_id}. Sélectionnez un capteur de circuit (courant/puissance) ou un capteur de sous-appareil." + }, + "favorite_no_device": { + "message": "L'entité {entity_id} n'est associée à aucun appareil. Sélectionnez un capteur de circuit ou de sous-appareil SPAN." + }, + "favorite_no_unique_id": { + "message": "L'entité {entity_id} n'a pas d'identifiant unique et ne peut pas être résolue vers une cible SPAN." + }, + "favorite_not_span_entity": { + "message": "L'entité {entity_id} n'est pas une entité SPAN Panel. Sélectionnez un capteur de circuit, de batterie (BESS) ou de chargeur EV (EVSE)." + }, + "favorite_subdevice_no_span_parent": { + "message": "Le sous-appareil {entity_id} n'a pas de panneau SPAN parent — son via_device_id ne pointe pas vers un panneau SPAN." + }, + "gfe_override_failed": { + "message": "Échec de l'envoi du forçage GFE au panneau." + }, + "graph_horizon_not_available": { + "message": "Le gestionnaire d'horizon de graphique n'est pas disponible pour le panneau SPAN sélectionné." + }, + "monitoring_not_enabled": { + "message": "Aucun panneau SPAN n'a la surveillance de courant activée." + }, + "panel_auth_failed": { + "message": "L'authentification avec le SPAN Panel a échoué. Veuillez vous réauthentifier." + }, + "panel_connection_failed": { + "message": "Échec de la connexion au SPAN Panel à {host}." + }, + "panel_not_ready": { + "message": "Le SPAN Panel n'est pas prêt. Veuillez vérifier que le panneau est en ligne." + } + }, + "options": { + "step": { + "init": { "data": { - "simulation_offline_minutes": "Minutes Hors Ligne" + "show_panel": "Afficher le Panneau dans la Barre Latérale", + "panel_admin_only": "Utilisateurs Administrateurs Uniquement", + "enable_circuit_net_energy_sensors": "Énergie Nette du Circuit", + "enable_energy_dip_compensation": "Compenser les Baisses d'Énergie Automatiquement", + "enable_panel_net_energy_sensors": "Énergie Nette du Panneau", + "enable_unmapped_circuit_sensors": "Capteurs de Circuits Non Assignés", + "energy_reporting_grace_period": "Période de grâce du capteur d'énergie (minutes)", + "snapshot_update_interval": "Intervalle de Mise à Jour du Snapshot (secondes)" }, "data_description": { - "simulation_offline_minutes": "Nombre de minutes pendant lesquelles le panneau doit apparaître hors ligne pendant la simulation. Définissez sur 0 pour désactiver la simulation hors ligne." - } + "show_panel": "Afficher le tableau de bord du panneau SPAN comme entrée de barre latérale.", + "panel_admin_only": "Restreindre l'entrée de la barre latérale du tableau de bord aux utilisateurs administrateurs.", + "enable_circuit_net_energy_sensors": "Énergie nette par circuit, tenant compte de la puissance réactive.", + "enable_energy_dip_compensation": "Compenser lorsque le panneau signale des lectures d'énergie inférieures. La désactivation efface les compensations.", + "enable_panel_net_energy_sensors": "Énergie nette pour le compteur principal et la dérivation, tenant compte des flux bidirectionnels.", + "enable_unmapped_circuit_sensors": "Capteurs sous-jacents pour les onglets non assignés à des disjoncteurs nommés.", + "energy_reporting_grace_period": "Minutes pendant lesquelles les capteurs conservent leur dernière valeur lorsque le panneau est hors ligne (0–60). Par défaut : 15.", + "snapshot_update_interval": "Secondes entre les reconstructions de snapshot (0–15). Plus bas = plus rapide, plus de CPU. 0 = pas de temporisation." + }, + "description": "Configurer les paramètres de l'intégration SPAN Panel.", + "title": "Options du panneau SPAN" } } }, "selector": { "entity_naming_pattern": { "options": { - "friendly_names": "Noms Conviviaux (ex., span_panel_cuisine_prises_power)", - "circuit_numbers": "Numéros de Circuit (ex., span_panel_circuit_15_power)" + "circuit_numbers": "Numéros de Circuit (ex., span_panel_circuit_15_power)", + "friendly_names": "Noms Conviviaux (ex., span_panel_cuisine_prises_power)" + } + } + }, + "services": { + "export_circuit_manifest": { + "description": "Retourne un manifeste structuré des circuits pour tous les panneaux SPAN configurés : numéro de série, hôte, identifiants d'entité des capteurs de puissance par circuit, identifiants de modèle de disposition (clone_{{tab}}), types d'appareils et numéros d'onglets de disjoncteur. Destiné aux cartes de tableau de bord et aux automatisations nécessitant la topologie du panneau sans interroger le registre d'entités. Il s'agit d'un service en réponse seule ; la réponse peut lister zéro panneau si rien ne peut être résolu.", + "name": "Exporter le manifeste des circuits" + }, + "set_circuit_threshold": { + "name": "Définir le seuil du circuit", + "description": "Définir les seuils de surveillance du courant pour un circuit spécifique.", + "fields": { + "circuit_id": { + "name": "Circuit", + "description": "L'entité capteur de puissance du circuit." + }, + "continuous_threshold_pct": { + "name": "Seuil continu (%)", + "description": "Pourcentage du calibre du disjoncteur pour les alertes de charge continue." + }, + "spike_threshold_pct": { + "name": "Seuil de pic (%)", + "description": "Pourcentage du calibre du disjoncteur pour les alertes de pic instantané." + }, + "window_duration_m": { + "name": "Durée de la fenêtre (minutes)", + "description": "Durée pendant laquelle le courant doit rester au-dessus du seuil avant déclenchement de l'alerte." + }, + "cooldown_duration_m": { + "name": "Durée de refroidissement (minutes)", + "description": "Minutes entre les alertes répétées pour le même circuit." + }, + "monitoring_enabled": { + "name": "Surveillance activée", + "description": "Activer ou désactiver la surveillance pour ce circuit." + }, + "config_entry_id": { + "name": "Entrée de configuration", + "description": "Id d'entrée de configuration optionnel. Nécessaire uniquement si plusieurs panneaux SPAN sont configurés." + } + } + }, + "clear_circuit_threshold": { + "name": "Effacer le seuil du circuit", + "description": "Supprimer les seuils personnalisés du circuit, revenant aux valeurs par défaut globales.", + "fields": { + "circuit_id": { + "name": "Circuit", + "description": "L'entité capteur de puissance du circuit." + }, + "config_entry_id": { + "name": "Entrée de configuration", + "description": "Id d'entrée de configuration optionnel. Nécessaire uniquement si plusieurs panneaux SPAN sont configurés." + } + } + }, + "set_mains_threshold": { + "name": "Définir le seuil du réseau principal", + "description": "Définir les seuils de surveillance du courant pour une phase du réseau principal.", + "fields": { + "leg": { + "name": "Phase du réseau", + "description": "L'entité capteur de courant du réseau principal." + }, + "continuous_threshold_pct": { + "name": "Seuil continu (%)", + "description": "Pourcentage du calibre du disjoncteur pour les alertes de charge continue." + }, + "spike_threshold_pct": { + "name": "Seuil de pic (%)", + "description": "Pourcentage du calibre du disjoncteur pour les alertes de pic instantané." + }, + "window_duration_m": { + "name": "Durée de la fenêtre (minutes)", + "description": "Durée pendant laquelle le courant doit rester au-dessus du seuil avant déclenchement de l'alerte." + }, + "cooldown_duration_m": { + "name": "Durée de refroidissement (minutes)", + "description": "Minutes entre les alertes répétées pour la même phase du réseau." + }, + "monitoring_enabled": { + "name": "Surveillance activée", + "description": "Activer ou désactiver la surveillance pour cette phase du réseau." + }, + "config_entry_id": { + "name": "Entrée de configuration", + "description": "Id d'entrée de configuration optionnel. Nécessaire uniquement si plusieurs panneaux SPAN sont configurés." + } + } + }, + "clear_mains_threshold": { + "name": "Effacer le seuil du réseau principal", + "description": "Supprimer les seuils personnalisés de la phase du réseau, revenant aux valeurs par défaut globales.", + "fields": { + "leg": { + "name": "Phase du réseau", + "description": "L'entité capteur de courant du réseau principal." + }, + "config_entry_id": { + "name": "Entrée de configuration", + "description": "Id d'entrée de configuration optionnel. Nécessaire uniquement si plusieurs panneaux SPAN sont configurés." + } + } + }, + "get_monitoring_status": { + "name": "Obtenir l'état de surveillance", + "description": "Retourne l'état actuel de surveillance pour tous les circuits et phases du réseau suivis.", + "fields": { + "config_entry_id": { + "name": "Entrée de configuration", + "description": "Id d'entrée de configuration optionnel. Nécessaire uniquement si plusieurs panneaux SPAN sont configurés." + } + } + }, + "set_global_monitoring": { + "name": "Configurer la surveillance globale", + "description": "Mettre à jour les seuils de surveillance globale et les paramètres de notification.", + "fields": { + "enabled": { + "name": "Activé", + "description": "Activer ou désactiver la surveillance du courant globalement pour ce panneau SPAN." + }, + "continuous_threshold_pct": { + "name": "Seuil continu", + "description": "Pourcentage de la capacité du disjoncteur pour la détection de surcharge continue." + }, + "spike_threshold_pct": { + "name": "Seuil de pointe", + "description": "Pourcentage de la capacité du disjoncteur pour la détection de pointes." + }, + "window_duration_m": { + "name": "Durée de la fenêtre", + "description": "Minutes de surcharge soutenue avant de déclencher une alerte." + }, + "cooldown_duration_m": { + "name": "Durée de refroidissement", + "description": "Minutes entre les alertes répétées pour le même point." + }, + "notify_targets": { + "name": "Cibles de notification", + "description": "Liste séparée par des virgules des cibles de services de notification." + }, + "notification_title_template": { + "name": "Modèle de titre de notification", + "description": "Modèle pour le titre de la notification. Marqueurs : {name}, {entity_id}, {alert_type}, {current_a}, {breaker_rating_a}, {threshold_pct}, {utilization_pct}, {window_m}." + }, + "notification_message_template": { + "name": "Modèle de message de notification", + "description": "Modèle pour le corps du message de notification. Marqueurs : {name}, {entity_id}, {alert_type}, {current_a}, {breaker_rating_a}, {threshold_pct}, {utilization_pct}, {window_m}." + }, + "notification_priority": { + "name": "Priorité de notification", + "description": "Niveau de priorité de la notification push. Contrôle le comportement d'interruption sur les appareils mobiles (iOS : interruption-level, Android : priority/canal)." + }, + "config_entry_id": { + "name": "Entrée de configuration", + "description": "Id d'entrée de configuration optionnel. Nécessaire uniquement si plusieurs panneaux SPAN sont configurés." + } + } + }, + "test_notification": { + "name": "Notification de test", + "description": "Envoyer une notification de test à toutes les cibles configurées avec des valeurs d'exemple.", + "fields": { + "config_entry_id": { + "name": "Entrée de configuration", + "description": "Id d'entrée de configuration optionnel. Nécessaire uniquement si plusieurs panneaux SPAN sont configurés." + } + } + }, + "set_graph_time_horizon": { + "name": "Définir l'horizon temporel du graphique", + "description": "Définit l'horizon temporel par défaut global pour les graphiques de circuits.", + "fields": { + "horizon": { + "name": "Horizon", + "description": "Fenêtre temporelle prédéfinie pour l'affichage du graphique." + }, + "config_entry_id": { + "name": "Entrée de configuration", + "description": "Id d'entrée de configuration optionnel. Nécessaire uniquement si plusieurs panneaux SPAN sont configurés." + } + } + }, + "set_circuit_graph_horizon": { + "name": "Définir l'horizon de graphique par circuit", + "description": "Définit un remplacement d'horizon temporel par circuit pour les graphiques.", + "fields": { + "circuit_id": { + "name": "ID du circuit", + "description": "L'identifiant du circuit à remplacer." + }, + "horizon": { + "name": "Horizon", + "description": "Fenêtre temporelle prédéfinie pour l'affichage du graphique." + }, + "config_entry_id": { + "name": "Entrée de configuration", + "description": "Id d'entrée de configuration optionnel. Nécessaire uniquement si plusieurs panneaux SPAN sont configurés." + } + } + }, + "clear_circuit_graph_horizon": { + "name": "Effacer l'horizon de graphique du circuit", + "description": "Supprime le remplacement d'horizon de graphique par circuit, revenant à la valeur par défaut globale.", + "fields": { + "circuit_id": { + "name": "ID du circuit", + "description": "L'identifiant du circuit pour lequel effacer le remplacement." + }, + "config_entry_id": { + "name": "Entrée de configuration", + "description": "Id d'entrée de configuration optionnel. Nécessaire uniquement si plusieurs panneaux SPAN sont configurés." + } + } + }, + "set_subdevice_graph_horizon": { + "name": "Définir l'horizon de graphique par sous-appareil", + "description": "Définit un remplacement d'horizon temporel par sous-appareil pour les graphiques.", + "fields": { + "subdevice_id": { + "name": "ID du sous-appareil", + "description": "L'identifiant du sous-appareil à remplacer." + }, + "horizon": { + "name": "Horizon", + "description": "Fenêtre temporelle prédéfinie pour l'affichage du graphique." + }, + "config_entry_id": { + "name": "Entrée de configuration", + "description": "Id d'entrée de configuration optionnel. Nécessaire uniquement si plusieurs panneaux SPAN sont configurés." + } + } + }, + "clear_subdevice_graph_horizon": { + "name": "Effacer l'horizon de graphique du sous-appareil", + "description": "Supprime le remplacement d'horizon de graphique par sous-appareil, revenant à la valeur par défaut globale.", + "fields": { + "subdevice_id": { + "name": "ID du sous-appareil", + "description": "L'identifiant du sous-appareil pour lequel effacer le remplacement." + }, + "config_entry_id": { + "name": "Entrée de configuration", + "description": "Id d'entrée de configuration optionnel. Nécessaire uniquement si plusieurs panneaux SPAN sont configurés." + } + } + }, + "get_graph_settings": { + "name": "Obtenir les paramètres de graphique", + "description": "Retourne les paramètres actuels d'horizon de graphique globaux et par circuit.", + "fields": { + "config_entry_id": { + "name": "Entrée de configuration", + "description": "Id d'entrée de configuration optionnel. Nécessaire uniquement si plusieurs panneaux SPAN sont configurés." + } + } + }, + "get_favorites": { + "name": "Obtenir les favoris", + "description": "Retourne la carte des favoris inter-panneaux enregistrés pour les circuits et les sous-appareils, indexée par identifiant d'appareil de panneau SPAN." + }, + "add_favorite": { + "name": "Ajouter un favori", + "description": "Marque un circuit SPAN, une batterie (BESS) ou un chargeur EV (EVSE) comme favori pour qu'il apparaisse dans la vue Favoris du tableau de bord.", + "fields": { + "entity_id": { + "name": "Entité", + "description": "N'importe quel capteur du circuit ou du sous-appareil à marquer comme favori (courant, puissance, SoC, etc.)." + } + } + }, + "remove_favorite": { + "name": "Retirer un favori", + "description": "Retire un circuit ou un sous-appareil de la vue Favoris inter-panneaux.", + "fields": { + "entity_id": { + "name": "Entité", + "description": "N'importe quel capteur du circuit ou du sous-appareil à retirer des favoris." + } } } } diff --git a/custom_components/span_panel/translations/ja.json b/custom_components/span_panel/translations/ja.json index 68db6473..8879fc0e 100644 --- a/custom_components/span_panel/translations/ja.json +++ b/custom_components/span_panel/translations/ja.json @@ -1,175 +1,697 @@ { "config": { "abort": { - "no_devices_found": "ネットワーク上にデバイスが見つかりません", "already_configured": "スパンパネルがすでに設定されています。設定は一つのみです。", - "reauth_successful": "認証できました" + "cannot_connect": "スパンパネルへの接続に失敗しました", + "no_devices_found": "ネットワーク上にデバイスが見つかりません", + "no_host": "アドオンからホストアドレスが提供されませんでした。", + "no_serial": "パネルのシリアル番号を特定できませんでした。", + "reauth_successful": "認証できました", + "reconfigure_successful": "ホストが正常に更新されました。", + "unique_id_mismatch": "このアドレスのパネルは異なるシリアル番号を持っています。再設定中に別のパネルに切り替えることはできません。", + "v1_not_supported": "v1ファームウェアはサポートされていません。パネルのファームウェアをv2にアップグレードしてください。" }, "error": { "cannot_connect": "スパンパネルへの接続に失敗しました", + "fqdn_registration_failed": "パネルにドメイン名を登録できなかったか、TLS証明書が時間内に更新されませんでした。", + "host_required": "ホストが必要です", "invalid_auth": "認証が無効です", - "unknown": "予期しないエラー", - "host_required": "ライブパネル接続にはホストが必要です" + "proximity_failed": "近接が証明されませんでした。パネルのドアを3回開閉してから、もう一度お試しください。", + "unknown": "予期しないエラー" }, "flow_title": "スパンパネル ({host})", + "progress": { + "registering_fqdn": "パネルにドメイン名を登録し、TLS証明書の更新を待っています。最大60秒かかる場合があります..." + }, "step": { + "auth_passphrase": { + "data": { + "hop_passphrase": "パスフレーズ" + }, + "data_description": { + "hop_passphrase": "SPAN Panel v2 APIでの認証に使用するパスフレーズ" + }, + "description": "SPAN Panelのパスフレーズを入力してください。パネルラベルに印刷されているか、パネルオーナーによって設定されています。", + "title": "パネルパスフレーズ" + }, + "auth_proximity": { + "description": "スパンパネルのドアを3回開閉してから、以下の確認を選択してください。", + "menu_options": { + "auth_passphrase": "代わりにパスフレーズを使用", + "auth_proximity_confirm": "ドアを開閉しました" + }, + "title": "近接認証" + }, + "auth_proximity_confirm": { + "description": "パネルとの近接を確認しています。", + "title": "近接認証" + }, + "choose_entity_naming_initial": { + "data": { + "entity_naming_pattern": "命名パターン" + }, + "data_description": { + "entity_naming_pattern": "フレンドリ名はパネルの回路ラベルを使用します。タブベースの名前はブレーカータブ番号を使用し、安定したエンティティIDを提供します。" + }, + "description": "Home Assistantでの回路関連エンティティの命名方法を選択してください。", + "title": "回路エンティティの命名" + }, + "choose_v2_auth": { + "menu_options": { + "auth_passphrase": "パネルパスフレーズを入力", + "auth_proximity": "近接証明(ドアの開閉)" + }, + "title": "認証方法の選択" + }, "confirm_discovery": { "description": "{host}でスパンパネルをセットアップしますか?", "title": "スパンパネルに接続" }, - "user": { + "fqdn_failed": { + "description": "パネルにドメイン名を登録できなかったか、TLS証明書が時間内に更新されませんでした。続行できますが、パネルの証明書にドメイン名が含まれていない場合、MQTT接続が失敗する可能性があります。", + "title": "ドメイン登録" + }, + "reauth_confirm": { + "description": "{host}のSpan Panelは再認証が必要です。", + "menu_options": { + "auth_passphrase": "パネルパスフレーズを入力", + "auth_proximity": "近接証明(ドアの開閉)" + }, + "title": "Span Panelの再認証" + }, + "reconfigure": { "data": { - "host": "ホスト", - "use_ssl": "SSLを使用", - "simulator_mode": "シミュレーターモード", - "power_display_precision": "電力表示精度", - "energy_display_precision": "エネルギー表示精度" + "host": "ホスト" }, "data_description": { - "host": "SPAN PanelのIPアドレスまたはホスト名(シミュレーション用:'sp3-simulation-001'のようなシリアル番号を使用するか、デフォルトの場合は空にしてください)", - "use_ssl": "SPAN Panelへの安全なHTTPS接続を有効にします", - "simulator_mode": "テスト用のシミュレーターモードを有効にします(ハードウェア不要)。デフォルトシミュレーションの場合はホストを空にするか、カスタムシリアル番号を入力してください。", - "power_display_precision": "電力値の小数点以下の桁数(0-3)", - "energy_display_precision": "エネルギー値の小数点以下の桁数(0-3)" + "host": "SPAN PanelのIPアドレスまたはホスト名" }, - "description": "SPAN Panelの接続詳細を入力してください。シミュレーターモードの場合は、シミュレーターチェックボックスをチェックし、必要に応じてカスタムシリアル番号を入力してください。", - "title": "スパンパネルに接続" + "description": "このSPAN Panelのホストアドレスを更新します。パネルのシリアル番号が一致する必要があります。", + "title": "SPAN Panelの再設定" }, - "choose_auth_type": { - "title": "認証オプションを選んでください", - "menu_options": { - "auth_proximity": "スパンパネル実物を通じて認証する", - "auth_token": "アクセストークンを使用して認証する" - } + "reconfigure_fqdn_done": { + "title": "再設定完了" }, - "auth_proximity": { - "title": "近接認証", - "description": "スパンパネルのドアを {remaining} 回開閉してください。" + "reconfigure_fqdn_failed": { + "description": "パネルにドメイン名を登録できなかったか、TLS証明書が時間内に更新されませんでした。続行できますが、パネルの証明書にドメイン名が含まれていない場合、MQTT接続が失敗する可能性があります。", + "title": "ドメイン登録" }, - "auth_token": { - "title": "手動にトークン認証", - "description": "あなたのアクセストークンを入力してください(空白のままで再開):", - "data": { - "access_token": "アクセストークン" - } + "reconfigure_register_fqdn": { + "title": "ドメイン名の登録" }, - "simulator_config": { - "title": "シミュレーター設定", - "description": "シミュレーション設定を選択し、必要に応じてカスタム開始時刻を設定します。{config_count}の設定が利用可能です。", + "register_fqdn": { + "title": "ドメイン名の登録" + }, + "user": { "data": { - "simulation_config": "シミュレーション設定", - "host": "ホスト(シリアル番号)", - "simulation_start_time": "シミュレーション開始時刻(オプション)", - "entity_naming_pattern": "エンティティ命名パターン" + "enable_energy_dip_compensation": "エネルギー低下を自動補正", + "energy_display_precision": "エネルギー表示精度", + "host": "ホスト", + "http_port": "HTTPポート", + "power_display_precision": "電力表示精度" }, "data_description": { - "simulation_config": "回路テンプレートとエネルギー プロファイルを定義するシミュレーション設定を選択します", - "host": "シミュレートされたパネルのカスタムシリアル番号(オプション、空の場合は設定のデフォルトを使用)", - "simulation_start_time": "時刻形式:HH:MM(24時間)、H:MM(12時間)、または完全なISO日時(YYYY-MM-DDTHH:MM:SS)。空のままにすると現在時刻を使用します。", - "entity_naming_pattern": "シミュレーションで回路エンティティの命名方法を選択します" - } + "enable_energy_dip_compensation": "パネルがより低いエネルギー読み取り値を報告した場合に自動的に補正し、エネルギーダッシュボードのスパイクを防止します。", + "energy_display_precision": "エネルギー値の小数点以下の桁数(0-3)", + "host": "SPAN PanelのIPアドレスまたはホスト名", + "http_port": "パネルAPIのHTTPポート(デフォルト:80)", + "power_display_precision": "電力値の小数点以下の桁数(0-3)" + }, + "description": "SPAN Panelの接続詳細を入力してください。", + "title": "スパンパネルに接続" } } }, - "options": { - "error": { - "directory": "ファイルパスが必要です", - "base": "エクスポートに失敗しました:{error}" + "entity": { + "binary_sensor": { + "bess_connected": { + "name": "BESS接続済み" + }, + "door_state": { + "name": "ドア状態" + }, + "ethernet_link": { + "name": "イーサネットリンク" + }, + "evse_charging": { + "name": "充電中" + }, + "evse_ev_connected": { + "name": "EV接続済み" + }, + "grid_islandable": { + "name": "系統自立運転可能" + }, + "panel_status": { + "name": "パネルステータス" + }, + "wifi_link": { + "name": "Wi-Fiリンク" + } }, - "step": { - "init": { - "title": "オプションメニュー", - "menu_options": { - "general_options": "一般オプション", - "entity_naming_options": "エンティティ命名オプション", - "entity_naming": "エンティティ命名パターン", - "export_config": "合成センサー設定をエクスポート", - "simulation_start_time": "シミュレーション開始時刻", - "simulation_offline_minutes": "シミュレーションオフラインミニッツ" + "button": { + "gfe_override": { + "name": "GFEオーバーライド:系統接続" + } + }, + "select": { + "circuit_priority": { + "name": "回路優先度", + "state": { + "never": "停電時もオンのまま", + "off_grid": "停電時にオフになる", + "soc_threshold": "バッテリーしきい値までオンのまま" } + } + }, + "sensor": { + "battery_level": { + "name": "バッテリー残量" }, - "general_options": { - "title": "一般オプション", - "data": { - "scan_interval": "スキャンインターバル(秒)", - "enable_battery_percentage": "バッテリーパーセントセンサーを有効にする", - "enable_solar_circuit": "ソーラーインバーターセンサーを有効にする", - "leg1": "ソーラーレッグ1", - "leg2": "ソーラーレッグ2", - "api_retries": "APIリトライ回数 (0-10)", - "api_retry_timeout": "APIリトライタイムアウト (秒)", - "api_retry_backoff_multiplier": "APIリトライバックオフ倍率", - "energy_reporting_grace_period": "エネルギーセンサー猶予期間 (分)", - "legacy_upgrade_to_friendly": "Add Device Name Prefix to Entity ID's", - "enable_panel_net_energy_sensors": "パネル正味エネルギー", - "enable_circuit_net_energy_sensors": "回路正味エネルギー", - "enable_solar_net_energy_sensors": "ソーラー正味エネルギー" - }, - "data_description": { - "enable_solar_circuit": "ソーラーインバーター監視用の合成センサーを有効にします。反対の電気相の2つの未マップタブの選択が必要です。", - "energy_reporting_grace_period": "パネルが利用できなくなったときにエネルギーセンサーが最後の既知の値を維持する時間(0-60分)。短時間の停電中にエネルギー統計の整合性を維持するのに役立ちます。デフォルト:15分。", - "legacy_upgrade_to_friendly": "Warning: This irreversible operation will change your circuit entity IDs allowing support for more than one panel", - "enable_panel_net_energy_sensors": "パネルレベルエネルギー流れ(メインメーターとフィードスルー)の正味エネルギーセンサーを作成します。双方向流れを考慮した真のエネルギー収支を表示します。", - "enable_circuit_net_energy_sensors": "個別回路の正味エネルギーセンサーを作成します。無効電力と回生流れを考慮した真のエネルギー消費を表示します。", - "enable_solar_net_energy_sensors": "ソーラー発電の正味エネルギーセンサーを作成します。ソーラー回路での消費を考慮した正味ソーラー生産量を表示します。" + "battery_power": { + "name": "バッテリー電力" + }, + "bess_firmware_version": { + "name": "ファームウェアバージョン" + }, + "bess_model": { + "name": "モデル" + }, + "bess_nameplate_capacity": { + "name": "定格容量" + }, + "bess_serial_number": { + "name": "シリアル番号" + }, + "bess_soe_kwh": { + "name": "エネルギー残量" + }, + "bess_vendor": { + "name": "ベンダー" + }, + "current_run_config": { + "name": "現在の動作設定", + "state": { + "panel_backup": "バックアップ", + "panel_off_grid": "系統切離", + "panel_on_grid": "系統連系", + "unknown": "不明" } }, - "entity_naming_options": { - "title": "エンティティ命名オプション", - "description": "エンティティIDの命名方法を設定します。フレンドリ名(例:kitchen_outlets_power)または回路タブ番号(例:circuit_15_power)を選択してください。", - "data": { - "legacy_upgrade_to_friendly": "レガシーインストールをフレンドリ名にアップグレード", - "entity_naming_pattern": "エンティティ命名パターン" - }, - "data_description": { - "legacy_upgrade_to_friendly": "このレガシーインストールをデバイスプレフィックス付きのフレンドリエンティティ名にアップグレードします。これにより既存のすべてのエンティティが名前変更されます。", - "entity_naming_pattern": "エンティティIDの構築方法を選択してください。フレンドリ名は回路名を使用し(例:kitchen_outlets_power)、回路番号はタブ位置を使用します(例:circuit_15_power)。" + "downstream_l1_current": { + "name": "下流側L1電流" + }, + "downstream_l2_current": { + "name": "下流側L2電流" + }, + "dsm_grid_state": { + "name": "DSM系統状態", + "state": { + "dsm_off_grid": "系統切離", + "dsm_on_grid": "系統連系", + "unknown": "不明" } }, - "entity_naming": { - "title": "エンティティ命名パターン", - "description": "回路エンティティの命名方法を選択してください。**これを変更すると既存のエンティティが名前変更されます。**\n\n{friendly_example}\n\n{circuit_example}\n\n⚠️ **重要**: \n• エンティティ履歴は名前変更中に保持されます\n• 続行する前に設定のバックアップを検討してください\n• 自動化とスクリプトは新しいエンティティIDを使用するために手動更新が必要な場合があります", - "data": { - "entity_naming_pattern": "エンティティ命名パターン" + "dsm_state": { + "name": "DSM状態", + "state": { + "dsm_off_grid": "系統切離", + "dsm_on_grid": "系統連系", + "unknown": "不明" } }, - "export_config": { - "title": "合成センサー設定をエクスポート", - "description": "現在の合成センサー設定をYAMLファイルにエクスポートします。ディレクトリ(ファイルは自動的に名前が付けられます)または完全なファイルパスを指定できます。デフォルトファイル名:{filename}", - "data": { - "directory": "ファイルパス" - }, - "data_description": { - "directory": "ディレクトリパス(ファイルは自動的に名前が付けられます)または設定が保存される.yamlで終わる完全なファイルパス。" + "evse_advertised_current": { + "name": "通知電流" + }, + "evse_lock_state": { + "name": "ロック状態", + "state": { + "locked": "ロック", + "unknown": "不明", + "unlocked": "アンロック" } }, - "simulation_start_time": { - "title": "シミュレーション開始時刻", - "description": "シミュレーション開始時刻を構成します。シミュレーション時刻の変更により、新しい時刻を適用するために統合が再読み込みされます。", - "data": { - "simulation_start_time": "シミュレーション開始時刻" - }, - "data_description": { - "simulation_start_time": "時刻形式:HH:MM(24時間)、H:MM(12時間)、または完全なISO日時(YYYY-MM-DDTHH:MM:SS)。空のままにすると現在時刻を使用します。統合は変更を適用するために再読み込みされます。" + "evse_status": { + "name": "充電器ステータス", + "state": { + "available": "利用可能", + "charging": "充電中", + "faulted": "故障", + "finishing": "終了処理中", + "preparing": "準備中", + "reserved": "予約済み", + "suspended_ev": "車両により一時停止", + "suspended_evse": "充電器により一時停止", + "unavailable": "利用不可", + "unknown": "不明" + } + }, + "feedthrough_consumed_energy": { + "name": "フィードスルー消費エネルギー" + }, + "feedthrough_net_energy": { + "name": "フィードスルー正味エネルギー" + }, + "feedthrough_power": { + "name": "フィードスルー電力" + }, + "feedthrough_produced_energy": { + "name": "フィードスルー発電エネルギー" + }, + "grid_forming_entity": { + "name": "系統形成エンティティ", + "state": { + "battery": "バッテリー", + "generator": "発電機", + "grid": "系統", + "none": "なし", + "pv": "PV", + "unknown": "不明" + } + }, + "grid_power_flow": { + "name": "系統電力" + }, + "instant_grid_power": { + "name": "現在の電力" + }, + "l1_voltage": { + "name": "L1電圧" + }, + "l2_voltage": { + "name": "L2電圧" + }, + "main_breaker_rating": { + "name": "主幹ブレーカー定格" + }, + "main_meter_consumed_energy": { + "name": "主幹メーター消費エネルギー" + }, + "main_meter_net_energy": { + "name": "主幹メーター正味エネルギー" + }, + "main_meter_produced_energy": { + "name": "主幹メーター発電エネルギー" + }, + "main_relay_state": { + "name": "主幹リレー状態", + "state": { + "closed": "閉", + "open": "開", + "unknown": "不明" } }, - "simulation_offline_minutes": { - "title": "シミュレーションオフラインミニッツ", - "description": "シミュレーション中にパネルがオフラインに表示される時間を構成します。", + "pv_nameplate_capacity": { + "name": "PV定格容量" + }, + "pv_power": { + "name": "PV電力" + }, + "pv_product": { + "name": "PV製品" + }, + "pv_vendor": { + "name": "PVベンダー" + }, + "site_power": { + "name": "サイト電力" + }, + "software_version": { + "name": "ソフトウェアバージョン" + }, + "upstream_l1_current": { + "name": "上流側L1電流" + }, + "upstream_l2_current": { + "name": "上流側L2電流" + }, + "vendor_cloud": { + "name": "ベンダークラウド", + "state": { + "connected": "接続済み", + "unconnected": "未接続", + "unknown": "不明" + } + } + } + }, + "exceptions": { + "circuit_priority_failed": { + "message": "{circuit}の回路優先度の設定に失敗しました。" + }, + "circuit_relay_failed": { + "message": "{circuit}の回路リレー状態の設定に失敗しました。" + }, + "export_manifest_no_entries": { + "message": "SPANパネルの設定エントリが読み込まれていません。このサービスを呼び出す前に、SPANパネルを追加して設定してください。" + }, + "favorite_no_circuit_uuid": { + "message": "エンティティ {entity_id} からお気に入り対象を導出できませんでした。回路センサー (電流/電力) またはサブデバイスのセンサーを選択してください。" + }, + "favorite_no_device": { + "message": "エンティティ {entity_id} はデバイスに紐付いていません。SPANの回路またはサブデバイスのセンサーを選択してください。" + }, + "favorite_no_unique_id": { + "message": "エンティティ {entity_id} は unique_id を持たず、SPANの対象に解決できません。" + }, + "favorite_not_span_entity": { + "message": "エンティティ {entity_id} はSPAN Panelのエンティティではありません。回路、バッテリー (BESS)、またはEV充電器 (EVSE) のセンサーを選択してください。" + }, + "favorite_subdevice_no_span_parent": { + "message": "サブデバイス {entity_id} に親のSPAN Panelデバイスがありません。via_device_id がSPANパネルを指していません。" + }, + "gfe_override_failed": { + "message": "パネルへのGFEオーバーライドの送信に失敗しました。" + }, + "graph_horizon_not_available": { + "message": "選択されたSPANパネルではグラフホライズンマネージャーが利用できません。" + }, + "monitoring_not_enabled": { + "message": "電流監視が有効なSPANパネルがありません。" + }, + "panel_auth_failed": { + "message": "SPAN Panelとの認証に失敗しました。再認証してください。" + }, + "panel_connection_failed": { + "message": "{host}のSPAN Panelへの接続に失敗しました。" + }, + "panel_not_ready": { + "message": "SPAN Panelの準備ができていません。パネルがオンラインであることを確認してください。" + } + }, + "options": { + "step": { + "init": { "data": { - "simulation_offline_minutes": "オフラインミニッツ" + "show_panel": "サイドバーにダッシュボードを表示", + "panel_admin_only": "管理ユーザーのみ", + "enable_circuit_net_energy_sensors": "回路正味エネルギー", + "enable_energy_dip_compensation": "エネルギー低下を自動補正", + "enable_panel_net_energy_sensors": "パネル正味エネルギー", + "enable_unmapped_circuit_sensors": "未割当回路センサー", + "energy_reporting_grace_period": "エネルギーセンサー猶予期間 (分)", + "snapshot_update_interval": "スナップショット更新間隔(秒)" }, "data_description": { - "simulation_offline_minutes": "シミュレーション中にパネルがオフラインに表示される分数。オフラインシミュレーションを無効にするには0に設定します。" - } + "show_panel": "SPANパネルダッシュボードをサイドバーエントリとして表示します。", + "panel_admin_only": "ダッシュボードサイドバーエントリを管理ユーザーのみに制限します。", + "enable_circuit_net_energy_sensors": "無効電力を考慮した回路ごとの正味エネルギー。", + "enable_energy_dip_compensation": "パネルがより低いエネルギー読み取り値を報告した場合に補正します。無効にするとオフセットがクリアされます。", + "enable_panel_net_energy_sensors": "双方向フローを考慮した主幹メーターとフィードスルーの正味エネルギー。", + "enable_unmapped_circuit_sensors": "名前付きブレーカーに割り当てられていないタブのバッキングセンサー。", + "energy_reporting_grace_period": "パネルがオフライン時にセンサーが最後の値を保持する分数(0〜60)。デフォルト:15。", + "snapshot_update_interval": "スナップショット再構築の間隔(秒)(0〜15)。低い値=高速、CPU負荷増。0=デバウンスなし。" + }, + "description": "SPAN Panel統合の設定を構成します。", + "title": "SPANパネルオプション" } } }, "selector": { "entity_naming_pattern": { "options": { - "friendly_names": "フレンドリ名(例:span_panel_kitchen_outlets_power)", - "circuit_numbers": "回路番号(例:span_panel_circuit_15_power)" + "circuit_numbers": "回路番号(例:span_panel_circuit_15_power)", + "friendly_names": "フレンドリ名(例:span_panel_kitchen_outlets_power)" + } + } + }, + "services": { + "export_circuit_manifest": { + "description": "設定済みのすべてのSPANパネルの構造化された回路マニフェストを返します:シリアル番号、ホスト、回路ごとの電力センサーエンティティID、レイアウトテンプレート識別子(clone_{{tab}})、デバイスタイプ、ブレーカータブ番号。エンティティレジストリを照会せずにパネルトポロジーが必要なダッシュボードカードやオートメーション向けです。これはレスポンス専用サービスです。解決できるものがない場合、レスポンスにはパネルが含まれない場合があります。", + "name": "回路マニフェストのエクスポート" + }, + "set_circuit_threshold": { + "name": "回路しきい値の設定", + "description": "特定の回路の電流モニタリングしきい値を設定します。", + "fields": { + "circuit_id": { + "name": "回路", + "description": "回路電力センサーエンティティ。" + }, + "continuous_threshold_pct": { + "name": "連続しきい値(%)", + "description": "連続負荷アラート用のブレーカー定格に対する割合。" + }, + "spike_threshold_pct": { + "name": "スパイクしきい値(%)", + "description": "瞬時スパイクアラート用のブレーカー定格に対する割合。" + }, + "window_duration_m": { + "name": "ウィンドウ期間(分)", + "description": "アラート発報前に電流がしきい値を超えている必要がある時間。" + }, + "cooldown_duration_m": { + "name": "クールダウン期間(分)", + "description": "同じ回路の繰り返しアラート間の分数。" + }, + "monitoring_enabled": { + "name": "モニタリング有効", + "description": "この回路のモニタリングを有効または無効にします。" + }, + "config_entry_id": { + "name": "設定エントリ", + "description": "オプションの設定エントリID。複数のSPANパネルが設定されている場合にのみ必要です。" + } + } + }, + "clear_circuit_threshold": { + "name": "回路しきい値のクリア", + "description": "回路ごとのしきい値オーバーライドを削除し、グローバルデフォルトに戻します。", + "fields": { + "circuit_id": { + "name": "回路", + "description": "回路電力センサーエンティティ。" + }, + "config_entry_id": { + "name": "設定エントリ", + "description": "オプションの設定エントリID。複数のSPANパネルが設定されている場合にのみ必要です。" + } + } + }, + "set_mains_threshold": { + "name": "主幹しきい値の設定", + "description": "特定の主幹レグの電流モニタリングしきい値を設定します。", + "fields": { + "leg": { + "name": "主幹レグ", + "description": "主幹電流センサーエンティティ。" + }, + "continuous_threshold_pct": { + "name": "連続しきい値(%)", + "description": "連続負荷アラート用のブレーカー定格に対する割合。" + }, + "spike_threshold_pct": { + "name": "スパイクしきい値(%)", + "description": "瞬時スパイクアラート用のブレーカー定格に対する割合。" + }, + "window_duration_m": { + "name": "ウィンドウ期間(分)", + "description": "アラート発報前に電流がしきい値を超えている必要がある時間。" + }, + "cooldown_duration_m": { + "name": "クールダウン期間(分)", + "description": "同じ主幹レグの繰り返しアラート間の分数。" + }, + "monitoring_enabled": { + "name": "モニタリング有効", + "description": "この主幹レグのモニタリングを有効または無効にします。" + }, + "config_entry_id": { + "name": "設定エントリ", + "description": "オプションの設定エントリID。複数のSPANパネルが設定されている場合にのみ必要です。" + } + } + }, + "clear_mains_threshold": { + "name": "主幹しきい値のクリア", + "description": "主幹レグごとのしきい値オーバーライドを削除し、グローバルデフォルトに戻します。", + "fields": { + "leg": { + "name": "主幹レグ", + "description": "主幹電流センサーエンティティ。" + }, + "config_entry_id": { + "name": "設定エントリ", + "description": "オプションの設定エントリID。複数のSPANパネルが設定されている場合にのみ必要です。" + } + } + }, + "get_monitoring_status": { + "name": "モニタリングステータスの取得", + "description": "すべての追跡対象回路と主幹レグの現在のモニタリング状態を返します。", + "fields": { + "config_entry_id": { + "name": "設定エントリ", + "description": "オプションの設定エントリID。複数のSPANパネルが設定されている場合にのみ必要です。" + } + } + }, + "set_global_monitoring": { + "name": "グローバル監視の設定", + "description": "グローバル監視のしきい値と通知設定を更新します。", + "fields": { + "enabled": { + "name": "有効", + "description": "このSPANパネルの電流監視をグローバルに有効または無効にします。" + }, + "continuous_threshold_pct": { + "name": "連続しきい値", + "description": "連続過負荷検出のためのブレーカー定格に対する割合。" + }, + "spike_threshold_pct": { + "name": "スパイクしきい値", + "description": "スパイク検出のためのブレーカー定格に対する割合。" + }, + "window_duration_m": { + "name": "ウィンドウ期間", + "description": "アラートを発する前の持続的過負荷の分数。" + }, + "cooldown_duration_m": { + "name": "クールダウン期間", + "description": "同じポイントの繰り返しアラート間の分数。" + }, + "notify_targets": { + "name": "通知ターゲット", + "description": "通知サービスターゲットのカンマ区切りリスト。" + }, + "notification_title_template": { + "name": "通知タイトルテンプレート", + "description": "通知タイトルのテンプレート。プレースホルダー: {name}, {entity_id}, {alert_type}, {current_a}, {breaker_rating_a}, {threshold_pct}, {utilization_pct}, {window_m}。" + }, + "notification_message_template": { + "name": "通知メッセージテンプレート", + "description": "通知メッセージ本文のテンプレート。プレースホルダー: {name}, {entity_id}, {alert_type}, {current_a}, {breaker_rating_a}, {threshold_pct}, {utilization_pct}, {window_m}。" + }, + "notification_priority": { + "name": "通知優先度", + "description": "プッシュ通知の優先度レベル。モバイルデバイスでの割り込み動作を制御します(iOS: interruption-level、Android: priority/チャンネル)。" + }, + "config_entry_id": { + "name": "設定エントリ", + "description": "オプションの設定エントリID。複数のSPANパネルが設定されている場合にのみ必要です。" + } + } + }, + "test_notification": { + "name": "テスト通知", + "description": "サンプル値を使用して設定されたすべてのターゲットにテスト通知を送信します。", + "fields": { + "config_entry_id": { + "name": "設定エントリ", + "description": "オプションの設定エントリID。複数のSPANパネルが設定されている場合にのみ必要です。" + } + } + }, + "set_graph_time_horizon": { + "name": "グラフの時間範囲を設定", + "description": "回路グラフのグローバルデフォルト時間範囲を設定します。", + "fields": { + "horizon": { + "name": "時間範囲", + "description": "グラフ表示用の時間ウィンドウプリセット。" + }, + "config_entry_id": { + "name": "設定エントリ", + "description": "オプションの設定エントリID。複数のSPANパネルが設定されている場合にのみ必要です。" + } + } + }, + "set_circuit_graph_horizon": { + "name": "回路別グラフ時間範囲を設定", + "description": "回路別のグラフ時間範囲オーバーライドを設定します。", + "fields": { + "circuit_id": { + "name": "回路ID", + "description": "オーバーライドする回路の識別子。" + }, + "horizon": { + "name": "時間範囲", + "description": "グラフ表示用の時間ウィンドウプリセット。" + }, + "config_entry_id": { + "name": "設定エントリ", + "description": "オプションの設定エントリID。複数のSPANパネルが設定されている場合にのみ必要です。" + } + } + }, + "clear_circuit_graph_horizon": { + "name": "回路別グラフ時間範囲をクリア", + "description": "回路別のグラフ時間範囲オーバーライドを削除し、グローバルデフォルトに戻します。", + "fields": { + "circuit_id": { + "name": "回路ID", + "description": "オーバーライドをクリアする回路の識別子。" + }, + "config_entry_id": { + "name": "設定エントリ", + "description": "オプションの設定エントリID。複数のSPANパネルが設定されている場合にのみ必要です。" + } + } + }, + "set_subdevice_graph_horizon": { + "name": "サブデバイス別グラフ時間範囲を設定", + "description": "サブデバイス別のグラフ時間範囲オーバーライドを設定します。", + "fields": { + "subdevice_id": { + "name": "サブデバイスID", + "description": "オーバーライドするサブデバイスの識別子。" + }, + "horizon": { + "name": "時間範囲", + "description": "グラフ表示用の時間ウィンドウプリセット。" + }, + "config_entry_id": { + "name": "設定エントリ", + "description": "オプションの設定エントリID。複数のSPANパネルが設定されている場合にのみ必要です。" + } + } + }, + "clear_subdevice_graph_horizon": { + "name": "サブデバイス別グラフ時間範囲をクリア", + "description": "サブデバイス別のグラフ時間範囲オーバーライドを削除し、グローバルデフォルトに戻します。", + "fields": { + "subdevice_id": { + "name": "サブデバイスID", + "description": "オーバーライドをクリアするサブデバイスの識別子。" + }, + "config_entry_id": { + "name": "設定エントリ", + "description": "オプションの設定エントリID。複数のSPANパネルが設定されている場合にのみ必要です。" + } + } + }, + "get_graph_settings": { + "name": "グラフ設定を取得", + "description": "現在のグローバルおよび回路別のグラフ時間範囲設定を返します。", + "fields": { + "config_entry_id": { + "name": "設定エントリ", + "description": "オプションの設定エントリID。複数のSPANパネルが設定されている場合にのみ必要です。" + } + } + }, + "get_favorites": { + "name": "お気に入りを取得", + "description": "SPANパネルのデバイスIDをキーとした、パネル横断のお気に入りマップを返します。" + }, + "add_favorite": { + "name": "お気に入りを追加", + "description": "SPANの回路、バッテリー (BESS)、またはEV充電器 (EVSE) をお気に入りとしてマークし、ダッシュボードのお気に入りビューに表示します。", + "fields": { + "entity_id": { + "name": "エンティティ", + "description": "お気に入りに追加する回路またはサブデバイスの任意のセンサー (電流、電力、SoCなど)。" + } + } + }, + "remove_favorite": { + "name": "お気に入りを削除", + "description": "パネル横断のお気に入りビューから回路またはサブデバイスを削除します。", + "fields": { + "entity_id": { + "name": "エンティティ", + "description": "お気に入りから削除する回路またはサブデバイスの任意のセンサー。" + } } } } diff --git a/custom_components/span_panel/translations/pt.json b/custom_components/span_panel/translations/pt.json index e898bb25..3898d7f0 100644 --- a/custom_components/span_panel/translations/pt.json +++ b/custom_components/span_panel/translations/pt.json @@ -1,175 +1,697 @@ { "config": { "abort": { - "no_devices_found": "Não foram encontrados dispositivos na rede", "already_configured": "Painel Span já configurado. Só é possível uma única configuração.", - "reauth_successful": "Autenticação bem-sucedida." + "cannot_connect": "Falha ao ligar ao Painel Span", + "no_devices_found": "Não foram encontrados dispositivos na rede", + "no_host": "Nenhum endereço de anfitrião foi fornecido pelo add-on.", + "no_serial": "Não foi possível determinar o número de série do painel.", + "reauth_successful": "Autenticação bem-sucedida.", + "reconfigure_successful": "Anfitrião atualizado com sucesso.", + "unique_id_mismatch": "O painel neste endereço tem um número de série diferente. Não é possível mudar para um painel diferente durante a reconfiguração.", + "v1_not_supported": "O firmware v1 não é suportado. Atualize o firmware do seu painel para v2." }, "error": { "cannot_connect": "Falha ao ligar ao Painel Span", + "fqdn_registration_failed": "Não foi possível registar o nome de domínio no painel ou o certificado TLS não foi atualizado a tempo.", + "host_required": "O anfitrião é necessário", "invalid_auth": "Autenticação inválida", - "unknown": "Erro inesperado", - "host_required": "O anfitrião é necessário para conexões de painel ao vivo" + "proximity_failed": "Proximidade não comprovada. Por favor, abra e feche a porta do painel 3 vezes e tente novamente.", + "unknown": "Erro inesperado" }, "flow_title": "Painel Span ({host})", + "progress": { + "registering_fqdn": "A registar o nome de domínio no painel e a aguardar a atualização do certificado TLS. Isto pode demorar até 60 segundos..." + }, "step": { + "auth_passphrase": { + "data": { + "hop_passphrase": "Frase-passe" + }, + "data_description": { + "hop_passphrase": "A frase-passe utilizada para autenticar com a API v2 do SPAN Panel" + }, + "description": "Insira a frase-passe do seu SPAN Panel. Está impressa na etiqueta do painel ou definida pelo proprietário do painel.", + "title": "Frase-passe do Painel" + }, + "auth_proximity": { + "description": "Abra e feche a porta do Painel Span 3 vezes, depois selecione a confirmação abaixo.", + "menu_options": { + "auth_passphrase": "Usar frase-passe em vez disso", + "auth_proximity_confirm": "Já abri e fechei a porta" + }, + "title": "Autenticação por Proximidade" + }, + "auth_proximity_confirm": { + "description": "A verificar proximidade com o painel.", + "title": "Autenticação por Proximidade" + }, + "choose_entity_naming_initial": { + "data": { + "entity_naming_pattern": "Padrão de nomenclatura" + }, + "data_description": { + "entity_naming_pattern": "Os nomes amigáveis usam as etiquetas dos circuitos do painel. Os nomes baseados em posições usam os números das posições dos disjuntores para IDs de entidade estáveis." + }, + "description": "Escolha como as entidades relacionadas com circuitos são nomeadas no Home Assistant.", + "title": "Nomenclatura de entidades de circuito" + }, + "choose_v2_auth": { + "menu_options": { + "auth_passphrase": "Inserir Frase-passe do Painel", + "auth_proximity": "Prova de Proximidade (abrir/fechar porta)" + }, + "title": "Escolher Método de Autenticação" + }, "confirm_discovery": { "description": "Deseja configurar o Painel Span em {host}?", "title": "Ligar ao Painel Span" }, - "user": { + "fqdn_failed": { + "description": "Não foi possível registar o nome de domínio no painel ou o certificado TLS não foi atualizado a tempo. Pode continuar, mas a conexão MQTT pode falhar se o certificado do painel não incluir o seu nome de domínio.", + "title": "Registo de Domínio" + }, + "reauth_confirm": { + "description": "O Painel Span em {host} requer re-autenticação.", + "menu_options": { + "auth_passphrase": "Inserir Frase-passe do Painel", + "auth_proximity": "Prova de Proximidade (abrir/fechar porta)" + }, + "title": "Re-autenticar Painel Span" + }, + "reconfigure": { "data": { - "host": "Anfitrião", - "use_ssl": "Usar SSL", - "simulator_mode": "Modo Simulador", - "power_display_precision": "Precisão de Exibição de Potência", - "energy_display_precision": "Precisão de Exibição de Energia" + "host": "Anfitrião" }, "data_description": { - "host": "Endereço IP ou nome do host do SPAN Panel (para simulação: use número de série como 'sp3-simulation-001' ou deixe vazio para padrão)", - "use_ssl": "Ativar conexão HTTPS segura ao SPAN Panel", - "simulator_mode": "Ativar modo simulador para testes (não requer hardware). Deixe o host vazio para simulação padrão, ou insira um número de série personalizado.", - "power_display_precision": "Número de casas decimais para valores de potência (0-3)", - "energy_display_precision": "Número de casas decimais para valores de energia (0-3)" + "host": "Endereço IP ou nome do host do SPAN Panel" }, - "description": "Insira os detalhes de conexão para seu SPAN Panel. Para modo simulador, marque a caixa do simulador e opcionalmente insira um número de série personalizado.", - "title": "Ligar ao Painel Span" + "description": "Atualize o endereço do anfitrião para este SPAN Panel. O número de série do painel deve coincidir.", + "title": "Reconfigurar SPAN Panel" }, - "choose_auth_type": { - "title": "Escolher Opções de Autenticação", - "menu_options": { - "auth_proximity": "Autenticar através do seu Painel Span físico", - "auth_token": "Autenticar usando um token de acesso" - } + "reconfigure_fqdn_done": { + "title": "Reconfiguração Concluída" }, - "auth_proximity": { - "title": "Autenticação por Proximidade", - "description": "Por favor, abra e feche a porta do Painel Span 3 vezes." + "reconfigure_fqdn_failed": { + "description": "Não foi possível registar o nome de domínio no painel ou o certificado TLS não foi atualizado a tempo. Pode continuar, mas a conexão MQTT pode falhar se o certificado do painel não incluir o seu nome de domínio.", + "title": "Registo de Domínio" }, - "auth_token": { - "title": "Autenticação Manual por Token", - "description": "Por favor, introduza o seu token de acesso (Vazio para recomeçar)", - "data": { - "access_token": "Token de Acesso" - } + "reconfigure_register_fqdn": { + "title": "A Registar Nome de Domínio" }, - "simulator_config": { - "title": "Configuração do Simulador", - "description": "Selecione uma configuração de simulação e opcionalmente defina um horário de início personalizado. {config_count} configurações disponíveis.", + "register_fqdn": { + "title": "A Registar Nome de Domínio" + }, + "user": { "data": { - "simulation_config": "Configuração de Simulação", - "host": "Anfitrião (Número de Série)", - "simulation_start_time": "Horário de Início da Simulação (Opcional)", - "entity_naming_pattern": "Padrão de Nomenclatura de Entidades" + "enable_energy_dip_compensation": "Compensar Quedas de Energia Automaticamente", + "energy_display_precision": "Precisão de Exibição de Energia", + "host": "Anfitrião", + "http_port": "Porta HTTP", + "power_display_precision": "Precisão de Exibição de Potência" }, "data_description": { - "simulation_config": "Escolha uma configuração de simulação que define modelos de circuito e perfis de energia", - "host": "Número de série personalizado para o painel simulado (opcional, usará o padrão da configuração se vazio)", - "simulation_start_time": "Formato de horário: HH:MM (24h), H:MM (12h), ou datetime ISO completo (YYYY-MM-DDTHH:MM:SS). Deixe vazio para usar o horário atual.", - "entity_naming_pattern": "Escolha como as entidades de circuito são nomeadas na simulação" - } + "enable_energy_dip_compensation": "Compensar automaticamente quando o painel reporta leituras de energia mais baixas, prevenindo picos no painel de energia.", + "energy_display_precision": "Número de casas decimais para valores de energia (0-3)", + "host": "Endereço IP ou nome do host do SPAN Panel", + "http_port": "Porta HTTP para a API do painel (padrão: 80)", + "power_display_precision": "Número de casas decimais para valores de potência (0-3)" + }, + "description": "Insira os detalhes de conexão para seu SPAN Panel.", + "title": "Ligar ao Painel Span" } } }, - "options": { - "error": { - "directory": "O caminho do arquivo é necessário", - "base": "Falha na exportação: {error}" + "entity": { + "binary_sensor": { + "bess_connected": { + "name": "BESS Conectado" + }, + "door_state": { + "name": "Estado da Porta" + }, + "ethernet_link": { + "name": "Ligação Ethernet" + }, + "evse_charging": { + "name": "Carregando" + }, + "evse_ev_connected": { + "name": "VE Conectado" + }, + "grid_islandable": { + "name": "Isolável da Rede" + }, + "panel_status": { + "name": "Estado do Painel" + }, + "wifi_link": { + "name": "Ligação Wi-Fi" + } }, - "step": { - "init": { - "title": "Menu de Opções", - "menu_options": { - "general_options": "Opções Gerais", - "entity_naming_options": "Opções de Nomenclatura de Entidades", - "entity_naming": "Padrão de Nomenclatura de Entidades", - "export_config": "Exportar Configuração de Sensores Sintéticos", - "simulation_start_time": "Horário de Início da Simulação", - "simulation_offline_minutes": "Minutos Offline da Simulação" + "button": { + "gfe_override": { + "name": "Substituição GFE: Conectado à Rede" + } + }, + "select": { + "circuit_priority": { + "name": "Prioridade do Circuito", + "state": { + "never": "Permanece ligado em caso de falha", + "off_grid": "Desliga em caso de falha", + "soc_threshold": "Permanece ligado até o limite da bateria" } + } + }, + "sensor": { + "battery_level": { + "name": "Nível da Bateria" }, - "general_options": { - "title": "Opções Gerais", - "data": { - "scan_interval": "Intervalo de verificação em segundos", - "enable_battery_percentage": "Ativar Sensor de Percentagem da Bateria", - "enable_solar_circuit": "Ativar Sensores do Inversor Solar", - "leg1": "Fase Solar 1", - "leg2": "Fase Solar 2", - "api_retries": "Tentativas de API (0-10)", - "api_retry_timeout": "Tempo limite de tentativa de API (segundos)", - "api_retry_backoff_multiplier": "Multiplicador de atraso de tentativa de API", - "energy_reporting_grace_period": "Período de Graça do Sensor de Energia (minutos)", - "legacy_upgrade_to_friendly": "Add Device Name Prefix to Entity ID's", - "enable_panel_net_energy_sensors": "Energia Líquida do Painel", - "enable_circuit_net_energy_sensors": "Energia Líquida do Circuito", - "enable_solar_net_energy_sensors": "Energia Líquida Solar" - }, - "data_description": { - "enable_solar_circuit": "Ativar sensores sintéticos para monitoramento do inversor solar. Requer seleção de duas abas não mapeadas em fases elétricas opostas.", - "energy_reporting_grace_period": "Quanto tempo os sensores de energia mantêm seu último valor conhecido quando o painel fica indisponível (0-60 minutos). Ajuda a preservar a integridade das estatísticas de energia durante interrupções breves. Padrão: 15 minutos.", - "legacy_upgrade_to_friendly": "Warning: This irreversible operation will change your circuit entity IDs allowing support for more than one panel", - "enable_panel_net_energy_sensors": "Criar sensores de energia líquida para fluxos de energia no nível do painel (medidor principal e derivação). Mostra o verdadeiro balanço energético contabilizando fluxos bidirecionais.", - "enable_circuit_net_energy_sensors": "Criar sensores de energia líquida para circuitos individuais. Mostra o verdadeiro consumo de energia contabilizando potência reativa e fluxos regenerativos.", - "enable_solar_net_energy_sensors": "Criar sensores de energia líquida para geração solar. Mostra a produção solar líquida contabilizando qualquer consumo em circuitos solares." + "battery_power": { + "name": "Potência da Bateria" + }, + "bess_firmware_version": { + "name": "Versão do Firmware" + }, + "bess_model": { + "name": "Modelo" + }, + "bess_nameplate_capacity": { + "name": "Capacidade Nominal" + }, + "bess_serial_number": { + "name": "Número de Série" + }, + "bess_soe_kwh": { + "name": "Estado de Energia" + }, + "bess_vendor": { + "name": "Fornecedor" + }, + "current_run_config": { + "name": "Configuração Atual de Operação", + "state": { + "panel_backup": "Reserva", + "panel_off_grid": "Fora da Rede", + "panel_on_grid": "Na Rede", + "unknown": "Desconhecido" } }, - "entity_naming_options": { - "title": "Opções de Nomenclatura de Entidades", - "description": "Configure como os IDs de entidade são nomeados. Escolha entre nomes amigáveis (ex., kitchen_outlets_power) ou números de abas de circuito (ex., circuit_15_power).", - "data": { - "legacy_upgrade_to_friendly": "Atualizar Instalação Legacy para Nomes Amigáveis", - "entity_naming_pattern": "Padrão de Nomenclatura de Entidades" - }, - "data_description": { - "legacy_upgrade_to_friendly": "Atualizar esta instalação legacy para usar nomes de entidade amigáveis com prefixo de dispositivo. Isso renomeará todas as entidades existentes.", - "entity_naming_pattern": "Escolha como os IDs de entidade são construídos. Nomes amigáveis usam nomes de circuito (ex., kitchen_outlets_power), enquanto números de circuito usam posições de aba (ex., circuit_15_power)." + "downstream_l1_current": { + "name": "Corrente Downstream L1" + }, + "downstream_l2_current": { + "name": "Corrente Downstream L2" + }, + "dsm_grid_state": { + "name": "Estado DSM da Rede", + "state": { + "dsm_off_grid": "Fora da Rede", + "dsm_on_grid": "Na Rede", + "unknown": "Desconhecido" } }, - "entity_naming": { - "title": "Padrão de Nomenclatura de Entidades", - "description": "Escolha como as entidades de circuito são nomeadas. **Alterar isto irá renomear as suas entidades existentes.**\n\n{friendly_example}\n\n{circuit_example}\n\n⚠️ **Importante**: \n• O histórico das entidades será preservado durante a renomeação\n• Considere fazer backup da sua configuração antes de prosseguir\n• Automações e scripts podem precisar de atualizações manuais para usar os novos IDs de entidade", - "data": { - "entity_naming_pattern": "Padrão de Nomenclatura de Entidades" + "dsm_state": { + "name": "Estado DSM", + "state": { + "dsm_off_grid": "Fora da Rede", + "dsm_on_grid": "Na Rede", + "unknown": "Desconhecido" } }, - "export_config": { - "title": "Exportar Configuração de Sensores Sintéticos", - "description": "Exportar a configuração atual de sensores sintéticos para um arquivo YAML. Você pode especificar um diretório (o arquivo será nomeado automaticamente) ou um caminho de arquivo completo. Nome do arquivo padrão: {filename}", - "data": { - "directory": "Caminho do Arquivo" - }, - "data_description": { - "directory": "Um caminho de diretório (o arquivo será nomeado automaticamente) ou um caminho de arquivo completo terminando em .yaml onde a configuração será salva." + "evse_advertised_current": { + "name": "Corrente Anunciada" + }, + "evse_lock_state": { + "name": "Estado do Bloqueio", + "state": { + "locked": "Bloqueado", + "unknown": "Desconhecido", + "unlocked": "Desbloqueado" } }, - "simulation_start_time": { - "title": "Horário de Início da Simulação", - "description": "Configurar o horário de início da simulação. Mudanças no horário de simulação recarregarão a integração para aplicar o novo horário.", - "data": { - "simulation_start_time": "Horário de Início da Simulação" - }, - "data_description": { - "simulation_start_time": "Formato de horário: HH:MM (24h), H:MM (12h), ou datetime ISO completo (YYYY-MM-DDTHH:MM:SS). Deixe vazio para usar o horário atual. A integração será recarregada para aplicar as mudanças." + "evse_status": { + "name": "Estado do Carregador", + "state": { + "available": "Disponível", + "charging": "Carregando", + "faulted": "Com Defeito", + "finishing": "Finalizando", + "preparing": "Preparando", + "reserved": "Reservado", + "suspended_ev": "Suspenso pelo Veículo", + "suspended_evse": "Suspenso pelo Carregador", + "unavailable": "Indisponível", + "unknown": "Desconhecido" + } + }, + "feedthrough_consumed_energy": { + "name": "Energia Consumida pela Derivação" + }, + "feedthrough_net_energy": { + "name": "Energia Líquida da Derivação" + }, + "feedthrough_power": { + "name": "Potência da Derivação" + }, + "feedthrough_produced_energy": { + "name": "Energia Produzida pela Derivação" + }, + "grid_forming_entity": { + "name": "Entidade Formadora de Rede", + "state": { + "battery": "Bateria", + "generator": "Gerador", + "grid": "Rede", + "none": "Nenhum", + "pv": "PV", + "unknown": "Desconhecido" + } + }, + "grid_power_flow": { + "name": "Potência da Rede" + }, + "instant_grid_power": { + "name": "Potência Atual" + }, + "l1_voltage": { + "name": "Tensão L1" + }, + "l2_voltage": { + "name": "Tensão L2" + }, + "main_breaker_rating": { + "name": "Classificação do Disjuntor Principal" + }, + "main_meter_consumed_energy": { + "name": "Energia Consumida pelo Medidor Principal" + }, + "main_meter_net_energy": { + "name": "Energia Líquida do Medidor Principal" + }, + "main_meter_produced_energy": { + "name": "Energia Produzida pelo Medidor Principal" + }, + "main_relay_state": { + "name": "Estado do Relé Principal", + "state": { + "closed": "Fechado", + "open": "Aberto", + "unknown": "Desconhecido" } }, - "simulation_offline_minutes": { - "title": "Minutos Offline da Simulação", - "description": "Configurar por quanto tempo o painel deve aparecer offline durante a simulação.", + "pv_nameplate_capacity": { + "name": "Capacidade Nominal PV" + }, + "pv_power": { + "name": "Potência PV" + }, + "pv_product": { + "name": "Produto PV" + }, + "pv_vendor": { + "name": "Fornecedor PV" + }, + "site_power": { + "name": "Potência do Local" + }, + "software_version": { + "name": "Versão do Software" + }, + "upstream_l1_current": { + "name": "Corrente Upstream L1" + }, + "upstream_l2_current": { + "name": "Corrente Upstream L2" + }, + "vendor_cloud": { + "name": "Nuvem do Fornecedor", + "state": { + "connected": "Conectado", + "unconnected": "Desconectado", + "unknown": "Desconhecido" + } + } + } + }, + "exceptions": { + "circuit_priority_failed": { + "message": "Falha ao definir a prioridade do circuito para {circuit}." + }, + "circuit_relay_failed": { + "message": "Falha ao definir o estado do relé do circuito para {circuit}." + }, + "export_manifest_no_entries": { + "message": "Nenhuma entrada de configuração do painel SPAN está carregada. Adicione e configure um painel SPAN antes de chamar este serviço." + }, + "favorite_no_circuit_uuid": { + "message": "Não foi possível derivar um destino favorito a partir da entidade {entity_id}. Selecione um sensor de circuito (corrente/potência) ou um sensor de sub-dispositivo." + }, + "favorite_no_device": { + "message": "A entidade {entity_id} não está associada a um dispositivo. Selecione um sensor de circuito ou sub-dispositivo SPAN." + }, + "favorite_no_unique_id": { + "message": "A entidade {entity_id} não tem um id único e não pode ser resolvida para um destino SPAN." + }, + "favorite_not_span_entity": { + "message": "A entidade {entity_id} não é uma entidade do SPAN Panel. Selecione um sensor de circuito, bateria (BESS) ou carregador EV (EVSE)." + }, + "favorite_subdevice_no_span_parent": { + "message": "O sub-dispositivo {entity_id} não tem um painel SPAN pai — o seu via_device_id não aponta para um painel SPAN." + }, + "gfe_override_failed": { + "message": "Falha ao enviar substituição GFE para o painel." + }, + "graph_horizon_not_available": { + "message": "O gerenciador de horizonte do gráfico não está disponível para o painel SPAN selecionado." + }, + "monitoring_not_enabled": { + "message": "Nenhum painel SPAN com monitoramento de corrente ativado." + }, + "panel_auth_failed": { + "message": "A autenticação com o SPAN Panel falhou. Por favor, reautentique." + }, + "panel_connection_failed": { + "message": "Falha ao ligar ao SPAN Panel em {host}." + }, + "panel_not_ready": { + "message": "O SPAN Panel não está pronto. Verifique se o painel está online." + } + }, + "options": { + "step": { + "init": { "data": { - "simulation_offline_minutes": "Minutos Offline" + "show_panel": "Mostrar Painel na Barra Lateral", + "panel_admin_only": "Apenas Usuários Administradores", + "enable_circuit_net_energy_sensors": "Energia Líquida do Circuito", + "enable_energy_dip_compensation": "Compensar Quedas de Energia Automaticamente", + "enable_panel_net_energy_sensors": "Energia Líquida do Painel", + "enable_unmapped_circuit_sensors": "Sensores de Circuitos Não Mapeados", + "energy_reporting_grace_period": "Período de Graça do Sensor de Energia (minutos)", + "snapshot_update_interval": "Intervalo de Atualização do Snapshot (segundos)" }, "data_description": { - "simulation_offline_minutes": "Número de minutos que o painel deve aparecer offline durante a simulação. Defina como 0 para desabilitar a simulação offline." - } + "show_panel": "Exiba o painel de controle do SPAN como uma entrada de barra lateral.", + "panel_admin_only": "Restrinja a entrada da barra lateral do painel de controle apenas aos usuários administradores.", + "enable_circuit_net_energy_sensors": "Energia líquida por circuito, contabilizando potência reativa.", + "enable_energy_dip_compensation": "Compensar quando o painel reporta leituras de energia mais baixas. Ao desativar, as compensações são apagadas.", + "enable_panel_net_energy_sensors": "Energia líquida para o medidor principal e derivação, contabilizando fluxos bidirecionais.", + "enable_unmapped_circuit_sensors": "Sensores de apoio para posições não atribuídas a disjuntores nomeados.", + "energy_reporting_grace_period": "Minutos que os sensores mantêm o último valor quando o painel está offline (0–60). Padrão: 15.", + "snapshot_update_interval": "Segundos entre reconstruções do snapshot (0–15). Menor = mais rápido, mais CPU. 0 = sem debounce." + }, + "description": "Configurar os ajustes da integração SPAN Panel.", + "title": "Opções do painel SPAN" } } }, "selector": { "entity_naming_pattern": { "options": { - "friendly_names": "Nomes Amigáveis (ex., span_panel_cozinha_tomadas_power)", - "circuit_numbers": "Números de Circuito (ex., span_panel_circuit_15_power)" + "circuit_numbers": "Números de Circuito (ex., span_panel_circuit_15_power)", + "friendly_names": "Nomes Amigáveis (ex., span_panel_cozinha_tomadas_power)" + } + } + }, + "services": { + "export_circuit_manifest": { + "description": "Retorna um manifesto estruturado de circuitos para todos os painéis SPAN configurados: número de série, anfitrião e IDs de entidade dos sensores de potência por circuito, identificadores de modelo de layout (clone_{{tab}}), tipos de dispositivo e números de posição do disjuntor. Destinado a cartões de painel e automações que necessitam da topologia do painel sem consultar o registo de entidades. Este é um serviço apenas de resposta; a resposta pode listar zero painéis se nada puder ser resolvido.", + "name": "Exportar manifesto de circuitos" + }, + "set_circuit_threshold": { + "name": "Definir limiar do circuito", + "description": "Definir limiares de monitorização de corrente para um circuito específico.", + "fields": { + "circuit_id": { + "name": "Circuito", + "description": "A entidade do sensor de potência do circuito." + }, + "continuous_threshold_pct": { + "name": "Limiar contínuo (%)", + "description": "Percentagem da capacidade do disjuntor para alertas de carga contínua." + }, + "spike_threshold_pct": { + "name": "Limiar de pico (%)", + "description": "Percentagem da capacidade do disjuntor para alertas de pico instantâneo." + }, + "window_duration_m": { + "name": "Duração da janela (minutos)", + "description": "Quanto tempo a corrente deve permanecer acima do limiar antes de alertar." + }, + "cooldown_duration_m": { + "name": "Duração de arrefecimento (minutos)", + "description": "Minutos entre alertas repetidos para o mesmo circuito." + }, + "monitoring_enabled": { + "name": "Monitorização ativada", + "description": "Ativar ou desativar a monitorização para este circuito." + }, + "config_entry_id": { + "name": "Entrada de configuração", + "description": "Id de entrada de configuração opcional. Necessário apenas quando há mais de um painel SPAN configurado." + } + } + }, + "clear_circuit_threshold": { + "name": "Limpar limiar do circuito", + "description": "Remover substituições de limiar por circuito, revertendo para os valores globais predefinidos.", + "fields": { + "circuit_id": { + "name": "Circuito", + "description": "A entidade do sensor de potência do circuito." + }, + "config_entry_id": { + "name": "Entrada de configuração", + "description": "Id de entrada de configuração opcional. Necessário apenas quando há mais de um painel SPAN configurado." + } + } + }, + "set_mains_threshold": { + "name": "Definir limiar da alimentação principal", + "description": "Definir limiares de monitorização de corrente para uma fase da alimentação principal específica.", + "fields": { + "leg": { + "name": "Fase da alimentação principal", + "description": "A entidade do sensor de corrente da alimentação principal." + }, + "continuous_threshold_pct": { + "name": "Limiar contínuo (%)", + "description": "Percentagem da capacidade do disjuntor para alertas de carga contínua." + }, + "spike_threshold_pct": { + "name": "Limiar de pico (%)", + "description": "Percentagem da capacidade do disjuntor para alertas de pico instantâneo." + }, + "window_duration_m": { + "name": "Duração da janela (minutos)", + "description": "Quanto tempo a corrente deve permanecer acima do limiar antes de alertar." + }, + "cooldown_duration_m": { + "name": "Duração de arrefecimento (minutos)", + "description": "Minutos entre alertas repetidos para a mesma fase da alimentação principal." + }, + "monitoring_enabled": { + "name": "Monitorização ativada", + "description": "Ativar ou desativar a monitorização para esta fase da alimentação principal." + }, + "config_entry_id": { + "name": "Entrada de configuração", + "description": "Id de entrada de configuração opcional. Necessário apenas quando há mais de um painel SPAN configurado." + } + } + }, + "clear_mains_threshold": { + "name": "Limpar limiar da alimentação principal", + "description": "Remover substituições de limiar por fase da alimentação principal, revertendo para os valores globais predefinidos.", + "fields": { + "leg": { + "name": "Fase da alimentação principal", + "description": "A entidade do sensor de corrente da alimentação principal." + }, + "config_entry_id": { + "name": "Entrada de configuração", + "description": "Id de entrada de configuração opcional. Necessário apenas quando há mais de um painel SPAN configurado." + } + } + }, + "get_monitoring_status": { + "name": "Obter estado da monitorização", + "description": "Retorna o estado atual de monitorização para todos os circuitos e fases da alimentação principal monitorizados.", + "fields": { + "config_entry_id": { + "name": "Entrada de configuração", + "description": "Id de entrada de configuração opcional. Necessário apenas quando há mais de um painel SPAN configurado." + } + } + }, + "set_global_monitoring": { + "name": "Configurar monitorização global", + "description": "Atualizar os limiares de monitorização global e as definições de notificação.", + "fields": { + "enabled": { + "name": "Ativado", + "description": "Ativar ou desativar o monitoramento de corrente globalmente para este painel SPAN." + }, + "continuous_threshold_pct": { + "name": "Limiar contínuo", + "description": "Percentagem da capacidade do disjuntor para deteção de sobrecarga contínua." + }, + "spike_threshold_pct": { + "name": "Limiar de pico", + "description": "Percentagem da capacidade do disjuntor para deteção de picos." + }, + "window_duration_m": { + "name": "Duração da janela", + "description": "Minutos de sobrecarga sustentada antes de gerar um alerta." + }, + "cooldown_duration_m": { + "name": "Duração de arrefecimento", + "description": "Minutos entre alertas repetidos para o mesmo ponto." + }, + "notify_targets": { + "name": "Destinos de notificação", + "description": "Lista separada por vírgulas de destinos de serviços de notificação." + }, + "notification_title_template": { + "name": "Modelo de título de notificação", + "description": "Modelo para o título da notificação. Marcadores: {name}, {entity_id}, {alert_type}, {current_a}, {breaker_rating_a}, {threshold_pct}, {utilization_pct}, {window_m}." + }, + "notification_message_template": { + "name": "Modelo de mensagem de notificação", + "description": "Modelo para o corpo da mensagem de notificação. Marcadores: {name}, {entity_id}, {alert_type}, {current_a}, {breaker_rating_a}, {threshold_pct}, {utilization_pct}, {window_m}." + }, + "notification_priority": { + "name": "Prioridade de notificação", + "description": "Nível de prioridade da notificação push. Controla o comportamento de interrupção em dispositivos móveis (iOS: interruption-level, Android: priority/canal)." + }, + "config_entry_id": { + "name": "Entrada de configuração", + "description": "Id de entrada de configuração opcional. Necessário apenas quando há mais de um painel SPAN configurado." + } + } + }, + "test_notification": { + "name": "Notificação de teste", + "description": "Enviar uma notificação de teste para todos os destinos configurados com valores de exemplo.", + "fields": { + "config_entry_id": { + "name": "Entrada de configuração", + "description": "Id de entrada de configuração opcional. Necessário apenas quando há mais de um painel SPAN configurado." + } + } + }, + "set_graph_time_horizon": { + "name": "Definir horizonte temporal do gráfico", + "description": "Define o horizonte temporal padrão global para os gráficos de circuitos.", + "fields": { + "horizon": { + "name": "Horizonte", + "description": "Janela temporal predefinida para a exibição do gráfico." + }, + "config_entry_id": { + "name": "Entrada de configuração", + "description": "Id de entrada de configuração opcional. Necessário apenas quando há mais de um painel SPAN configurado." + } + } + }, + "set_circuit_graph_horizon": { + "name": "Definir horizonte de gráfico por circuito", + "description": "Define uma substituição de horizonte temporal por circuito para os gráficos.", + "fields": { + "circuit_id": { + "name": "ID do circuito", + "description": "O identificador do circuito a substituir." + }, + "horizon": { + "name": "Horizonte", + "description": "Janela temporal predefinida para a exibição do gráfico." + }, + "config_entry_id": { + "name": "Entrada de configuração", + "description": "Id de entrada de configuração opcional. Necessário apenas quando há mais de um painel SPAN configurado." + } + } + }, + "clear_circuit_graph_horizon": { + "name": "Limpar horizonte de gráfico do circuito", + "description": "Remove a substituição do horizonte de gráfico por circuito, revertendo para o padrão global.", + "fields": { + "circuit_id": { + "name": "ID do circuito", + "description": "O identificador do circuito para limpar a substituição." + }, + "config_entry_id": { + "name": "Entrada de configuração", + "description": "Id de entrada de configuração opcional. Necessário apenas quando há mais de um painel SPAN configurado." + } + } + }, + "set_subdevice_graph_horizon": { + "name": "Definir horizonte de gráfico por subdispositivo", + "description": "Define uma substituição de horizonte temporal por subdispositivo para os gráficos.", + "fields": { + "subdevice_id": { + "name": "ID do subdispositivo", + "description": "O identificador do subdispositivo a substituir." + }, + "horizon": { + "name": "Horizonte", + "description": "Janela temporal predefinida para a exibição do gráfico." + }, + "config_entry_id": { + "name": "Entrada de configuração", + "description": "Id de entrada de configuração opcional. Necessário apenas quando há mais de um painel SPAN configurado." + } + } + }, + "clear_subdevice_graph_horizon": { + "name": "Limpar horizonte de gráfico do subdispositivo", + "description": "Remove a substituição do horizonte de gráfico por subdispositivo, revertendo para o padrão global.", + "fields": { + "subdevice_id": { + "name": "ID do subdispositivo", + "description": "O identificador do subdispositivo para limpar a substituição." + }, + "config_entry_id": { + "name": "Entrada de configuração", + "description": "Id de entrada de configuração opcional. Necessário apenas quando há mais de um painel SPAN configurado." + } + } + }, + "get_graph_settings": { + "name": "Obter definições de gráfico", + "description": "Retorna as definições atuais de horizonte de gráfico globais e por circuito.", + "fields": { + "config_entry_id": { + "name": "Entrada de configuração", + "description": "Id de entrada de configuração opcional. Necessário apenas quando há mais de um painel SPAN configurado." + } + } + }, + "get_favorites": { + "name": "Obter favoritos", + "description": "Retorna o mapa de favoritos entre painéis, indexado pelo id de dispositivo do painel SPAN." + }, + "add_favorite": { + "name": "Adicionar favorito", + "description": "Marca um circuito SPAN, bateria (BESS) ou carregador EV (EVSE) como favorito para que apareça na vista de Favoritos do painel de controlo.", + "fields": { + "entity_id": { + "name": "Entidade", + "description": "Qualquer sensor do circuito ou sub-dispositivo a marcar como favorito (corrente, potência, SoC, etc.)." + } + } + }, + "remove_favorite": { + "name": "Remover favorito", + "description": "Remove um circuito ou sub-dispositivo da vista de Favoritos entre painéis.", + "fields": { + "entity_id": { + "name": "Entidade", + "description": "Qualquer sensor do circuito ou sub-dispositivo a remover dos favoritos." + } } } } diff --git a/custom_components/span_panel/util.py b/custom_components/span_panel/util.py index c34ab68e..a42ce0de 100644 --- a/custom_components/span_panel/util.py +++ b/custom_components/span_panel/util.py @@ -3,31 +3,65 @@ import logging from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.util import slugify +from span_panel_api import SpanBatterySnapshot, SpanEvseSnapshot, SpanPanelSnapshot from .const import DOMAIN -from .span_panel import SpanPanel _LOGGER = logging.getLogger(__name__) -def panel_to_device_info(panel: SpanPanel, device_name: str | None = None) -> DeviceInfo: - """Convert a Span Panel to a Home Assistant device info object. - - For simulator entries, use a per-entry identifier derived from the device name - so multiple simulators don't collapse into a single device in the registry. - Live panels continue to use the true serial number identifier. - """ - if getattr(panel.api, "simulation_mode", False) and device_name: - device_identifier = slugify(device_name) - else: - device_identifier = panel.status.serial_number +def snapshot_to_device_info( + snapshot: SpanPanelSnapshot, + device_name: str | None = None, + host: str | None = None, +) -> DeviceInfo: + """Convert a SpanPanelSnapshot to a Home Assistant device info object.""" + configuration_url = f"http://{host}" if host else None return DeviceInfo( - identifiers={(DOMAIN, device_identifier)}, + identifiers={(DOMAIN, snapshot.serial_number)}, manufacturer="Span", - model=f"Span Panel ({panel.status.model})", + model="SPAN Panel", name=device_name or "Span Panel", - sw_version=panel.status.firmware_version, - configuration_url=f"http://{panel.host}", + sw_version=snapshot.firmware_version, + configuration_url=configuration_url, + ) + + +def bess_device_info( + panel_identifier: str, + battery: SpanBatterySnapshot, + panel_name: str, +) -> DeviceInfo: + """Create DeviceInfo for a BESS sub-device linked to the parent panel.""" + name = f"{panel_name} Battery" + return DeviceInfo( + identifiers={(DOMAIN, f"{panel_identifier}_bess")}, + name=name, + manufacturer=battery.vendor_name or "Unknown", + model=battery.product_name or "Battery Storage", + serial_number=battery.serial_number, + sw_version=battery.software_version, + via_device=(DOMAIN, panel_identifier), + ) + + +def evse_device_info( + panel_identifier: str, + evse: SpanEvseSnapshot, + panel_name: str, + display_suffix: str | None = None, +) -> DeviceInfo: + """Create DeviceInfo for an EVSE sub-device linked to the parent panel.""" + base_name = evse.product_name or "EV Charger" + name = f"{base_name} ({display_suffix})" if display_suffix else base_name + name = f"{panel_name} {name}" + return DeviceInfo( + identifiers={(DOMAIN, f"{panel_identifier}_evse_{evse.node_id}")}, + name=name, + manufacturer=evse.vendor_name or "SPAN", + model=evse.product_name or "SPAN Drive", + serial_number=evse.serial_number, + sw_version=evse.software_version, + via_device=(DOMAIN, panel_identifier), ) diff --git a/custom_components/span_panel/version.py b/custom_components/span_panel/version.py deleted file mode 100644 index a264aa28..00000000 --- a/custom_components/span_panel/version.py +++ /dev/null @@ -1,30 +0,0 @@ -"""Version for span.""" - -import json -import os -from typing import Any, cast - -from homeassistant.core import HomeAssistant - - -def get_version() -> str: - """Return the version from manifest.json.""" - manifest_path = os.path.join(os.path.dirname(__file__), "manifest.json") - with open(manifest_path, encoding="utf-8") as f: - manifest: dict[str, Any] = json.load(f) - return cast(str, manifest["version"]) - - -async def async_get_version(hass: HomeAssistant) -> str: - """Return the version from manifest.json using async file operations.""" - manifest_path = os.path.join(os.path.dirname(__file__), "manifest.json") - - def _read_manifest() -> dict[str, Any]: - with open(manifest_path, encoding="utf-8") as f: - return cast(dict[str, Any], json.load(f)) - - manifest = await hass.async_add_executor_job(_read_manifest) - return cast(str, manifest["version"]) - - -__version__ = get_version() diff --git a/custom_components/span_panel/websocket.py b/custom_components/span_panel/websocket.py new file mode 100644 index 00000000..aae65dbb --- /dev/null +++ b/custom_components/span_panel/websocket.py @@ -0,0 +1,325 @@ +"""WebSocket API commands for the Span Panel integration.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from homeassistant.components import websocket_api +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er +import voluptuous as vol + +from .const import DOMAIN +from .helpers import build_panel_unique_id +from .id_builder import build_binary_sensor_unique_id + +if TYPE_CHECKING: + from . import SpanPanelRuntimeData + +# Mapping from unique_id suffixes to semantic role names for circuit sensors. +# Mapping from unique_id suffixes (as generated by build_circuit_unique_id via +# get_user_friendly_suffix) to semantic role names used in the topology response. +# NOTE: build_circuit_unique_id maps API keys through CIRCUIT_SUFFIX_MAPPING, +# so unique_ids end with e.g. "_power" not "_instantPowerW". +# Panel-level sensor keys to resolve via entity registry. +# Maps topology role name -> sensor description key (used by +# build_panel_unique_id to construct the unique_id). +_PANEL_SENSOR_KEYS: dict[str, str] = { + "current_power": "instantGridPowerW", + "site_power": "sitePowerW", + "grid_power": "gridPowerFlowW", + "feedthrough_power": "feedthroughPowerW", + "pv_power": "pvPowerW", + "battery_power": "batteryPowerW", + "battery_level": "storage_battery_percentage", + "dsm_state": "dsm_grid_state", + "main_breaker_rating": "main_breaker_rating", + "upstream_l1_current": "upstream_l1_current", + "upstream_l2_current": "upstream_l2_current", + "downstream_l1_current": "downstream_l1_current", + "downstream_l2_current": "downstream_l2_current", + "l1_voltage": "l1_voltage", + "l2_voltage": "l2_voltage", +} + +_SENSOR_ROLE_SUFFIXES: dict[str, str] = { + "_power": "power", + "_energy_produced": "produced_energy", + "_energy_consumed": "consumed_energy", + "_energy_net": "net_energy", + "_current": "current", + "_breaker_rating": "breaker_rating", +} + + +def async_register_commands(hass: HomeAssistant) -> None: + """Register WebSocket commands for the Span Panel integration.""" + websocket_api.async_register_command(hass, handle_panel_topology) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "span_panel/panel_topology", + vol.Required("device_id"): str, + } +) +@websocket_api.require_admin +@websocket_api.async_response +async def handle_panel_topology( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Return the full panel topology with entity mappings. + + Admin users must pass the HA device registry ID for the **main SPAN panel** + device only (not BESS/EVSE sub-devices). Returns panel metadata, circuits + with tabs/entity mappings, and sub-devices (BESS, EVSE). + """ + device_id = msg["device_id"] + + device_registry = dr.async_get(hass) + device_entry = device_registry.async_get(device_id) + + if device_entry is None: + connection.send_error(msg["id"], "device_not_found", "Device not found") + return + + is_span_device = any(domain == DOMAIN for domain, _ in device_entry.identifiers) + if not is_span_device: + connection.send_error(msg["id"], "not_span_panel", "Device is not a SPAN Panel device") + return + + # Sub-devices (BESS, EVSE) register with via_device_id pointing at the panel. + if device_entry.via_device_id is not None: + connection.send_error( + msg["id"], + "not_panel_device", + "Use the SPAN panel device registry ID, not a BESS or EVSE sub-device.", + ) + return + + # Find the config entry for this device. + config_entry_id = _find_config_entry_id(device_entry) + if config_entry_id is None: + connection.send_error(msg["id"], "not_span_panel", "Device is not a SPAN Panel device") + return + + config_entry = hass.config_entries.async_get_entry(config_entry_id) + if config_entry is None: + connection.send_error(msg["id"], "not_loaded", "SPAN Panel config entry not found") + return + + if config_entry.state is not ConfigEntryState.LOADED: + connection.send_error(msg["id"], "not_loaded", "SPAN Panel integration is not loaded") + return + + runtime_data: SpanPanelRuntimeData = config_entry.runtime_data + coordinator = runtime_data.coordinator + snapshot = coordinator.data + + if snapshot is None: + connection.send_error(msg["id"], "no_data", "SPAN Panel has not yet provided any data") + return + + # Build entity lookup grouped by device_id for sub-device section. + entity_registry = er.async_get(hass) + all_entities = er.async_entries_for_config_entry(entity_registry, config_entry_id) + + entities_by_device: dict[str, list[er.RegistryEntry]] = {} + for entity in all_entities: + if entity.device_id: + entities_by_device.setdefault(entity.device_id, []).append(entity) + + # Resolve panel-level sensor entity_ids via unique_id registry lookup. + panel_entities = _build_panel_entity_map(snapshot.serial_number, entity_registry) + + panel_status_entity = _resolve_panel_status_entity(snapshot.serial_number, entity_registry) + if panel_status_entity is not None: + panel_entities["panel_status"] = panel_status_entity + + # Single pass over all entities to build circuit_id to role to entity_id + # map. EVSE feed circuit sensors live on the EVSE sub-device, so we + # search all entities for the config entry, not just panel-device ones. + circuit_ids = {cid for cid in snapshot.circuits if not cid.startswith("unmapped_tab_")} + entity_map = _build_circuit_entity_map(circuit_ids, all_entities) + + # Build circuits section. + circuits: dict[str, dict[str, Any]] = {} + for circuit_id, circuit in snapshot.circuits.items(): + if circuit_id not in circuit_ids: + continue + + tabs = sorted(circuit.tabs) if circuit.tabs else [] + circuits[circuit_id] = { + "tabs": tabs, + "name": circuit.name or None, + "voltage": 240 if len(tabs) == 2 else 120, + "device_type": circuit.device_type, + "relay_state": circuit.relay_state, + "relay_state_target": circuit.relay_state_target, + "is_user_controllable": circuit.is_user_controllable, + "breaker_rating_a": circuit.breaker_rating_a, + "always_on": circuit.always_on, + "priority": circuit.priority, + "priority_target": circuit.priority_target, + "entities": entity_map.get(circuit_id, {}), + } + + # Build sub-devices section from devices linked via via_device_id. + sub_devices: dict[str, dict[str, Any]] = {} + + for dev in dr.async_entries_for_config_entry(device_registry, config_entry_id): + if dev.id == device_id: + continue + if dev.via_device_id != device_id: + continue + + sub_device_entities = entities_by_device.get(dev.id, []) + sub_device_type = _classify_sub_device(dev) + + sub_devices[dev.id] = { + "name": dev.name, + "type": sub_device_type, + "manufacturer": dev.manufacturer, + "model": dev.model, + "serial_number": dev.serial_number, + "sw_version": dev.sw_version, + "entities": { + ent.entity_id: { + "domain": ent.domain, + "original_name": ent.original_name, + "unique_id": ent.unique_id, + } + for ent in sub_device_entities + }, + } + + connection.send_result( + msg["id"], + { + "serial": snapshot.serial_number, + "firmware": snapshot.firmware_version, + "panel_size": snapshot.panel_size, + "device_id": device_id, + "device_name": device_entry.name, + "panel_entities": panel_entities, + "circuits": circuits, + "sub_devices": sub_devices, + }, + ) + + +def _find_config_entry_id(device_entry: dr.DeviceEntry) -> str | None: + """Find the SPAN Panel config entry ID for a device.""" + is_span_device = any(domain == DOMAIN for domain, _ in device_entry.identifiers) + if not is_span_device: + return None + + # Return the first config entry; a SPAN device only belongs to one. + for config_entry_id in device_entry.config_entries: + return config_entry_id + return None + + +def _classify_sub_device(device_entry: dr.DeviceEntry) -> str: + """Classify a sub-device as 'bess' or 'evse' based on its identifiers.""" + for _, identifier in device_entry.identifiers: + if "_bess" in identifier: + return "bess" + if "_evse_" in identifier: + return "evse" + return "unknown" + + +def _build_circuit_entity_map( + circuit_ids: set[str], + entities: list[er.RegistryEntry], +) -> dict[str, dict[str, str]]: + """Build a circuit_id to {role: entity_id} map in a single pass. + + Iterates over entities once, matching each to a circuit by checking + whether its unique_id contains a known circuit UUID. + """ + result: dict[str, dict[str, str]] = {} + + for entity in entities: + uid = entity.unique_id + + # Skip entities without a unique_id, as we cannot reliably match them. + if not uid: + continue + + # Find which circuit this entity belongs to. + matched_circuit_id: str | None = None + for cid in circuit_ids: + # Avoid plain substring matching to prevent collisions like "1" in "15". + idx = uid.find(cid) + if idx == -1: + continue + + # Require delimiters (or string boundaries) around the circuit_id. + before_ok = idx == 0 or uid[idx - 1] in "_-" + after_index = idx + len(cid) + after_ok = after_index == len(uid) or uid[after_index] in "_-" + + if before_ok and after_ok: + matched_circuit_id = cid + break + + if matched_circuit_id is None: + continue + + mapped = result.setdefault(matched_circuit_id, {}) + + if entity.domain == "switch": + mapped["switch"] = entity.entity_id + elif entity.domain == "select": + mapped["select"] = entity.entity_id + elif entity.domain == "sensor": + role = _classify_sensor_role(uid) + if role: + mapped[role] = entity.entity_id + + return result + + +def _build_panel_entity_map( + serial: str, + entity_registry: er.EntityRegistry, +) -> dict[str, str]: + """Resolve panel-level sensor unique_ids to current entity_ids. + + Returns a dict of {role: entity_id} for sensors that exist in the + registry. Entries that cannot be resolved are omitted. + """ + result: dict[str, str] = {} + for role, description_key in _PANEL_SENSOR_KEYS.items(): + unique_id = build_panel_unique_id(serial, description_key) + entity_id = entity_registry.async_get_entity_id("sensor", DOMAIN, unique_id) + if entity_id is not None: + result[role] = entity_id + return result + + +def _resolve_panel_status_entity( + serial: str, + entity_registry: er.EntityRegistry, +) -> str | None: + """Resolve the panel_status binary sensor entity_id for the frontend. + + The frontend watches this entity to detect panel online/offline state. + The binary sensor is always available (see binary_sensor.py) so the + frontend can rely on its state regardless of coordinator offline status. + """ + unique_id = build_binary_sensor_unique_id(serial, "panel_status") + return entity_registry.async_get_entity_id("binary_sensor", DOMAIN, unique_id) + + +def _classify_sensor_role(unique_id: str) -> str | None: + """Classify a sensor's role from its unique_id suffix.""" + for suffix, role in _SENSOR_ROLE_SUFFIXES.items(): + if unique_id.endswith(suffix): + return role + return None diff --git a/developer.md b/developer.md new file mode 100644 index 00000000..46b96dcc --- /dev/null +++ b/developer.md @@ -0,0 +1,197 @@ +# Development Guide + +## Repository Layout + +The SPAN Panel integration consists of three repositories: + +| Repo | Purpose | Branch | +| ------------------ | ------------------------------- | ------------------- | +| `span` (this repo) | HA custom integration (Python) | `main` | +| `span-panel-api` | API client library (Python) | `main` | +| `span-card` | Frontend dashboard (JavaScript) | `integration-panel` | + +The card repo produces two JS bundles: + +- `span-panel-card.js` -- Lovelace card (standalone, HACS-distributable) +- `span-panel.js` -- Full-page sidebar panel (served by the integration) + +Both bundles are committed as build artifacts in `custom_components/span_panel/frontend/dist/`. + +## Prerequisites + +- [uv](https://docs.astral.sh/uv/) for Python dependency management +- [prek](https://github.com/j178/prek) for pre-commit hooks (fast, Rust-based) +- Python 3.14.2+ +- Node.js 18+ and npm (for the card repo) +- [Home Assistant Core](https://developers.home-assistant.io/docs/development_environment) for local development +- [direnv](https://direnv.net/) (recommended, for automatic env setup) + +## Initial Setup + +```bash +# Clone all repos +git clone ~/projects/HA/span +git clone ~/projects/HA/span-panel-api +git clone ~/projects/HA/cards/span-card + +# Set up the integration +cd ~/projects/HA/span +uv sync +prek install + +# Set up the API library +cd ~/projects/HA/span-panel-api +uv sync + +# Set up the card +cd ~/projects/HA/cards/span-card +npm install +``` + +## Environment Variables + +The `.env` file configures paths to sibling repos and the local HA config directory. These variables are used by build scripts. + +```bash +cd ~/projects/HA/span +cp .env.example .env +``` + +The defaults assume the standard workspace layout: + +```dotenv +# Path to span-panel-api repo (for editable pip install) +export SPAN_PANEL_API_DIR=../span-panel-api + +# Path to span-card frontend repo (for build-frontend.sh) +export SPAN_CARD_DIR=../cards/span-card + +# Path to HA config directory +export HA_CONFIG_DIR=./ha-config +``` + +VS Code loads `.env` automatically into Python terminals (requires `python.terminal.useEnvFile` enabled in workspace settings). For shell use outside VS Code, +[direnv](https://direnv.net/) is recommended -- create an `.envrc` that sources `.env`: + +```bash +echo 'dotenv' > .envrc +direnv allow +``` + +## Pre-commit Hooks + +This project uses prek for pre-commit hooks. Hooks run automatically on `git commit` and check formatting, linting, type checking, translations, and test +coverage. + +The linters may modify files (e.g., to sort imports or reformat). Files that are changed or fail checks will be unstaged. Review the changes, re-stage, and +recommit. + +To run hooks manually: + +```bash +# All hooks on staged files +prek run + +# All hooks on all files +prek run --all-files +``` + +You can also use VS Code's `Tasks: Run Task` from the command palette to run `Run all Pre-commit checks`. + +## Frontend Build Workflow + +The span-card repo is independent -- it has its own git history, branches, and releases. The integration repo consumes its build output via a copy script. There +is no git submodule. + +### Build and update frontend + +```bash +# 1. Make changes in the span-card repo +cd ~/projects/HA/cards/span-card +# ... edit files ... + +# 2. Build and copy into the integration +cd ~/projects/HA/span +./scripts/build-frontend.sh + +# 3. Commit both repos +cd ~/projects/HA/cards/span-card +git add -A && git commit -m "feat: description of card changes" + +cd ~/projects/HA/span +git add custom_components/span_panel/frontend/dist/ +git commit -m "feat: update frontend with card changes" +``` + +### How the build script works + +`scripts/build-frontend.sh` does three things: + +1. Runs `npm run build` in the span-card repo (rollup produces two IIFE bundles) +2. Copies `dist/span-panel.js` and `dist/span-panel-card.js` into `custom_components/span_panel/frontend/dist/` +3. Prints the files and a reminder to stage them + +The script reads `SPAN_CARD_DIR` from `.env` (or the environment). You can also pass the path as an argument: + +```bash +# Uses SPAN_CARD_DIR from .env +./scripts/build-frontend.sh + +# Via argument (overrides env var) +./scripts/build-frontend.sh ~/projects/HA/cards/span-card +``` + +### Local development with HA Core + +When running HA Core locally, the integration is symlinked into `config/custom_components/span_panel`. The frontend JS files are served from +`custom_components/span_panel/frontend/dist/` via the `async_register_static_paths` call in `__init__.py`. + +After rebuilding the frontend, restart HA to pick up the new JS. Browsers cache aggressively -- a hard refresh (Cmd+Shift+R) of the panel page also works if you +clear the `cache_headers` flag during development. + +## Running Tests + +```bash +# Full suite +python -m pytest tests/ -q + +# Single file +python -m pytest tests/test_current_monitor.py -q + +# With coverage +python -m pytest tests/ --cov=custom_components/span_panel --cov-report=term-missing +``` + +## Linting and Type Checking + +```bash +# Ruff (lint + format) +ruff check custom_components/span_panel/ +ruff format custom_components/span_panel/ + +# Mypy +python -m mypy custom_components/span_panel/ + +# Markdown +./scripts/fix-markdown.sh . +``` + +## Translation Workflow + +Source strings live in `custom_components/span_panel/strings.json`. Translated files in `translations/` are synced from `strings.json` by the pre-commit hook +(`sync_translations.py`). + +To add a new translatable string: + +1. Add the key to `strings.json` +2. Add translations to each `translations/.json` +3. The pre-commit hook validates that all translation files match `strings.json` keys + +## Panel Sidebar Registration + +The integration registers a sidebar panel in `async_setup()` (domain-level, called once). Panel visibility (`show_panel`, `admin_only`) is stored in +domain-level storage (`span_panel_settings`) -- shared across all config entries. These settings are editable from any entry's options flow. + +## VS Code + +See `.vscode/settings.json.example` for starter settings. diff --git a/docs/dev/README.md b/docs/dev/README.md deleted file mode 100644 index 64d1efe0..00000000 --- a/docs/dev/README.md +++ /dev/null @@ -1,16 +0,0 @@ -# SPAN Panel Documentation - -This repository contains documentation and planning materials for SPAN Panel integration and dependent packages - -## Overview - -These documents provide technical specifications, planning notes, and implementation guidance for: - -- SPAN Panel Home Assistant integration -- Synthetic sensor development -- Entity naming patterns and unique ID management -- Device association and compatibility - -## Contributing - -This documentation is actively maintained as part of the SPAN Panel integration development process. diff --git a/docs/dev/developer_attribute_entity_renaming_migration.md b/docs/dev/developer_attribute_entity_renaming_migration.md deleted file mode 100644 index b0b9dea1..00000000 --- a/docs/dev/developer_attribute_entity_renaming_migration.md +++ /dev/null @@ -1,318 +0,0 @@ -# Synthetic Sensor Entity Renaming and Migration - -## Background - -Only a single renaming is supported: migrating legacy installations (pre-1.0.4 without a device prefix) to device-prefixed entity IDs while preserving the -existing entity_id tails. Post-install flipping between naming patterns (Friendly Names ↔ Circuit Numbers) is not supported in the UI. The goal is to preserve -entity history and user customizations without deleting/recreating entities. - -## Goals - -- Preserve Home Assistant entity history for synthetic sensors when renaming occurs. -- Retain user customizations (such as friendly names, device class, etc.) if the user has not manually changed the entity ID. -- Update entity IDs in YAML definitions only if the entity ID was not customized by the user. -- Safely migrate legacy installations (no device prefix) to device-prefixed naming while preserving existing tails, handling edge cases where user modifications - exist. -- Exclude Panel sensors from migration except in the legacy device prefix migration case. Panel level circuits can be identifiedi through filter logic. -- Exclude User Customized Entity ID's from migration. The process for identifying a user customized entity_id is detailed below. - -The options flow triggers a one-time legacy-to-prefix migration and then saves the updated options. Once entity_ids are migrated, a common routine is used to -modify the SensorSet storage via the synthetic package. - -## Naming Policy (Post-Install Behavior) - -- After initial install, the entity naming style is fixed. The options UI does not offer flipping between Friendly Names and Circuit Numbers. -- Supported migration path is limited to legacy installations (pre-1.0.4) that lacked a device prefix. These can migrate once to device-prefixed naming while - preserving the existing entity_id tails (friendly/circuit text remains unchanged). -- This policy avoids repeated renames, preserves history, and removes the need for dual-calculation under different flag sets. - -## Panel Level vs Named Circuits Filtering - -Panel level circuits are those like the grid power or the mains current_power which apply to the entire panel. Since panel level circuits do not control an -individual numbered circuit, which is a combination of one or more tabs in the panel and they do not have a circuit ID in their sensor key/unique_id. A circuit -ID is a UUID generated by the panel and permanently assigned to a circuit so that adding circuits does not affect the other circuit ID's. Named individual -circuits returned by the panel API are circuits like those used to control individual circuit like a refrigerator. - -For a device with device identifier span_sp3-simulation-001 sensor keys take this form: - -- Panel circuits take the form without the circuit_id/UUID: `span_sp3-simulation-001_current_power` -- Named circuits will have a Sensor Key that contains this circuit_id/UUID: `span_sp3-simulation-001_12ce227695cd44338864b0ef2ec4168b_power` - -## Information Extraction from YAML Export - -From the `SensorSet.export()` YAML dump, extract: - -**1. Suffix from entity_id:** - -- Extract last part of entity_id (e.g., `sensor.span_panel_dining_room_wine_fridge_power` → `power`) -- Reverse map to API description key (`power` → `instantPowerW`) - -**2. Circuit information from variables:** - -- For solar sensors: parse variables (unmapped) to find leg1/leg2 circuit references -- For single-circuit sensors: find circuit backing entity to determine circuit_data -- For multi-circuit sensors: identify which circuits are being combined - -**3. Friendly name from name field:** - -- Use for solar sensors when calling `construct_solar_synthetic_entity_id()` - -### Suffix to API Description Key Mapping - -| Entity ID Suffix | API Description Key | Description | -| -------------------- | ------------------- | -------------------------------- | -| `power` | `instantPowerW` | Current power consumption | -| `energy_produced` | `producedEnergyWh` | Total energy produced | -| `energy_consumed` | `consumedEnergyWh` | Total energy consumed | -| `current_power` | `instantGridPowerW` | Grid power (panel level) | -| `feed_through_power` | `feedthroughPowerW` | Feed through power (panel level) | - -### Developer Helper References (for tooling only) - -The supported migration is prefix-only. The following helper-based generation approach is for development tooling and diagnostics; it is not used by the -supported user migration. - -Use the composite helper function `construct_multi_tab_entity_id_from_key()` which automatically: - -- Determines the appropriate sensor type (panel, multi-tab/240V, circuit-based) -- Extracts required parameters from sensor key and configuration -- Calls the correct specific helper function internally -- Handles all suffix mapping and parameter extraction - -**Usage (dev tooling only):** - -```python -new_entity_id = construct_multi_tab_entity_id_from_key( - coordinator=coordinator, - span_panel=span_panel, - platform="sensor", - sensor_key=sensor_key, - sensor_config=sensor_config, - unique_id=None, # Skip registry during migration -) -``` - -**Internal Parameter Extraction (dev tooling only):** - -The composite helper internally handles: - -- **Suffix extraction**: Uses `get_suffix_from_sensor_key()` to extract suffix from sensor key -- **Sensor type detection**: Identifies panel, multi-tab/240V, or circuit sensors automatically -- **Multi-tab/240V information**: Extracts tab numbers and friendly names from sensor configuration -- **Circuit data**: Determines circuit numbers and friendly names as needed -- **API key mapping**: Handles any required suffix-to-API-key conversions - -## Migration Steps - -### A) Legacy → Device Prefix (Prefix-Only) Migration [Supported] - -1. Export current YAML definitions for all synthetic sensors. -2. Determine the device prefix (slugified `config_entry.data["device_name"]` or `config_entry.title`). -3. For every sensor (panel, solar, circuit): - - If its `entity_id` is not already prefixed, rewrite it as: `sensor.{device_prefix}_{old_tail}` where `{old_tail}` is the existing suffix portion after the - platform (`sensor.`). Do not recompute the tail. - - Build old→new mapping as you rewrite. -4. Update cross-references across the YAML using the mapping (find/replace in strings). -5. Use the synthetic package's `modify()` to persist changes. -6. Save new options (enable device prefix), then reload the integration. - -Notes: - -- Panel sensors are included in this legacy migration (they adopt the device prefix). -- User-customized entity_ids are preserved in their tail; only the device prefix is added if missing. - -### B) Non-Legacy Pattern Flips (Friendly ↔ Circuit Numbers) [Not supported post-install] - -- Post-install flipping is not supported in the options UI. If required for testing, install with the desired pattern instead. -- If a non-legacy migration must be computed (e.g., in development tooling), use `construct_multi_tab_entity_id_from_key()` under explicit flag overrides to - compute the target entity_id, perform cross-reference updates, and persist via `modify()`. - -## Technical Implementation Details - -### Understanding Sensor Keys vs Entity IDs - -**Sensor Keys:** - -- Serve as unique identifiers in the synthetic package with format: - - Panel sensors: `{device_id}_{suffix}` (e.g., `span_sp3-simulation-001_current_power`) - - Circuit sensors: `{device_id}_{circuit_uuid}_{suffix}` (e.g., `span_sp3-simulation-001_12ce227695cd44338864b0ef2ec4168b_power`) - - Solar sensors: `{device_id}_solar_{suffix}` (e.g., `span_sp3-simulation-001_solar_power`) -- Used primarily for: - - Filtering logic to determine panel circuit vs normal named circuit (presence of UUID in sensor key) - - Sensor type identification (solar detection using `solar` in sensor_key.lower()) - - As unique identifiers for synthetic package operations -- Sensor keys themselves are mostly irrelevant for parameter extraction - -**Entity IDs:** - -- Found in the `entity_id` field of sensor configs with format determined by configuration flags: - - Friendly names: `sensor.span_panel_{friendly_name}_{suffix}` (e.g., `sensor.span_panel_dining_room_wine_fridge_power`) - - Circuit numbers: `sensor.span_panel_circuit_{number}_{suffix}` (e.g., `sensor.span_panel_circuit_14_power`) - - Solar sensors (friendly names): `sensor.span_panel_solar_inverter_{suffix}` (e.g., `sensor.span_panel_solar_inverter_power`) - - Solar sensors (circuit numbers): `sensor.span_panel_circuit_{leg1}_{leg2}_{suffix}` (e.g., `sensor.span_panel_circuit_30_32_power`) -- Show the current naming pattern, not what the old or new patterns should be -- Used for extracting the suffix needed by helper functions -- **Helper functions automatically ensure Home Assistant entity ID compliance**: - - The integration's helper functions use `slugify()` to convert circuit names and device names - - Ensures valid entity IDs with only lowercase letters, numbers, and underscores - -### Helper Function Requirements - -- Helper functions expect API description keys, not user-friendly suffixes -- All entity IDs must be generated by helper functions -- Helper functions take parameters like `instantPowerW`, `producedEnergyWh`, `instantGridPowerW` (not user-friendly suffixes like `power`, `energy_produced`, - `grid_power`) -- A reverse mapping from user-friendly suffix back to API description key is required (see table above) - -### Helper Function Reference Table - -| Function Type | Helper Function | Usage Context | Output/Format | -| -------------------------- | ------------------------------------------ | --------------------------------------- | ---------------------------------------------------------------------------------------------- | -| **Entity ID Construction** | | | | -| **Panel Sensors** | `construct_panel_entity_id()` | Panel-level sensors (grid, mains, etc.) | `sensor.span_panel_{description}` | -| **Multi-tab/240V Sensors** | `construct_240v_synthetic_entity_id()` | Multi-tab/240V synthetic sensors | `sensor.span_panel_circuit_{tab1}_{tab2}_{suffix}` | -| **Single Circuit** | `construct_single_circuit_entity_id()` | Individual circuit sensors | `sensor.span_panel_{friendly_name}_{suffix}` or `sensor.span_panel_circuit_{number}_{suffix}` | -| **Multi Circuit** | `construct_multi_circuit_entity_id()` | Combined circuit sensors | `sensor.span_panel_{friendly_name}_{suffix}` or `sensor.span_panel_circuit_{numbers}_{suffix}` | -| **Backing/Raw** | `construct_backing_entity_id()` | Underlying SPAN API entities | `sensor.span_panel_{circuit_name}_{suffix}` | -| **Unmapped** | `construct_unmapped_entity_id()` | Internal unmapped circuits (tabs) | `sensor.span_panel_unmapped_tab_{number}_{suffix}` | -| **General Purpose** | `construct_entity_id()` | Legacy/general entity construction | Variable format based on parameters | -| **From Sensor Key** | `construct_multi_tab_entity_id_from_key()` | Convert sensor key to entity ID | Variable format based on sensor type | -| **Suffix Utilities** | | | | -| **Reverse Mapping** | `get_api_description_key_from_suffix()` | Convert entity ID suffix to API key | `"power"` → `"instantPowerW"` | -| **Forward Mapping** | `get_user_friendly_suffix()` | Convert API key to user-friendly suffix | `"instantPowerW"` → `"power"` | -| **Panel Suffix** | `get_panel_entity_suffix()` | Convert panel API key to entity suffix | `"instantGridPowerW"` → `"current_power"` | -| **Extract Suffix** | `get_suffix_from_sensor_key()` | Extract suffix from sensor key | `"span_device_uuid_power"` → `"power"` | - -### Helper Function Selection Guide - -**For Migration Logic:** - -1. **Use the composite helper**: `construct_multi_tab_entity_id_from_key()` handles all sensor types automatically -2. **Sensor type detection**: The helper internally identifies panel, multi-tab/240V, and circuit sensors from sensor keys -3. **Parameter extraction**: All required parameters are extracted automatically from sensor key and configuration - -**Migration Implementation Pattern:** - -```python -# Generate new entity ID with specified flags -new_entity_id = construct_multi_tab_entity_id_from_key( - coordinator=coordinator, - span_panel=span_panel, - platform="sensor", - sensor_key=sensor_key, - sensor_config=sensor_config, - unique_id=None, # Skip registry during migration -) -``` - -**Internal Helper Selection:** - -The composite helper automatically delegates to: - -- **Panel sensors** → `construct_panel_entity_id()` -- **Multi-tab/240V sensors** → `construct_240v_synthetic_entity_id()` -- **Circuit sensors** → `construct_multi_circuit_entity_id()` (with extracted parameters) - -**Key Utility Functions Available:** - -- `get_suffix_from_sensor_key()` - Extract suffix from sensor key (used internally) -- `is_panel_level_sensor_key()` - Determine if sensor is panel-level (used for filtering) -- `is_solar_sensor_key()` - Identify solar sensors (used internally) -- `extract_solar_info_from_sensor_key()` - Extract solar parameters (used internally) - -### Entity ID Validation Requirements - -**Home Assistant Entity ID Rules:** - -- Must contain only lowercase letters, numbers, and underscores: `[a-z0-9_]+` -- Cannot contain dashes, slashes, spaces, or special characters -- The integration uses `homeassistant.util.slugify()` to sanitize circuit names and device names - -### Multi-tab/240V Sensor Examples - -**Multi-tab/240V sensors for tabs 30/32 configuration:** - -**Friendly Names Pattern (USE_DEVICE_PREFIX=True, USE_CIRCUIT_NUMBERS=False):** - -```yaml -span_sp3-simulation-001_240v_power: - name: 240V Current Power - entity_id: sensor.span_panel_240v_power - variables: - tab1_power: sensor.span_panel_unmapped_tab_30_power - tab2_power: sensor.span_panel_unmapped_tab_32_power -``` - -**Circuit Numbers Pattern (USE_DEVICE_PREFIX=True, USE_CIRCUIT_NUMBERS=True):** - -```yaml -span_sp3-simulation-001_240v_power: - name: 240V Current Power - entity_id: sensor.span_panel_circuit_30_32_power - variables: - tab1_power: sensor.span_panel_unmapped_tab_30_power - tab2_power: sensor.span_panel_unmapped_tab_32_power -``` - -Note: The sensor key remains the same (`span_sp3-simulation-001_240v_power`) regardless of naming pattern, only the `entity_id` changes. - -## Common YAML Update Process - -### 1. Update Entity IDs Only - -- Only modify the `entity_id` field in sensor configs -- Leave everything else unchanged: `name`, `formula`, `variables`, `metadata`, `attributes`, etc. -- This preserves all user customizations except entity_id changes - -### 2. Update Cross-References - -- Scan entire YAML document for references to changed entity_ids -- Find/replace all occurrences of old entity_id with new entity_id throughout the document -- This handles cases where users have custom attribute formulas referencing other synthetic sensors, variables in one sensor referencing another synthetic - sensor's entity_id, or any other cross-references in the configuration - -### 3. Apply Changes - -- Do not remove and recreate entities in Home Assistant -- Use the synthetic package's `modify()` routine to write the updated YAML definitions back to storage - -### 4. Reload Integration - -- Trigger a reload of the integration so that Home Assistant picks up the updated YAML and applies the new entity IDs - -## Summary Table - -| Case | Action Taken | -| ---------------------------------------------------------------- | ------------------------------------------------ | -| Legacy (no prefix) migrating all sensors to prefix/friendly only | Update to device prefix friendly | -| Panel-level sensor (see filter, non-legacy) | Excluded from migration | -| User-customized entity ID (differs from expected, non-legacy) | Excluded from migration | -| Non-legacy pattern flip (friendly ↔ circuit) post-install | Not supported in UI (install with desired style) | - -## Benefits - -- Preserves entity history by renaming rather than deleting/recreating entities. -- Respects user customizations by only updating entity IDs that have not been changed by the user. -- Maintains integrity of cross-references between synthetic sensors. -- Ensures smooth migration between naming patterns, including legacy to modern transitions. - -## Implementation Analysis - -### Current Implementation vs Documentation - -The migration implementation in `entity_id_naming_patterns.py` largely follows the documented approach with some key differences: - -1. **Solar Information Extraction**: Verify that `extract_solar_info_from_sensor_key()` correctly identifies leg1/leg2 from sensor configuration variables - -2. **Multi-Circuit Handling**: Ensure multi-circuit sensors are handled correctly with proper circuit number extraction from sensor configurations - -3. **Legacy Migration Coverage**: Confirm that legacy migration includes all sensor types (panel, solar, circuit) as intended - -### Validation Recommendations - -To ensure full migration functionality: - -1. **Test Solar Sensor Migration**: Verify leg1/leg2 extraction works correctly with real solar sensor configurations -2. **Validate Multi-Circuit Scenarios**: Test migration of sensors that combine multiple circuits -3. **Test Legacy Migration**: Verify comprehensive legacy migration handles all sensor types correctly -4. **Cross-Reference Validation**: Ensure entity ID references in formulas and variables are updated properly diff --git a/docs/dev/entity_id_change_architecture.md b/docs/dev/entity_id_change_architecture.md deleted file mode 100644 index dc76e19d..00000000 --- a/docs/dev/entity_id_change_architecture.md +++ /dev/null @@ -1,262 +0,0 @@ -# Entity ID Change Architecture - -## Overview - -The synthetic sensors system handles entity ID changes through a coordinated approach involving per-SensorSet entity indexes, bulk modification operations, and -registry event storm protection. The primary goal is to prevent event thrashing when we initiate entity ID changes that would otherwise trigger cascading update -cycles. - -## Core Components - -### Per-SensorSet Entity Index - -- **Purpose**: Tracks which entity IDs are actively referenced by sensors within a specific SensorSet for event filtering -- **Scope**: Per-SensorSet, completely rebuilt whenever that SensorSet is modified -- **Content**: Set of ALL entity IDs referenced in sensor configurations, formula variables, and global settings within the SensorSet -- **No Distinction**: Contains any entity_id reference - no distinction between synthetic and external entities -- **Exclusions**: Dynamic patterns (device_class, regex, label) are excluded since they're resolved at runtime -- **Location**: Owned by each SensorSet, not global -- **Update Strategy**: Always rebuild entire index from final SensorSet state - no incremental updates - -### Entity Registry Event Listener - -- **Purpose**: Monitors Home Assistant entity registry changes globally -- **Filtering**: Checks all SensorSet indexes to see which ones track the changed entity -- **Efficiency**: Only SensorSets that track the changed entity process the event -- **Storm Protection**: Ignores events for entities we're currently changing (self-change detection) - -### Bulk Modification System - -- **Purpose**: Handles coordinated changes to sensors, global settings, and entity ID renames within a SensorSet -- **Operations**: Add/remove/update sensors, change entity IDs, update global settings -- **Atomicity**: All changes within a SensorSet happen together -- **Index Strategy**: Pre-update EntityIndex for storm protection, then rebuild from final state - -## Key Principles - -### 1. Per-SensorSet Entity Tracking - -- Each SensorSet maintains its own EntityIndex of referenced entities -- Event listener checks all SensorSet indexes to find which ones care about a changed entity -- No global entity tracking needed - aggregation happens at query time if needed -- SensorSets are isolated from each other's entity changes - -### 2. Registry Event Storm Protection - -- **The Problem**: When we rename entities in HA registry, each rename triggers an event -- **The Solution**: Pre-update our SensorSet's EntityIndex before registry changes -- **Result**: Our own registry changes get ignored, preventing infinite loops and thrashing - -### 3. Two Types of Changes - -#### Type 1: Configuration Changes (No Storm Risk) - -- Modifying formulas, adding/removing sensors, changing global variables -- These don't trigger HA registry events -- EntityIndex rebuilt from final SensorSet state -- No event storm protection needed - -#### Type 2: Entity ID Registry Changes (Storm Risk) - -- HA registry renames `sensor.power_meter` → `sensor.new_power_meter` -- This triggers registry events that could cause cascading updates -- **Protection**: Pre-update EntityIndex, then ignore the events we caused - -### 4. Self-Change Detection Flow - -1. SensorSet decides to rename entity_id: sensor.A → sensor.B -2. Pre-update SensorSet's EntityIndex: remove sensor.A, add sensor.B -3. Update all references in SensorSet storage (formulas, variables, global settings) -4. Update HA entity registry: sensor.A → sensor.B (triggers event) -5. Event listener receives registry event for sensor.A -6. Event listener checks SensorSet indexes: sensor.A no longer tracked -7. Event ignored - no processing storm - -### 5. EntityIndex Simplicity - -- **No Synthetic vs External**: EntityIndex contains ANY entity_id reference found in the SensorSet -- **Event Filtering Purpose**: Primary purpose is determining which SensorSets care about registry events -- **Always Rebuild**: Never incremental updates - always rebuild entire index from final SensorSet state -- **Self-References Included**: ConfigManager may inject self-references (e.g., for attribute formulas) - these are tracked too - -## Architecture Details - -### SensorSet Structure - -```python -class SensorSet: - def __init__(self, storage_manager, sensor_set_id): - self.storage_manager = storage_manager - self.sensor_set_id = sensor_set_id - self._entity_index = EntityIndex(storage_manager.hass) # Per-SensorSet! - - def is_entity_tracked(self, entity_id: str) -> bool: - return self._entity_index.contains(entity_id) - - async def async_modify(self, modification): - # For entity ID changes: pre-update index for storm protection - if modification.entity_id_changes: - self._rebuild_entity_index_for_modification(modification) - - # Apply all modifications - # ... sensor changes, global settings changes ... - - # Always rebuild index from final state - self._rebuild_entity_index() - - def _rebuild_entity_index_for_modification(self, modification): - """Pre-update index to reflect final state for storm protection.""" - # Calculate what the final state will be after all modifications - # Update index to reflect that final state BEFORE making changes - # This ensures registry events we trigger get filtered out - - def _rebuild_entity_index(self): - """Always rebuild entire index from current SensorSet state.""" - self._entity_index.clear() - - # Add from all current sensors - for sensor in self.get_sensors(): - self._entity_index.add_sensor_entities(sensor) - - # Add from current global settings - if self.get_global_variables(): - self._entity_index.add_global_entities(self.get_global_variables()) -``` - -### Event Listener Flow - -```python -class EntityRegistryListener: - def handle_entity_change(self, old_entity_id, new_entity_id): - affected_sensor_sets = [] - - # Check which SensorSets track this entity - for sensor_set in self.storage_manager.get_all_sensor_sets(): - if sensor_set.is_entity_tracked(old_entity_id): - affected_sensor_sets.append(sensor_set) - - # Only process for SensorSets that care - for sensor_set in affected_sensor_sets: - sensor_set.handle_external_entity_change(old_entity_id, new_entity_id) -``` - -## Bulk Modification Flow - -### Phase 1: Validation - -- Check for conflicts between operations within the SensorSet -- Validate global settings don't conflict with local sensor variables -- Ensure referenced sensors exist for updates/removals - -### Phase 2: Entity ID Changes (Registry Storm Protection) - -- **Calculate final state** after all modifications -- **Pre-update SensorSet's EntityIndex** to reflect final state (remove old entity IDs, add new ones) -- Update sensor configurations with new entity IDs within the SensorSet -- Update global settings within the SensorSet with new entity IDs -- **Update Home Assistant entity registry** (triggers events that get ignored) - -### Phase 3: Configuration Changes - -- Remove sensors (if specified) -- Update existing sensors (if specified) -- Add new sensors (if specified) -- Apply global settings changes - -### Phase 4: Index Rebuild - -- **Rebuild SensorSet's EntityIndex from final state** (handles any remaining changes) -- Invalidate formula caches for affected entity IDs - -## Global vs SensorSet Scope - -### What's Per-SensorSet - -- **EntityIndex**: Each SensorSet tracks only its own entity references -- **Sensors**: Sensors belong to exactly one SensorSet -- **Global Settings**: Scoped to the SensorSet, not truly global -- **Bulk Operations**: Operate within a single SensorSet - -### What's Global - -- **Event Listener**: Receives all HA registry events, routes to relevant SensorSets -- **StorageManager**: Coordinates multiple SensorSets -- **Integration Interface**: SpanPanel integration works with its own SensorSet(s) - -## Performance Considerations - -### Event Processing Efficiency - -- O(1) lookup per SensorSet to check if entity is tracked -- Only affected SensorSets process events (not all SensorSets) -- Registry event storms prevented by self-change detection - -### Memory Usage - -- EntityIndex memory scales with entities per SensorSet (typically small) -- No global entity tracking overhead -- SensorSet isolation prevents cross-contamination - -### Bulk vs Individual Changes - -- Individual sensor changes rebuild only that SensorSet's index -- Bulk operations within a SensorSet are more efficient -- Cross-SensorSet operations are rare and handled separately - -## Integration Points - -### For SpanPanel Integration - -- SpanPanel creates/manages its own SensorSet(s) -- Uses `SensorSet.async_modify()` for bulk entity ID changes -- Registry event storms automatically prevented -- Isolated from other integrations' entity changes - -### For Event Handling - -- EntityRegistryListener routes events to relevant SensorSets only -- Each SensorSet handles its own entity reference updates -- Formula cache invalidation coordinated per SensorSet - -## Error Handling - -### Validation Failures - -- All validation occurs before any changes within the SensorSet -- Clear error messages indicate specific conflicts within the SensorSet -- SensorSet remains in consistent state after validation failures - -### Registry Update Failures - -- Entity registry updates may fail (e.g., entity doesn't exist in HA) -- SensorSet storage updates continue even if registry updates fail -- Cache invalidation always occurs to maintain SensorSet consistency - -## Future Considerations - -### Cross-SensorSet Operations - -- Rare but possible (e.g., moving sensors between SensorSets) -- Would require coordination between multiple SensorSet indexes -- Can be handled as separate bulk operations per affected SensorSet - -### Scaling - -- Per-SensorSet approach scales linearly with number of integrations -- Each integration's SensorSet(s) operate independently -- No global bottlenecks in entity tracking or event processing - -## Implementation Notes - -### EntityIndex Design Decisions - -- **Simplicity**: No distinction between synthetic and external entities - just track all entity_id references -- **Rebuild Strategy**: Always rebuild entire index rather than incremental updates for consistency -- **Event Filtering**: Primary purpose is determining which SensorSets need to process registry events -- **Self-References**: Includes any entity_id references, including self-references injected by ConfigManager - -### Dead Code Elimination - -- No `update_sensor_entities` method - always rebuild entire index -- No incremental entity tracking - full rebuild is simpler and more reliable -- No shared entity reference logic - per-SensorSet design eliminates this complexity diff --git a/docs/dev/simulation_based_testing_implementation.md b/docs/dev/simulation_based_testing_implementation.md deleted file mode 100644 index 075eba94..00000000 --- a/docs/dev/simulation_based_testing_implementation.md +++ /dev/null @@ -1,608 +0,0 @@ -# Simulation-Based Testing Implementation Plan - -## Overview - -This document outlines the implementation plan for transitioning SPAN Panel integration tests from mock-based testing to simulation-based testing using the -span-panel-api simulation mode. The primary goal is to generate realistic YAML test fixtures using the integration's actual code paths and helper functions, -ensuring proper entity ID and unique ID generation. - -## Background and Motivation - -### Current Issues - -- Hand-crafted YAML fixtures don't accurately represent real panel data -- Mock data generation creates unrealistic circuit configurations -- Entity naming migration tests fail due to fixture structure mismatches -- Test data doesn't reflect actual SPAN panel response formats - -### Solution Approach - -- Leverage span-panel-api simulation mode for realistic panel data -- Use integration's actual YAML generation code paths -- Ensure all entity IDs and unique IDs are created through integration helpers -- Generate fixtures programmatically rather than hand-crafting them - -### Key Principle: Integration Code Fidelity - -**CRITICAL**: All YAML fixtures must be generated using the integration's actual code paths, particularly: - -- Entity ID generation through `entity_id_naming_patterns.py` helpers -- Unique ID creation through integration helper functions -- YAML structure generation through `synthetic_*.py` modules -- Never bypass or mock the integration's ID generation logic - -## Phase 1: Simulation Infrastructure Setup - -### Task 1.1: Create SPAN Panel Simulation Factory - -**File**: `tests/factories/span_panel_simulation_factory.py` - -**Purpose**: Create a factory that uses span-panel-api simulation mode to generate realistic panel data, ensuring it exactly matches what the integration -expects. - -**Key Requirements**: - -```python -from span_panel_api import SpanPanelClient - -class SpanPanelSimulationFactory: - """Factory for creating simulation-based SPAN panel data.""" - - @classmethod - async def create_simulation_client(cls, **kwargs) -> SpanPanelClient: - """Create a simulation client with realistic data.""" - return SpanPanelClient( - host="localhost", # Ignored in simulation - simulation_mode=True, - **kwargs - ) - - @classmethod - async def get_realistic_panel_data(cls, variations=None): - """Get panel data using simulation mode.""" - async with cls.create_simulation_client() as client: - # Get all data types the integration needs - circuits = await client.get_circuits(variations=variations) - panel_state = await client.get_panel_state() - status = await client.get_status() - storage = await client.get_storage_soe() - - return { - 'circuits': circuits, - 'panel_state': panel_state, - 'status': status, - 'storage': storage - } -``` - -**Implementation Details**: - -- Support all simulation variations from span-panel-api -- Provide preset scenarios (normal operation, high load, circuit failures) -- Cache simulation data within test runs for performance -- Include circuit IDs that exactly match simulation fixtures - -### Task 1.2: Create Integration-Driven Data Provider - -**File**: `tests/providers/integration_data_provider.py` - -**Purpose**: Bridge between simulation data and integration code, ensuring all data flows through integration processing. - -**Key Requirements**: - -```python -from custom_components.span_panel.coordinator import SpanPanelCoordinator -from tests.factories.span_panel_simulation_factory import SpanPanelSimulationFactory - -class IntegrationDataProvider: - """Provides data using integration's actual processing logic.""" - - async def create_coordinator_with_simulation_data( - self, - hass, - config_entry, - simulation_variations=None - ) -> SpanPanelCoordinator: - """Create coordinator with simulation data processed through integration.""" - - # Get simulation data - sim_data = await SpanPanelSimulationFactory.get_realistic_panel_data( - variations=simulation_variations - ) - - # Create coordinator using integration's actual initialization - coordinator = SpanPanelCoordinator(hass, None, config_entry) - - # Set data as if it came from real API calls - coordinator.data = self._convert_sim_data_to_coordinator_format(sim_data) - - return coordinator - - def _convert_sim_data_to_coordinator_format(self, sim_data): - """Convert simulation data to coordinator's expected format.""" - # Use integration's actual data processing logic - # This ensures data structure matches exactly what coordinator expects - pass -``` - -### Task 1.3: Update Test Configuration Management - -**File**: `tests/conftest.py` (updates) - -**Purpose**: Integrate simulation infrastructure into test framework. - -**Implementation**: - -- Add simulation-based fixtures that replace mock factories -- Maintain backward compatibility with existing tests -- Provide easy access to different simulation scenarios - -## Phase 2: YAML Fixture Generation Using Integration Code - -### Task 2.1: Create Integration-Driven YAML Generator - -**File**: `tests/utils/integration_yaml_generator.py` - -**Purpose**: Generate YAML fixtures by running integration's actual synthetic sensor creation code with simulation data. - -**Critical Requirements**: - -```python -from custom_components.span_panel.synthetic_sensors import ( - async_setup_synthetic_sensors_with_entities -) -from custom_components.span_panel.synthetic_named_circuits import ( - generate_named_circuit_sensors -) -from custom_components.span_panel.synthetic_panel_circuits import ( - generate_panel_sensors -) -from custom_components.span_panel.synthetic_solar import ( - generate_solar_sensors -) - -class IntegrationYAMLGenerator: - """Generate YAML fixtures using integration's actual code paths.""" - - async def generate_yaml_for_naming_pattern( - self, - hass, - naming_flags: dict, - simulation_variations=None - ) -> str: - """Generate YAML using integration's actual sensor creation logic.""" - - # Create config entry with specific naming flags - config_entry = self._create_config_entry_with_flags(naming_flags) - - # Get coordinator with simulation data - coordinator = await self.data_provider.create_coordinator_with_simulation_data( - hass, config_entry, simulation_variations - ) - - # Run integration's actual synthetic sensor creation - # This is CRITICAL - we must use the integration's real code paths - sensor_manager = await async_setup_synthetic_sensors_with_entities( - hass, coordinator - ) - - # Export YAML using the synthetic sensors package - yaml_content = await sensor_manager.export_yaml() - - return yaml_content -``` - -**Key Features**: - -- Use integration's actual `generate_*_sensors()` functions -- Ensure all entity IDs come from `entity_id_naming_patterns.py` helpers -- Support all naming pattern combinations -- Generate solar and battery sensor configurations -- Preserve integration's exact YAML structure and formatting - -### Task 2.2: Create Naming Pattern Fixture Generator - -**File**: `tests/utils/naming_pattern_fixtures.py` - -**Purpose**: Generate complete sets of fixtures for all entity naming patterns. - -**Implementation**: - -```python -class NamingPatternFixtureGenerator: - """Generate fixtures for all entity naming patterns.""" - - NAMING_PATTERNS = [ - {"use_device_prefix": False, "use_circuit_numbers": False}, # Legacy - {"use_device_prefix": True, "use_circuit_numbers": False}, # Device + Friendly - {"use_device_prefix": True, "use_circuit_numbers": True}, # Device + Circuit Numbers - {"use_device_prefix": False, "use_circuit_numbers": True}, # Circuit Numbers Only - ] - - async def generate_all_pattern_fixtures(self, hass): - """Generate fixtures for all naming patterns.""" - fixtures = {} - - for pattern in self.NAMING_PATTERNS: - pattern_name = self._get_pattern_name(pattern) - - # Generate using integration code - yaml_content = await self.yaml_generator.generate_yaml_for_naming_pattern( - hass, pattern - ) - - fixtures[pattern_name] = yaml_content - - return fixtures -``` - -### Task 2.3: Create Migration Fixture Generator - -**File**: `tests/utils/migration_fixture_generator.py` - -**Purpose**: Generate before/after migration fixtures using integration's actual migration logic. - -**Critical Implementation**: - -```python -from custom_components.span_panel.entity_id_naming_patterns import ( - migrate_entity_ids_legacy_to_device_prefix, - migrate_entity_ids_friendly_to_circuit_numbers -) - -class MigrationFixtureGenerator: - """Generate migration test fixtures using integration's migration logic.""" - - async def generate_migration_fixtures( - self, - hass, - from_pattern: dict, - to_pattern: dict, - scenario_name: str - ) -> tuple[str, str]: - """Generate before/after migration fixtures.""" - - # Generate "before" state using integration code - before_yaml = await self.yaml_generator.generate_yaml_for_naming_pattern( - hass, from_pattern - ) - - # Set up integration state with "before" configuration - coordinator = await self._setup_coordinator_with_yaml(hass, before_yaml, from_pattern) - - # Run actual migration using integration's migration functions - success = await coordinator.migrate_synthetic_entities(from_pattern, to_pattern) - assert success, f"Migration should succeed for {scenario_name}" - - # Export "after" state - sensor_manager = coordinator.sensor_manager - after_yaml = await sensor_manager.export_yaml() - - return before_yaml, after_yaml -``` - -**Key Requirements**: - -- Use integration's actual migration functions -- Ensure entity ID transformations use integration helpers -- Preserve data integrity through migration -- Generate realistic migration scenarios - -## Phase 3: Enhanced Test Infrastructure - -### Task 3.1: Create Simulation Test Base Classes - -**File**: `tests/base/simulation_test_base.py` - -**Purpose**: Provide base classes for simulation-powered tests. - -```python -class SimulationTestBase: - """Base class for tests using simulation data.""" - - async def setup_simulation_coordinator( - self, - hass, - naming_flags=None, - simulation_variations=None - ): - """Set up coordinator with simulation data.""" - # Use integration's actual setup process - pass - -class MigrationTestBase(SimulationTestBase): - """Base class for entity naming migration tests.""" - - async def verify_migration_preserves_data(self, before_yaml, after_yaml): - """Verify migration preserves sensor data while changing entity IDs.""" - # Parse both YAML configurations - # Verify sensor count matches - # Verify references are properly updated - # Ensure no data loss - pass -``` - -### Task 3.2: Update Common Test Helpers - -**File**: `tests/common.py` (major updates) - -**Purpose**: Replace mock-based helpers with simulation-based equivalents. - -**Implementation**: - -- Deprecate `create_mock_span_panel_with_data()` in favor of simulation -- Update all helper functions to use realistic simulation data -- Maintain compatibility during transition period - -### Task 3.3: Create Integration Scenario Factory - -**File**: `tests/factories/integration_scenario_factory.py` - -**Purpose**: Create complete integration scenarios for testing. - -**Features**: - -- Different panel configurations (circuit counts, types) -- Various operational states (normal, high load, failures) -- Multiple naming pattern configurations -- Solar and battery integration scenarios - -## Phase 4: Migration Test Enhancement - -### Task 4.1: Rewrite Migration Tests - -**File**: `tests/test_config_flow_entity_naming.py` (complete rewrite) - -**Purpose**: Replace existing migration tests with simulation-based versions that use generated fixtures. - -**Key Changes**: - -```python -class TestEntityNamingMigrationWithSimulation: - """Test entity naming migration using simulation-generated fixtures.""" - - async def test_legacy_to_device_prefix_migration(self, hass): - """Test migration using integration-generated fixtures.""" - - # Generate fixtures using integration code - before_yaml, after_yaml = await self.migration_generator.generate_migration_fixtures( - hass, - from_pattern={"use_device_prefix": False, "use_circuit_numbers": False}, - to_pattern={"use_device_prefix": True, "use_circuit_numbers": False}, - scenario_name="legacy_to_device_prefix" - ) - - # Run migration test using generated fixtures - # All entity IDs will be created through integration helpers - # Test will use realistic circuit data from simulation - - # Verify migration results - self.verify_migration_results(before_yaml, after_yaml) -``` - -**Critical Requirements**: - -- Use only integration-generated fixtures -- Test against realistic simulation data -- Verify all entity ID transformations -- Test all migration paths comprehensively - -### Task 4.2: Create Migration Validation Framework - -**File**: `tests/utils/migration_validator.py` - -**Purpose**: Comprehensive validation of migration results. - -**Features**: - -- Entity ID transformation verification -- Data preservation checks -- Reference integrity validation -- Performance impact assessment - -### Task 4.3: Create Migration Test Coverage Matrix - -**File**: `tests/data/migration_test_matrix.py` - -**Purpose**: Define comprehensive migration test scenarios. - -**Coverage**: - -- All naming pattern combinations (4x4 = 16 migration paths) -- Different panel configurations -- Edge cases and error conditions -- Custom user modifications - -## Phase 5: Development Tools and Maintenance - -### Task 5.1: Create Fixture Generation Scripts - -**File**: `scripts/generate_test_fixtures.py` - -**Purpose**: Command-line tool for regenerating all test fixtures. - -**Features**: - -```bash -# Generate all fixtures -poetry run python scripts/generate_test_fixtures.py --all - -# Generate specific pattern fixtures -poetry run python scripts/generate_test_fixtures.py --pattern device_prefix_friendly - -# Generate migration fixtures -poetry run python scripts/generate_test_fixtures.py --migration legacy_to_device_prefix - -# Validate existing fixtures -poetry run python scripts/generate_test_fixtures.py --validate -``` - -### Task 5.2: Create Simulation Data Inspector - -**File**: `scripts/inspect_simulation_data.py` - -**Purpose**: Development tool for examining simulation data and comparing to fixtures. - -**Features**: - -- Display simulation panel structure -- Show all available circuit IDs and properties -- Compare simulation data to existing fixtures -- Export data for debugging - -### Task 5.3: Create Integration Test Validator - -**File**: `scripts/validate_integration_fixtures.py` - -**Purpose**: Validate that all fixtures were generated using integration code. - -**Features**: - -- Verify entity ID patterns match integration helpers -- Check YAML structure consistency -- Validate migration fixture correctness -- Generate validation reports - -## Implementation Guidelines - -### Code Quality Requirements - -1. **Integration Fidelity**: All fixtures must be generated using integration's actual code paths -2. **Helper Usage**: Entity IDs and unique IDs must come from integration helpers only -3. **Realistic Data**: Use simulation mode data that matches real SPAN panels -4. **Test Coverage**: Cover all naming patterns and migration scenarios -5. **Maintainability**: Generated fixtures should be reproducible and updateable - -### Testing Best Practices - -1. **Use Integration Code**: Never bypass integration logic for fixture generation -2. **Realistic Scenarios**: Base tests on actual SPAN panel configurations -3. **Comprehensive Coverage**: Test all naming pattern combinations -4. **Data Validation**: Verify migration preserves all sensor data -5. **Performance Awareness**: Ensure simulation doesn't slow down test suite - -### Simulation Usage Patterns - -Based on span-panel-api simulation documentation: - -```python -# Use realistic circuit variations -circuit_variations = { - "0dad2f16cd514812ae1807b0457d473e": { # Lights Dining Room - "power_variation": 0.05, # Low variation for lights - "relay_state": "CLOSED" - }, - "8a2ffda9dbd24bada9a01b880e910612": { # EV Charger - "power_variation": 0.8, # High variation for EV - "relay_state": "CLOSED" - } -} - -# Test specific scenarios -high_load_scenario = { - "global_power_variation": 0.3, - "variations": ev_charging_variations -} - -circuit_failure_scenario = { - "variations": { - "critical_circuit_id": {"relay_state": "OPEN"} - } -} -``` - -## Success Criteria - -### Phase 1 Success - -- Simulation factory provides realistic SPAN panel data -- Integration data provider bridges simulation to coordinator -- Test infrastructure supports simulation mode - -### Phase 2 Success - -- YAML fixtures generated using integration's actual code -- All entity IDs created through integration helpers -- Migration fixtures generated using real migration logic - -### Phase 3 Success - -- Test base classes simplify simulation-based testing -- Common helpers use realistic data -- Integration scenarios cover all use cases - -### Phase 4 Success - -- Migration tests pass with generated fixtures -- All naming pattern migrations tested -- Migration validation comprehensive - -### Phase 5 Success - -- Fixture generation automated and reproducible -- Development tools support debugging and validation -- Test maintenance streamlined - -## Timeline and Dependencies - -### Immediate Priority (Fix Current Tests) - -1. **Task 1.1** - Simulation Factory (1-2 days) -2. **Task 2.1** - Integration YAML Generator (2-3 days) -3. **Task 4.1** - Rewrite Migration Tests (1-2 days) - -### Short Term (Complete Infrastructure) - -1. **Task 1.2, 1.3** - Data Provider and Test Integration (1-2 days) -2. **Task 2.2, 2.3** - Pattern and Migration Generators (2-3 days) -3. **Task 3.1** - Test Base Classes (1 day) - -### Medium Term (Complete Testing Framework) - -1. **Task 3.2, 3.3** - Updated Helpers and Scenarios (2-3 days) -2. **Task 4.2, 4.3** - Migration Validation and Coverage (2-3 days) - -### Long Term (Development Tools) - -1. **Task 5.1, 5.2, 5.3** - Scripts and Tools (2-3 days) - -**Total Estimated Time**: 2-3 weeks for complete implementation - -## Conclusion - -This implementation plan leverages the span-panel-api simulation mode to create a robust, realistic testing framework that ensures: - -1. **Realistic Test Data**: All tests use data that matches actual SPAN panels -2. **Integration Fidelity**: All fixtures generated using integration's actual code paths -3. **Proper Entity ID Generation**: All entity IDs created through integration helpers -4. **Comprehensive Coverage**: All naming patterns and migrations tested -5. **Maintainable Tests**: Automated fixture generation and validation - -The key insight is using the integration's own code to generate test fixtures, ensuring perfect alignment between test data and production behavior while -leveraging realistic simulation data from actual SPAN panel responses. - -## ✅ Completed Tasks - -### Phase 1: Foundation Infrastructure ✅ - -- **Task 1.1: SPAN Panel Simulation Factory** ✅ - - Location: `/tests/factories/span_panel_simulation_factory.py` - - Features: Dynamic circuit ID retrieval, realistic SPAN panel data generation - - Status: Complete with dynamic circuit categorization - -- **Task 1.2: Integration Data Provider** ✅ - - Location: `/tests/providers/integration_data_provider.py` - - Features: Bridge between simulation and integration coordinator - - Status: Complete with full integration setup support - -### Phase 2: YAML Generation Infrastructure ✅ - -- **Task 2.1: Integration-Driven YAML Generator** ✅ - - Location: `/tests/utils/integration_yaml_generator.py` - - Features: Generate YAML using integration's actual sensor creation code - - Status: Complete with comprehensive naming pattern support - - Test Coverage: `/tests/test_integration_yaml_generator.py` - -## 🔄 Next Steps - -Continue with Task 2.2: Naming Pattern Fixture Generator to generate fixtures for all entity naming combinations and test variations. diff --git a/docs/dev/version_2_migration_strategy.md b/docs/dev/version_2_migration_strategy.md deleted file mode 100644 index 36442eae..00000000 --- a/docs/dev/version_2_migration_strategy.md +++ /dev/null @@ -1,245 +0,0 @@ -# Version 2 Migration Strategy - -## Overview - -This document outlines the migration strategy for upgrading existing SPAN Panel installations to version 2, which introduces synthetic sensors with YAML-based -configuration. The primary goal is to preserve existing entity IDs and user configurations while seamlessly transitioning to the new synthetic sensor -architecture. - -## Proposed Migration Strategy - -### Core Principles - -1. **Config Entry-Driven Migration**: Start with existing config entries as the only known data source along with the entity registry -2. **Per-Device YAML Generation**: Generate complete YAML configuration for each device/config entry independently -3. **Preserve All Identifiers**: Keep existing unique IDs as sensor keys and preserve all entity IDs as is -4. **Storage-Based Persistence**: Store generated YAML in ha-synthetic-sensors storage for each device -5. **Seamless Boot Transition**: After YAML generation, boot normally as if it were a pre-configured installation - -Existing installations have: - -- **One or more config entries** (each representing a SPAN Panel device) -- **Device, host, and token information** in each config entry -- **Existing entities** in the entity registry associated with each config entry -- **No knowledge of YAML/synthetic sensor process** -- **No knowledge of simulation modes** - -The migration must: - -- **Discover all config entries** for the SPAN Panel domain -- **For each config entry**: Generate complete YAML configuration for that device's sensor set -- **Store YAML configurations** in ha-synthetic-sensors storage using device identifiers -- **Preserve all existing identifiers** (unique IDs become sensor keys, entity IDs preserved) -- **Continue normal boot** as if the installation had always been configured with synthetic sensors - -After migration, the normal boot process takes over. The key is that after migration, each config entry will find that: - -1. **Storage manager exists and is populated** with sensor sets for each device -2. **YAML configurations are already stored** for each device identifier -3. **Boot process proceeds normally** as if it were a pre-configured installation -4. **Boot process provides sensor key to backing entity mapping** as if it were a pre-configured installation - -## Revised Migration Approach (unique_id normalization + registry lookups) - -### Summary - -- During migration, first normalize all existing sensor unique_ids in the registry for each device/config entry to the helper-format used by the integration. -- During migration, set a transient migration flag and trigger a reload. Do not generate YAML during migration. -- On the next normal boot (with the flag set), generate YAML for all config entries (one sensor set per entry/device). -- In migration mode, generation uses helpers to build sensor keys and looks up entity_ids in the registry by helper-format unique_id, using those entity_ids - instead of constructing new ones. This preserves existing entity_ids. - -### Rationale - -- Unique_ids are the primary join key for helper logic; normalizing them up front removes fragile translation layers. -- Entity_ids must remain unchanged; resolving them via the registry guarantees preservation. -- After normalization, generation and setup paths can operate exactly like a fresh install (with registry lookups succeeding). - -### Scope - -- Applies to `sensor` domain entities on the `span_panel` platform. -- `switch`, `select`, and `binary_sensor` unique_ids already conform and need no changes. - -### Phase 1: Unique_id normalization (entity registry) - -- For each SPAN config entry to be migrated: - - Read registry entries for that entry. - - Compute helper-format unique_id for each sensor: - - Circuit sensors: `span_{identifier}_{circuit_id}_{power|energy_produced|energy_consumed}` where - - `instantPowerW → power`, `producedEnergyWh → energy_produced`, `consumedEnergyWh → energy_consumed`. - - Panel sensors: `span_{identifier}_{current_power|feed_through_power|main_meter_produced_energy|main_meter_consumed_energy|...}` where - - `instantGridPowerW → current_power`, `feedthroughPowerW → feed_through_power`, - - `mainMeterEnergy.producedEnergyWh → main_meter_produced_energy`, - - `mainMeterEnergy.consumedEnergyWh → main_meter_consumed_energy`, - - `feedthroughEnergy.producedEnergyWh → feed_through_produced_energy`, - - `feedthroughEnergy.consumedEnergyWh → feed_through_consumed_energy`. - - If different, update the registry unique_id in place (idempotent; preserves entity_id). - -### Phase 2: Set per-entry migration flags and reload - -- After unique_id normalization completes, set a transient migration flag per config entry so entries can migrate independently and clear themselves when done. - - Example storage (either pattern is fine): - - Per-entry key: `hass.data[DOMAIN].setdefault(entry.entry_id, {})["migration_mode"] = True` - - Or central set: `hass.data[DOMAIN].setdefault("migration_entry_ids", set()).add(entry.entry_id)` -- Trigger a reload so the next boot runs the standard setup path. Each entry checks its own flag to decide if it should perform migration-mode YAML generation. - -### Phase 3: Normal boot YAML generation (migration mode) - -- During platform setup, detect the migration flag: - - For each config entry (device), generate a complete YAML sensor set using existing helpers to form sensor keys and backing metadata. - - Resolve entity_ids by registry lookup: `entity_registry.async_get_entity_id("sensor", "span_panel", )`. - - If found, use the existing entity_id. - - If missing, fall back to helper-constructed entity_id for new-only cases. - - Store/import YAML into ha-synthetic-sensors storage under `{device_identifier}_sensors`. - -### Phase 4: Solar handling (CRUD during first normal boot) - -- During the first normal boot in migration mode, perform solar CRUD inline, immediately after initial synthetic setup and before the existing solar setup block - runs. - - If entry options indicate solar is enabled, or prior solar entities exist for this entry, add/update solar sensors in the same SensorSet/YAML using - helper-format unique_ids and registry entity_id lookups. - - Then run the existing solar setup logic, which will pick up the newly added solar config. -- If neither condition applies, skip solar; user can enable later via options and a standard reload will add solar. - -- Entity_id preservation for solar: - - Solar generation must mirror the main generation’s behavior: when migration mode is active, resolve entity_ids from the registry using the helper-format - unique_id for each solar sensor key; only fall back to helper-built entity_ids if no registry entry exists. - - Ensure the migration flag remains set until solar CRUD completes so the solar path knows to perform registry lookups rather than generate new entity_ids. - -### Phase 5: Clear per-entry migration flags and steady state - -- After successful setup, including solar CRUD and solar setup, clear the per-entry migration flag so subsequent boots follow the standard path with no special - handling. - - For per-entry key: `hass.data[DOMAIN][entry.entry_id].pop("migration_mode", None)` (delete dict if empty). - - For central set: remove `entry.entry_id`; delete the set when empty. - -### Idempotency and safety - -- unique_id normalization is no-op on already-normalized installs. -- YAML generation/import is safe to re-run; entity_ids remain unchanged. -- User names/visibility preserved; only required backing entities may be enabled explicitly. - -### Validation - -- Post-boot (per entry): - - Registry shows helper-format unique_ids for SPAN sensors. - - Storage contains YAML for the device’s sensor set. - - Synthetic sensors register with existing entity_ids via helper-format unique_ids. - -## Implementation Tasks - -- unique_id normalization - - Implement per-entry normalization of `sensor` unique_ids to helper format; idempotent and conflict-safe. - - Leave `switch/select/binary_sensor` untouched. -- migration flag & reload - - Set per-entry migration flag on successful normalization; trigger reload. - - On setup, check and clear flag after successful generation. -- YAML generation (migration mode) - - For each entry on normal boot, generate full YAML using helpers; resolve entity_ids via registry lookup by helper-format unique_id with fallback to - helper-built entity_id only when missing. - - Store under `{device_identifier}_sensors`. -- Solar CRUD (first boot only) - - After initial synthetic setup returns a SensorSet and StorageManager, perform solar CRUD if enabled or previously present; when migration mode is active, - resolve entity_ids for solar by registry lookup using helper-format unique_ids; then call existing solar setup. - - Keep the per-entry migration flag set until solar CRUD and solar setup complete, then clear it. -- Multi-device - - Ensure the above runs independently per config entry; one sensor set per device identifier. -- Safety & telemetry - - Log normalization counts, skipped conflicts, and generation outcomes; avoid duplicate unique_ids; ensure idempotent reruns. - -## Unique ID Patterns by Version - -This section documents the unique ID patterns found in different SPAN Panel integration versions to aid in migration development and testing. - -### Version 1.0.4 Unique ID Patterns - -**Panel Power Sensors:** - -- `span_nj-2316-005k6_instantGridPowerW` → `sensor.span_panel_current_power` -- `span_nj-2316-005k6_feedthroughPowerW` → `sensor.span_panel_feed_through_power` - -**Panel Energy Sensors:** - -- `span_nj-2316-005k6_mainMeterEnergy.producedEnergyWh` → `sensor.span_panel_main_meter_produced_energy` -- `span_nj-2316-005k6_mainMeterEnergy.consumedEnergyWh` → `sensor.span_panel_main_meter_consumed_energy` -- `span_nj-2316-005k6_feedthroughEnergy.producedEnergyWh` → `sensor.span_panel_feed_through_produced_energy` -- `span_nj-2316-005k6_feedthroughEnergy.consumedEnergyWh` → `sensor.span_panel_feed_through_consumed_energy` - -**Circuit Power Sensors:** - -- `span_nj-2316-005k6_{circuit_id}_instantPowerW` → `sensor.span_panel_circuit_{number}_power` -- Example: `span_nj-2316-005k6_0dad2f16cd514812ae1807b0457d473e_instantPowerW` → `sensor.span_panel_circuit_2_power` - -**Circuit Energy Sensors:** - -- `span_nj-2316-005k6_{circuit_id}_producedEnergyWh` → `sensor.span_panel_circuit_{number}_energy_produced` -- `span_nj-2316-005k6_{circuit_id}_consumedEnergyWh` → `sensor.span_panel_circuit_{number}_energy_consumed` -- Example: `span_nj-2316-005k6_0dad2f16cd514812ae1807b0457d473e_producedEnergyWh` → `sensor.span_panel_circuit_2_energy_produced` - -**Status Sensors (unchanged):** - -- `span_nj-2316-005k6_doorState` → `binary_sensor.span_panel_door_state` -- `span_nj-2316-005k6_eth0Link` → `binary_sensor.span_panel_ethernet_link` -- `span_nj-2316-005k6_wlanLink` → `binary_sensor.span_panel_wi_fi_link` -- `span_nj-2316-005k6_wwanLink` → `binary_sensor.span_panel_cellular_link` - -### Version 1.0.10 Unique ID Patterns - -**Panel Power Sensors:** - -- `span_nj-2316-005k6_instantGridPowerW` → `sensor.span_panel_current_power` -- `span_nj-2316-005k6_feedthroughPowerW` → `sensor.span_panel_feed_through_power` - -**Panel Energy Sensors:** - -- `span_nj-2316-005k6_mainMeterEnergy.producedEnergyWh` → `sensor.span_panel_main_meter_produced_energy` -- `span_nj-2316-005k6_mainMeterEnergy.consumedEnergyWh` → `sensor.span_panel_main_meter_consumed_energy` -- `span_nj-2316-005k6_feedthroughEnergy.producedEnergyWh` → `sensor.span_panel_feed_through_produced_energy` -- `span_nj-2316-005k6_feedthroughEnergy.consumedEnergyWh` → `sensor.span_panel_feed_through_consumed_energy` - -**Circuit Power Sensors:** - -- `span_nj-2316-005k6_{circuit_id}_instantPowerW` → `sensor.span_panel_circuit_{number}_power` -- Example: `span_nj-2316-005k6_0dad2f16cd514812ae1807b0457d473e_instantPowerW` → `sensor.span_panel_circuit_2_power` - -**Circuit Energy Sensors:** - -- `span_nj-2316-005k6_{circuit_id}_producedEnergyWh` → `sensor.span_panel_circuit_{number}_energy_produced` -- `span_nj-2316-005k6_{circuit_id}_consumedEnergyWh` → `sensor.span_panel_circuit_{number}_energy_consumed` -- Example: `span_nj-2316-005k6_0dad2f16cd514812ae1807b0457d473e_producedEnergyWh` → `sensor.span_panel_circuit_2_energy_produced` - -**Status Sensors (unchanged):** - -- `span_nj-2316-005k6_doorState` → `binary_sensor.span_panel_door_state` -- `span_nj-2316-005k6_eth0Link` → `binary_sensor.span_panel_ethernet_link` -- `span_nj-2316-005k6_wlanLink` → `binary_sensor.span_panel_wi_fi_link` -- `span_nj-2316-005k6_wwanLink` → `binary_sensor.span_panel_cellular_link` - -### Key Differences Between Versions - -**No Differences Found:** The unique ID patterns between v1.0.4 and v1.0.10 are **identical**. Both versions use: - -- Old naming conventions (`instantGridPowerW`, `feedthroughPowerW`, `instantPowerW`, `producedEnergyWh`, `consumedEnergyWh`) -- Same circuit ID patterns with UUIDs -- Same panel sensor patterns with dot notation for energy sensors -- Same status sensor patterns - -**Migration Implications:** - -- The migration logic can be the same for both v1.0.4 and v1.0.10 -- Both versions need normalization to the new helper format: - - `instantGridPowerW` → `current_power` - - `feedthroughPowerW` → `feed_through_power` - - `instantPowerW` → `power` - - `producedEnergyWh` → `energy_produced` - - `consumedEnergyWh` → `energy_consumed` - - `mainMeterEnergy.producedEnergyWh` → `main_meter_produced_energy` - - `mainMeterEnergy.consumedEnergyWh` → `main_meter_consumed_energy` - - `feedthroughEnergy.producedEnergyWh` → `feed_through_produced_energy` - - `feedthroughEnergy.consumedEnergyWh` → `feed_through_consumed_energy` - -**Sensor Counts:** - -- Both versions have 24 power sensors (2 panel + 22 circuit) -- Both versions have 48 energy sensors (4 panel + 44 circuit) -- Total: 72 sensors per version diff --git a/frontend.md b/frontend.md new file mode 100644 index 00000000..25788d71 --- /dev/null +++ b/frontend.md @@ -0,0 +1,145 @@ +# Frontend Dashboard + +The SPAN Panel integration includes a built-in frontend dashboard accessible from the Home Assistant sidebar. The dashboard provides real-time visualization of +your panel's electrical activity, circuit-level monitoring configuration, and integration settings — all without requiring a separate Lovelace card. + +## Enabling the Dashboard + +The dashboard is enabled via the integration's configuration options: + +1. Go to `Settings` > `Devices & Services` > `SPAN Panel` > `Configure` > `General Options` +2. Check **Show Dashboard in Sidebar** to add a sidebar entry +3. Optionally check **Admin Users Only** to restrict access to administrator accounts + +![Configuration Options](images/config_flow.png) + +## Panel View + +The Panel tab displays your SPAN panel layout with real-time circuit-level power graphs. Each circuit card shows: + +- **Breaker rating** and **current power draw** in the card header +- **Circuit priority icons** indicating always-on, never-shed, SoC threshold, or off-grid shed behavior +- **Historical power or current graph** with configurable time horizon globally or per circuit +- **Switch control** for user-controllable circuits (via the safety slider in the card header) + +The top banner summarizes panel-level metrics: site power, grid state, upstream/downstream current, and solar production. A firmware version badge and legend +for circuit priority icons are displayed alongside. + +Use the **Enable Switches** toggle in the banner to globally enable or disable circuit switch controls. + +![Panel Dashboard](images/frontend.png) + +## List Views — By Activity and By Area + +Beyond the panel grid, each panel (and the cross-panel Favorites view) exposes two list-oriented tabs: + +- **By Activity** — circuits sorted by live power (or current, when the unit toggle is on Amps), highest first. A search box filters rows by name. +- **By Area** — circuits grouped by their Home Assistant area (entity-level assignment first, then device-level fallback). Unassigned circuits land in a + dedicated group at the bottom. + +Each row shows breaker rating, live utilization %, circuit name, shedding-priority icon, an ON/OFF toggle (tappable once the **Enable Switches** slider is +armed), live power / current value, a gear icon that opens the side panel, and a chevron to expand the row's chart. + +### List view columns + +By default the list views stack circuits one per row. You can switch to a **2** or **3** column grid from the **Graph Settings** side panel (gear icon at the +top of the panel header) → **List View Columns**. + +- Rows flow left-to-right, top-to-bottom, so the existing sort order (value-desc for By Activity, alphabetical-within-area for By Area) becomes top-left through + bottom-right. +- Expanding a row shows its chart directly below, constrained to the same column as the row. +- Viewports narrower than 600 px force single-column regardless of the setting. +- The choice is stored per browser in `localStorage`, so you can keep a phone in single-column and a desktop in three columns. + +## Monitoring View + +The Monitoring tab provides current-based alerting for individual circuits. It detects sustained high utilization and transient spikes relative to each +circuit's breaker rating, then delivers notifications through configurable channels. + +### Global Settings + +- **Continuous (%)** — Sustained utilization threshold as a percentage of breaker rating. When a circuit draws more than this percentage continuously for the + configured window duration, an alert is triggered. For example, a 15A breaker at 80% fires when the circuit sustains above 12A. +- **Spike (%)** — Instantaneous spike threshold as a percentage of breaker rating. A single reading above this level triggers an immediate alert without waiting + for the window duration. For example, a 15A breaker at 100% fires the moment current exceeds 15A. +- **Window (min)** — How long a circuit must continuously exceed the continuous threshold before an alert fires. Short windows (e.g., 1–2 min) catch brief + overloads; longer windows (e.g., 5–15 min) filter out transient loads like motor startup surges. +- **Cooldown (min)** — Minimum quiet period after an alert before the same circuit can trigger another alert. Prevents notification floods when a circuit + oscillates around the threshold. For example, a 15-minute cooldown means you receive at most one alert per circuit every 15 minutes. + +### Notification Settings + +- **All Targets** — Select all notification targets at once +- **Notify Targets** — Individual notification targets including mobile devices, persistent notification, and the HA event bus +- **Priority** — Notification priority level (controls iOS interruption level and Android notification channel) +- **Title/Message Templates** — Customizable templates with the following variables: + + | Variable | Description | Example | + | -------------------- | ------------------------------- | --------------------------- | + | `{name}` | Circuit or mains friendly name | Kitchen Oven | + | `{entity_id}` | HA entity ID | sensor.kitchen_oven_current | + | `{alert_type}` | Alert category | spike, continuous_overload | + | `{current_a}` | Current draw in amps | 18.3 | + | `{breaker_rating_a}` | Breaker rating in amps | 20 | + | `{threshold_pct}` | Configured threshold percentage | 80 | + | `{utilization_pct}` | Actual utilization percentage | 91.5 | + | `{window_m}` | Window duration in minutes | 5 | + | `{local_time}` | Local time of the alert | 2:15 PM | + +### Monitored Points + +Each circuit can be individually enabled or disabled for monitoring, with per-circuit overrides for continuous threshold, spike threshold, window, and cooldown +values. + +![Monitoring Configuration](images/monitoring.png) + +## Favorites View + +The dashboard supports a cross-panel **Favorites** view that lets you curate a single workspace from circuits and sub-devices (BESS, EVSE) belonging to any of +your configured SPAN panels. This is useful when you want a single place to keep an eye on a small set of important loads — say, the EV charger on Panel A and +the heat pump on Panel B — without switching panels in the dropdown. + +### Marking favorites + +Favorites are marked from the **side panels** that open via the gear icons in the dashboard. The standalone span-card (used in Lovelace dashboards) does not +expose hearts because it has no Favorites view. + +There are three places to toggle a favorite: + +- **Panel-level "Graph Settings" side panel** — opened from the gear icon at the top of the panel header. The per-circuit and per-sub-device lists each render a + heart icon next to the time-horizon dropdown. Click the heart to favorite or un-favorite that target without leaving the list. +- **Per-circuit side panel** — opened from a circuit's gear icon in the breaker grid (or in the By Activity / By Area rows). A "Favorite" section with a switch + sits between the relay control and the shedding priority. +- **Per-sub-device side panel** — opened from the gear icon on a BESS or EVSE tile. Same Favorite section as the per-circuit panel. + +A circuit or sub-device can be favorited on any panel. The integration stores the favorites under the configured SPAN integration's storage, so they sync across +browsers and devices. + +### The Favorites entry + +When you have at least one favorite configured, a synthetic **Favorites** entry appears at the top of the panel dropdown. Switching to it loads the aggregated +view. Removing the last favorite while the Favorites view is active automatically switches the dropdown and view back to the first real panel. + +### Tabs in the Favorites view + +The Favorites view exposes **By Activity**, **By Area**, and **Monitoring** tabs. **By Panel** is not available because the physical breaker grid is inherently +single-panel. + +- **By Activity / By Area** — Show the favorited circuits sorted by power (or grouped by area). Sub-device tiles render above the circuit list. When more than + one panel contributes favorites, circuit and sub-device names are prefixed with their panel name so you can tell them apart. +- **Monitoring** — Stacks one Monitoring block per contributing panel, since threshold and notification settings are per-panel. + +The Favorites view is **stateful**: the active tab, expanded rows, and search query are remembered in the browser and restored when you come back to it. + +### Editing from the Favorites view + +Opening a circuit's or sub-device's gear from the Favorites view targets the originating panel for any side-panel edits (graph horizon, monitoring thresholds, +relay state, etc.). Settings always go to the right panel even though the targets are aggregated in the Favorites view. + +### Services + +The favorites map is also exposed as services for automations and scripts: + +- `span_panel.get_favorites` — Returns the current map. +- `span_panel.add_favorite` / `span_panel.remove_favorite` — Take a single `entity_id` (any sensor on the circuit or sub-device — current, power, SoC, etc.). + The integration resolves the entity to its panel and target, so you never need to know internal circuit UUIDs or HA device IDs. diff --git a/images/bess-topology-integrated.drawio b/images/bess-topology-integrated.drawio new file mode 100644 index 00000000..f5eadb68 --- /dev/null +++ b/images/bess-topology-integrated.drawio @@ -0,0 +1,117 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/images/bess-topology-integrated.svg b/images/bess-topology-integrated.svg new file mode 100644 index 00000000..744a943f --- /dev/null +++ b/images/bess-topology-integrated.svg @@ -0,0 +1,3 @@ + + +
UTILITY SIDE — Invisible to panel when MID is open
HOME SIDE
All panel sensors measure here
UTILITY GRID
Utility Meter /
Service Entrance
Generator
+ ATS/MTS
Utility-Side Sensor
e.g. Emporia Vue, ATS
contact closure, clamp
Opens during outage, isolating
home from grid. Built into or
alongside the BESS.
MID
Microgrid Interconnect Device
Upstream Lugs
lugs current / power
PV Inverter
(Solar)
Downstream
Lugs
Sub-Panel or
Second Panel
SPAN Panel
Circuit
Circuit
Circuit
...
BESS / Hybrid Inverter
Battery Pack(s)
commscomms
Text is not SVG - cannot display
diff --git a/images/bess-topology-non-integrated.drawio b/images/bess-topology-non-integrated.drawio new file mode 100644 index 00000000..8e0e0c36 --- /dev/null +++ b/images/bess-topology-non-integrated.drawio @@ -0,0 +1,92 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/images/bess-topology-non-integrated.svg b/images/bess-topology-non-integrated.svg new file mode 100644 index 00000000..ddb579fa --- /dev/null +++ b/images/bess-topology-non-integrated.svg @@ -0,0 +1,3 @@ + + +
HOME SIDE
All panel sensors measure here
UTILITY GRID
Utility Meter /
Service Entrance
MID
Microgrid Interconnect Device
BESS controls MID but does
NOT report state to panel
Non-Integrated
BESS / Inverter
Battery Pack(s)
✖ NO COMMS
Panel has no awareness of BESS
Upstream Lugs
sees battery power as "grid"
PV Inverter
(Solar)
comms
Downstream
Lugs
SPAN Panel
Circuit
Circuit
Circuit
...
Sub-Panel or
Second Panel
Text is not SVG - cannot display
diff --git a/images/config_flow.png b/images/config_flow.png new file mode 100644 index 00000000..0c5310af Binary files /dev/null and b/images/config_flow.png differ diff --git a/images/frontend.png b/images/frontend.png new file mode 100644 index 00000000..76ec9703 Binary files /dev/null and b/images/frontend.png differ diff --git a/images/monitoring.png b/images/monitoring.png new file mode 100644 index 00000000..61f27276 Binary files /dev/null and b/images/monitoring.png differ diff --git a/poetry.lock b/poetry.lock deleted file mode 100644 index 928b2a93..00000000 --- a/poetry.lock +++ /dev/null @@ -1,5279 +0,0 @@ -# This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand. - -[[package]] -name = "acme" -version = "5.0.0" -description = "ACME protocol implementation in Python" -optional = false -python-versions = ">=3.10" -groups = ["main", "dev"] -files = [ - {file = "acme-5.0.0-py3-none-any.whl", hash = "sha256:b7923d1105f3df7f0c94bab90fc3006e9a0de0597baf82dedfc8ed24889f56dc"}, - {file = "acme-5.0.0.tar.gz", hash = "sha256:b701b23e66d3c58352896a72caa13523d9f72b183a0ba1cde93e6713a450a391"}, -] - -[package.dependencies] -cryptography = ">=43.0.0" -josepy = ">=2.0.0" -PyOpenSSL = ">=25.0.0" -pyrfc3339 = "*" -requests = ">=2.25.1" - -[package.extras] -docs = ["Sphinx (>=1.0)", "sphinx_rtd_theme"] -test = ["pytest", "pytest-xdist", "typing-extensions"] - -[[package]] -name = "aiodns" -version = "3.5.0" -description = "Simple DNS resolver for asyncio" -optional = false -python-versions = ">=3.9" -groups = ["main", "dev"] -files = [ - {file = "aiodns-3.5.0-py3-none-any.whl", hash = "sha256:6d0404f7d5215849233f6ee44854f2bb2481adf71b336b2279016ea5990ca5c5"}, - {file = "aiodns-3.5.0.tar.gz", hash = "sha256:11264edbab51896ecf546c18eb0dd56dff0428c6aa6d2cd87e643e07300eb310"}, -] - -[package.dependencies] -pycares = ">=4.9.0" - -[[package]] -name = "aiohappyeyeballs" -version = "2.6.1" -description = "Happy Eyeballs for asyncio" -optional = false -python-versions = ">=3.9" -groups = ["main", "dev"] -files = [ - {file = "aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8"}, - {file = "aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558"}, -] - -[[package]] -name = "aiohasupervisor" -version = "0.3.2" -description = "Asynchronous python client for Home Assistant Supervisor." -optional = false -python-versions = ">=3.12.0" -groups = ["main", "dev"] -files = [ - {file = "aiohasupervisor-0.3.2-py3-none-any.whl", hash = "sha256:93599f698e7daf238e8040053d455104bac149f9e59fda757c6378708fbd1629"}, - {file = "aiohasupervisor-0.3.2.tar.gz", hash = "sha256:eb291b600cc5cf05072e4bd16df5655cfaea3b9f3b964844896b38230e529a7c"}, -] - -[package.dependencies] -aiohttp = ">=3.3.0,<4.0.0" -mashumaro = ">=3.11,<4.0" -orjson = ">=3.6.1,<4.0.0" - -[package.extras] -dev = ["aiohttp (==3.12.15)", "aioresponses (==0.7.8)", "codespell (==2.4.1)", "coverage (==7.10.5)", "mashumaro (==3.16)", "mypy (==1.17.1)", "orjson (==3.11.2)", "pre-commit (==4.3.0)", "pytest (==8.4.1)", "pytest-aiohttp (==1.1.0)", "pytest-cov (==6.2.1)", "pytest-timeout (==2.4.0)", "ruff (==0.12.10)", "yamllint (==1.37.1)"] - -[[package]] -name = "aiohttp" -version = "3.12.15" -description = "Async http client/server framework (asyncio)" -optional = false -python-versions = ">=3.9" -groups = ["main", "dev"] -files = [ - {file = "aiohttp-3.12.15-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b6fc902bff74d9b1879ad55f5404153e2b33a82e72a95c89cec5eb6cc9e92fbc"}, - {file = "aiohttp-3.12.15-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:098e92835b8119b54c693f2f88a1dec690e20798ca5f5fe5f0520245253ee0af"}, - {file = "aiohttp-3.12.15-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:40b3fee496a47c3b4a39a731954c06f0bd9bd3e8258c059a4beb76ac23f8e421"}, - {file = "aiohttp-3.12.15-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2ce13fcfb0bb2f259fb42106cdc63fa5515fb85b7e87177267d89a771a660b79"}, - {file = "aiohttp-3.12.15-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3beb14f053222b391bf9cf92ae82e0171067cc9c8f52453a0f1ec7c37df12a77"}, - {file = "aiohttp-3.12.15-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4c39e87afe48aa3e814cac5f535bc6199180a53e38d3f51c5e2530f5aa4ec58c"}, - {file = "aiohttp-3.12.15-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d5f1b4ce5bc528a6ee38dbf5f39bbf11dd127048726323b72b8e85769319ffc4"}, - {file = "aiohttp-3.12.15-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1004e67962efabbaf3f03b11b4c43b834081c9e3f9b32b16a7d97d4708a9abe6"}, - {file = "aiohttp-3.12.15-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8faa08fcc2e411f7ab91d1541d9d597d3a90e9004180edb2072238c085eac8c2"}, - {file = "aiohttp-3.12.15-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:fe086edf38b2222328cdf89af0dde2439ee173b8ad7cb659b4e4c6f385b2be3d"}, - {file = "aiohttp-3.12.15-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:79b26fe467219add81d5e47b4a4ba0f2394e8b7c7c3198ed36609f9ba161aecb"}, - {file = "aiohttp-3.12.15-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b761bac1192ef24e16706d761aefcb581438b34b13a2f069a6d343ec8fb693a5"}, - {file = "aiohttp-3.12.15-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:e153e8adacfe2af562861b72f8bc47f8a5c08e010ac94eebbe33dc21d677cd5b"}, - {file = "aiohttp-3.12.15-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:fc49c4de44977aa8601a00edbf157e9a421f227aa7eb477d9e3df48343311065"}, - {file = "aiohttp-3.12.15-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2776c7ec89c54a47029940177e75c8c07c29c66f73464784971d6a81904ce9d1"}, - {file = "aiohttp-3.12.15-cp310-cp310-win32.whl", hash = "sha256:2c7d81a277fa78b2203ab626ced1487420e8c11a8e373707ab72d189fcdad20a"}, - {file = "aiohttp-3.12.15-cp310-cp310-win_amd64.whl", hash = "sha256:83603f881e11f0f710f8e2327817c82e79431ec976448839f3cd05d7afe8f830"}, - {file = "aiohttp-3.12.15-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d3ce17ce0220383a0f9ea07175eeaa6aa13ae5a41f30bc61d84df17f0e9b1117"}, - {file = "aiohttp-3.12.15-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:010cc9bbd06db80fe234d9003f67e97a10fe003bfbedb40da7d71c1008eda0fe"}, - {file = "aiohttp-3.12.15-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3f9d7c55b41ed687b9d7165b17672340187f87a773c98236c987f08c858145a9"}, - {file = "aiohttp-3.12.15-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bc4fbc61bb3548d3b482f9ac7ddd0f18c67e4225aaa4e8552b9f1ac7e6bda9e5"}, - {file = "aiohttp-3.12.15-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7fbc8a7c410bb3ad5d595bb7118147dfbb6449d862cc1125cf8867cb337e8728"}, - {file = "aiohttp-3.12.15-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:74dad41b3458dbb0511e760fb355bb0b6689e0630de8a22b1b62a98777136e16"}, - {file = "aiohttp-3.12.15-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b6f0af863cf17e6222b1735a756d664159e58855da99cfe965134a3ff63b0b0"}, - {file = "aiohttp-3.12.15-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b5b7fe4972d48a4da367043b8e023fb70a04d1490aa7d68800e465d1b97e493b"}, - {file = "aiohttp-3.12.15-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6443cca89553b7a5485331bc9bedb2342b08d073fa10b8c7d1c60579c4a7b9bd"}, - {file = "aiohttp-3.12.15-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6c5f40ec615e5264f44b4282ee27628cea221fcad52f27405b80abb346d9f3f8"}, - {file = "aiohttp-3.12.15-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:2abbb216a1d3a2fe86dbd2edce20cdc5e9ad0be6378455b05ec7f77361b3ab50"}, - {file = "aiohttp-3.12.15-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:db71ce547012a5420a39c1b744d485cfb823564d01d5d20805977f5ea1345676"}, - {file = "aiohttp-3.12.15-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:ced339d7c9b5030abad5854aa5413a77565e5b6e6248ff927d3e174baf3badf7"}, - {file = "aiohttp-3.12.15-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:7c7dd29c7b5bda137464dc9bfc738d7ceea46ff70309859ffde8c022e9b08ba7"}, - {file = "aiohttp-3.12.15-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:421da6fd326460517873274875c6c5a18ff225b40da2616083c5a34a7570b685"}, - {file = "aiohttp-3.12.15-cp311-cp311-win32.whl", hash = "sha256:4420cf9d179ec8dfe4be10e7d0fe47d6d606485512ea2265b0d8c5113372771b"}, - {file = "aiohttp-3.12.15-cp311-cp311-win_amd64.whl", hash = "sha256:edd533a07da85baa4b423ee8839e3e91681c7bfa19b04260a469ee94b778bf6d"}, - {file = "aiohttp-3.12.15-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:802d3868f5776e28f7bf69d349c26fc0efadb81676d0afa88ed00d98a26340b7"}, - {file = "aiohttp-3.12.15-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f2800614cd560287be05e33a679638e586a2d7401f4ddf99e304d98878c29444"}, - {file = "aiohttp-3.12.15-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8466151554b593909d30a0a125d638b4e5f3836e5aecde85b66b80ded1cb5b0d"}, - {file = "aiohttp-3.12.15-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2e5a495cb1be69dae4b08f35a6c4579c539e9b5706f606632102c0f855bcba7c"}, - {file = "aiohttp-3.12.15-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6404dfc8cdde35c69aaa489bb3542fb86ef215fc70277c892be8af540e5e21c0"}, - {file = "aiohttp-3.12.15-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3ead1c00f8521a5c9070fcb88f02967b1d8a0544e6d85c253f6968b785e1a2ab"}, - {file = "aiohttp-3.12.15-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6990ef617f14450bc6b34941dba4f12d5613cbf4e33805932f853fbd1cf18bfb"}, - {file = "aiohttp-3.12.15-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd736ed420f4db2b8148b52b46b88ed038d0354255f9a73196b7bbce3ea97545"}, - {file = "aiohttp-3.12.15-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c5092ce14361a73086b90c6efb3948ffa5be2f5b6fbcf52e8d8c8b8848bb97c"}, - {file = "aiohttp-3.12.15-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:aaa2234bb60c4dbf82893e934d8ee8dea30446f0647e024074237a56a08c01bd"}, - {file = "aiohttp-3.12.15-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6d86a2fbdd14192e2f234a92d3b494dd4457e683ba07e5905a0b3ee25389ac9f"}, - {file = "aiohttp-3.12.15-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a041e7e2612041a6ddf1c6a33b883be6a421247c7afd47e885969ee4cc58bd8d"}, - {file = "aiohttp-3.12.15-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5015082477abeafad7203757ae44299a610e89ee82a1503e3d4184e6bafdd519"}, - {file = "aiohttp-3.12.15-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:56822ff5ddfd1b745534e658faba944012346184fbfe732e0d6134b744516eea"}, - {file = "aiohttp-3.12.15-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b2acbbfff69019d9014508c4ba0401822e8bae5a5fdc3b6814285b71231b60f3"}, - {file = "aiohttp-3.12.15-cp312-cp312-win32.whl", hash = "sha256:d849b0901b50f2185874b9a232f38e26b9b3d4810095a7572eacea939132d4e1"}, - {file = "aiohttp-3.12.15-cp312-cp312-win_amd64.whl", hash = "sha256:b390ef5f62bb508a9d67cb3bba9b8356e23b3996da7062f1a57ce1a79d2b3d34"}, - {file = "aiohttp-3.12.15-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9f922ffd05034d439dde1c77a20461cf4a1b0831e6caa26151fe7aa8aaebc315"}, - {file = "aiohttp-3.12.15-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2ee8a8ac39ce45f3e55663891d4b1d15598c157b4d494a4613e704c8b43112cd"}, - {file = "aiohttp-3.12.15-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3eae49032c29d356b94eee45a3f39fdf4b0814b397638c2f718e96cfadf4c4e4"}, - {file = "aiohttp-3.12.15-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b97752ff12cc12f46a9b20327104448042fce5c33a624f88c18f66f9368091c7"}, - {file = "aiohttp-3.12.15-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:894261472691d6fe76ebb7fcf2e5870a2ac284c7406ddc95823c8598a1390f0d"}, - {file = "aiohttp-3.12.15-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5fa5d9eb82ce98959fc1031c28198b431b4d9396894f385cb63f1e2f3f20ca6b"}, - {file = "aiohttp-3.12.15-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f0fa751efb11a541f57db59c1dd821bec09031e01452b2b6217319b3a1f34f3d"}, - {file = "aiohttp-3.12.15-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5346b93e62ab51ee2a9d68e8f73c7cf96ffb73568a23e683f931e52450e4148d"}, - {file = "aiohttp-3.12.15-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:049ec0360f939cd164ecbfd2873eaa432613d5e77d6b04535e3d1fbae5a9e645"}, - {file = "aiohttp-3.12.15-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b52dcf013b57464b6d1e51b627adfd69a8053e84b7103a7cd49c030f9ca44461"}, - {file = "aiohttp-3.12.15-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:9b2af240143dd2765e0fb661fd0361a1b469cab235039ea57663cda087250ea9"}, - {file = "aiohttp-3.12.15-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ac77f709a2cde2cc71257ab2d8c74dd157c67a0558a0d2799d5d571b4c63d44d"}, - {file = "aiohttp-3.12.15-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:47f6b962246f0a774fbd3b6b7be25d59b06fdb2f164cf2513097998fc6a29693"}, - {file = "aiohttp-3.12.15-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:760fb7db442f284996e39cf9915a94492e1896baac44f06ae551974907922b64"}, - {file = "aiohttp-3.12.15-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad702e57dc385cae679c39d318def49aef754455f237499d5b99bea4ef582e51"}, - {file = "aiohttp-3.12.15-cp313-cp313-win32.whl", hash = "sha256:f813c3e9032331024de2eb2e32a88d86afb69291fbc37a3a3ae81cc9917fb3d0"}, - {file = "aiohttp-3.12.15-cp313-cp313-win_amd64.whl", hash = "sha256:1a649001580bdb37c6fdb1bebbd7e3bc688e8ec2b5c6f52edbb664662b17dc84"}, - {file = "aiohttp-3.12.15-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:691d203c2bdf4f4637792efbbcdcd157ae11e55eaeb5e9c360c1206fb03d4d98"}, - {file = "aiohttp-3.12.15-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8e995e1abc4ed2a454c731385bf4082be06f875822adc4c6d9eaadf96e20d406"}, - {file = "aiohttp-3.12.15-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:bd44d5936ab3193c617bfd6c9a7d8d1085a8dc8c3f44d5f1dcf554d17d04cf7d"}, - {file = "aiohttp-3.12.15-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:46749be6e89cd78d6068cdf7da51dbcfa4321147ab8e4116ee6678d9a056a0cf"}, - {file = "aiohttp-3.12.15-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0c643f4d75adea39e92c0f01b3fb83d57abdec8c9279b3078b68a3a52b3933b6"}, - {file = "aiohttp-3.12.15-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0a23918fedc05806966a2438489dcffccbdf83e921a1170773b6178d04ade142"}, - {file = "aiohttp-3.12.15-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:74bdd8c864b36c3673741023343565d95bfbd778ffe1eb4d412c135a28a8dc89"}, - {file = "aiohttp-3.12.15-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0a146708808c9b7a988a4af3821379e379e0f0e5e466ca31a73dbdd0325b0263"}, - {file = "aiohttp-3.12.15-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b7011a70b56facde58d6d26da4fec3280cc8e2a78c714c96b7a01a87930a9530"}, - {file = "aiohttp-3.12.15-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:3bdd6e17e16e1dbd3db74d7f989e8af29c4d2e025f9828e6ef45fbdee158ec75"}, - {file = "aiohttp-3.12.15-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:57d16590a351dfc914670bd72530fd78344b885a00b250e992faea565b7fdc05"}, - {file = "aiohttp-3.12.15-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:bc9a0f6569ff990e0bbd75506c8d8fe7214c8f6579cca32f0546e54372a3bb54"}, - {file = "aiohttp-3.12.15-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:536ad7234747a37e50e7b6794ea868833d5220b49c92806ae2d7e8a9d6b5de02"}, - {file = "aiohttp-3.12.15-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:f0adb4177fa748072546fb650d9bd7398caaf0e15b370ed3317280b13f4083b0"}, - {file = "aiohttp-3.12.15-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:14954a2988feae3987f1eb49c706bff39947605f4b6fa4027c1d75743723eb09"}, - {file = "aiohttp-3.12.15-cp39-cp39-win32.whl", hash = "sha256:b784d6ed757f27574dca1c336f968f4e81130b27595e458e69457e6878251f5d"}, - {file = "aiohttp-3.12.15-cp39-cp39-win_amd64.whl", hash = "sha256:86ceded4e78a992f835209e236617bffae649371c4a50d5e5a3987f237db84b8"}, - {file = "aiohttp-3.12.15.tar.gz", hash = "sha256:4fc61385e9c98d72fcdf47e6dd81833f47b2f77c114c29cd64a361be57a763a2"}, -] - -[package.dependencies] -aiohappyeyeballs = ">=2.5.0" -aiosignal = ">=1.4.0" -attrs = ">=17.3.0" -frozenlist = ">=1.1.1" -multidict = ">=4.5,<7.0" -propcache = ">=0.2.0" -yarl = ">=1.17.0,<2.0" - -[package.extras] -speedups = ["Brotli ; platform_python_implementation == \"CPython\"", "aiodns (>=3.3.0)", "brotlicffi ; platform_python_implementation != \"CPython\""] - -[[package]] -name = "aiohttp-asyncmdnsresolver" -version = "0.1.1" -description = "An async resolver for aiohttp that supports MDNS" -optional = false -python-versions = ">=3.9" -groups = ["main", "dev"] -files = [ - {file = "aiohttp_asyncmdnsresolver-0.1.1-py3-none-any.whl", hash = "sha256:d04ded993e9f0e07c07a1bc687cde447d9d32e05bcf55ecbf94f63b33dcab93e"}, - {file = "aiohttp_asyncmdnsresolver-0.1.1.tar.gz", hash = "sha256:8c65d4b08b42c8a260717a2766bd5967a1d437cee852a9b21f3928b5171a7c81"}, -] - -[package.dependencies] -aiodns = ">=3.2.0" -aiohttp = ">=3.10.0" -zeroconf = ">=0.142.0" - -[[package]] -name = "aiohttp-cors" -version = "0.8.1" -description = "CORS support for aiohttp" -optional = false -python-versions = ">=3.9" -groups = ["main", "dev"] -files = [ - {file = "aiohttp_cors-0.8.1-py3-none-any.whl", hash = "sha256:3180cf304c5c712d626b9162b195b1db7ddf976a2a25172b35bb2448b890a80d"}, - {file = "aiohttp_cors-0.8.1.tar.gz", hash = "sha256:ccacf9cb84b64939ea15f859a146af1f662a6b1d68175754a07315e305fb1403"}, -] - -[package.dependencies] -aiohttp = ">=3.9" - -[[package]] -name = "aiohttp-fast-zlib" -version = "0.3.0" -description = "Use the fastest installed zlib compatible library with aiohttp" -optional = false -python-versions = ">=3.9" -groups = ["main", "dev"] -files = [ - {file = "aiohttp_fast_zlib-0.3.0-py3-none-any.whl", hash = "sha256:d4cb20760a3e1137c93cb42c13871cbc9cd1fdc069352f2712cd650d6c0e537e"}, - {file = "aiohttp_fast_zlib-0.3.0.tar.gz", hash = "sha256:963a09de571b67fa0ef9cb44c5a32ede5cb1a51bc79fc21181b1cddd56b58b28"}, -] - -[package.dependencies] -aiohttp = ">=3.9.0" - -[package.extras] -isal = ["isal (>=1.6.1)"] -zlib-ng = ["zlib_ng (>=0.4.3)"] - -[[package]] -name = "aiooui" -version = "0.1.9" -description = "Async OUI lookups" -optional = false -python-versions = "<4.0,>=3.9" -groups = ["main", "dev"] -files = [ - {file = "aiooui-0.1.9-cp310-cp310-manylinux_2_31_x86_64.whl", hash = "sha256:64d904b43f14dd1d8d9fcf1684d9e2f558bc5e0bd68dc10023c93355c9027907"}, - {file = "aiooui-0.1.9-py3-none-any.whl", hash = "sha256:737a5e62d8726540218c2b70e5f966d9912121e4644f3d490daf8f3c18b182e5"}, - {file = "aiooui-0.1.9.tar.gz", hash = "sha256:e8c8bc59ab352419e0747628b4cce7c4e04d492574c1971e223401126389c5d8"}, -] - -[[package]] -name = "aiosignal" -version = "1.4.0" -description = "aiosignal: a list of registered asynchronous callbacks" -optional = false -python-versions = ">=3.9" -groups = ["main", "dev"] -files = [ - {file = "aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e"}, - {file = "aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7"}, -] - -[package.dependencies] -frozenlist = ">=1.1.0" - -[[package]] -name = "aiozoneinfo" -version = "0.2.3" -description = "Tools to fetch zoneinfo with asyncio" -optional = false -python-versions = ">=3.9" -groups = ["main", "dev"] -files = [ - {file = "aiozoneinfo-0.2.3-py3-none-any.whl", hash = "sha256:5423f0354c9eed982e3f1c35edeeef1458d4cc6a10f106616891a089a8455661"}, - {file = "aiozoneinfo-0.2.3.tar.gz", hash = "sha256:987ce2a7d5141f3f4c2e9d50606310d0bf60d688ad9f087aa7267433ba85fff3"}, -] - -[package.dependencies] -tzdata = ">=2024.1" - -[[package]] -name = "annotated-types" -version = "0.7.0" -description = "Reusable constraint types to use with typing.Annotated" -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, - {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, -] - -[[package]] -name = "annotatedyaml" -version = "0.4.5" -description = "Annotated YAML that supports secrets for Python" -optional = false -python-versions = ">=3.12" -groups = ["main", "dev"] -files = [ - {file = "annotatedyaml-0.4.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bf113cfe1c7a85f0e61ea39a6d2f3fdcf12fe528e7d563f8eff4a89afdfaa7a1"}, - {file = "annotatedyaml-0.4.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c6f64ebe3a81d7ddc6bc261feca2092905043e493da369bd93ad6aab58399a0a"}, - {file = "annotatedyaml-0.4.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:480670331a3f906ddc760f21b302984ace4c674cfa3e6c48fdf76841dd0cdc1e"}, - {file = "annotatedyaml-0.4.5-cp312-cp312-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:981b1dc193163d17757a8b8016b048e6d315de93055671a989f327276e1acd30"}, - {file = "annotatedyaml-0.4.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6acea1969910a3a956fdb818734bfb0e5f7a377c18d1080846372c281d930dd9"}, - {file = "annotatedyaml-0.4.5-cp312-cp312-manylinux_2_31_armv7l.manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:649a256ae447e97f075943ab6cfc15d582490f994ffc5523225ebdeaffd24164"}, - {file = "annotatedyaml-0.4.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a91b433c5250d3a42bbf5a72e38e2cd04f1fd48c82eae7f6dec3ecb3b4cc121e"}, - {file = "annotatedyaml-0.4.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5a0ecba3df7c5fd4f2256669b4d375a08e7e48db1092f44e98fe35505121f1ea"}, - {file = "annotatedyaml-0.4.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:2b0c706df48c8b96250b1f18728f815f3c7bdb6ad86310f3bc433cd21ca063ce"}, - {file = "annotatedyaml-0.4.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5ce311f389a6f149f0d7e76ba789ab5c543ed23c83fcd16e6f05e75934039f75"}, - {file = "annotatedyaml-0.4.5-cp312-cp312-win32.whl", hash = "sha256:9ce177a6a1c751ac08beaa8b9e449d4b3ef759ab23ad88847970d55b625f58d9"}, - {file = "annotatedyaml-0.4.5-cp312-cp312-win_amd64.whl", hash = "sha256:6ca77b171137f8a2939c3fc4eae70d26fcefa4fa7e7a839d84f0bb1f4b979b4a"}, - {file = "annotatedyaml-0.4.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:971293ef07be457554ee97bcd6f7b0cb13df1c8d8ab1a2554880d78d9dc5d27a"}, - {file = "annotatedyaml-0.4.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8100a47d37b766f850bf8659fc6f973b14633f5d4a1957195af0a0e36449ffbe"}, - {file = "annotatedyaml-0.4.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:51a053d426ce1d1d7a783cea5185f5f5b3a4c3c2f269cd9cd2dfb07bd6671ee0"}, - {file = "annotatedyaml-0.4.5-cp313-cp313-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:2ca45e75b3091680553f21dca3f776075fb029f1a8499de61801cb0712f29de5"}, - {file = "annotatedyaml-0.4.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7354a88931bc73e05d4e1b24dd6c26b8618ea6412553b4c8084a7481932482bc"}, - {file = "annotatedyaml-0.4.5-cp313-cp313-manylinux_2_31_armv7l.manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:75c3a91402dcfcf45967dcbbcd3ee151222c4881202be87f00c17cf0d627caae"}, - {file = "annotatedyaml-0.4.5-cp313-cp313-manylinux_2_36_x86_64.whl", hash = "sha256:3d76ca28122fd063f27f298aa76f074f4bb8dd84501cf74cfec51931f0ed7ae0"}, - {file = "annotatedyaml-0.4.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ea47e128d2a8f549fad47b4a579f9d0a0e11733130419cb5071eb242caf5e66e"}, - {file = "annotatedyaml-0.4.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:b0b21600607faea68a6a8e99fab7671119a672c454b153aec3fc3410347650ee"}, - {file = "annotatedyaml-0.4.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:233864f23f89a43457759a526a01cccc9f60409b08070b806b5122ee5cc4cb9c"}, - {file = "annotatedyaml-0.4.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:35e0be8088e81b60be70da401da23db5420795e1e3ba7451d232a02dd9a81f30"}, - {file = "annotatedyaml-0.4.5-cp313-cp313-win32.whl", hash = "sha256:967fddfa8af4864f09190bde7905f05ab5bdd5f32fcca672e86033a39b0afbe8"}, - {file = "annotatedyaml-0.4.5-cp313-cp313-win_amd64.whl", hash = "sha256:f53f9f8e4ae92081653337be56265cf7085a5bc216f5e15c4531b36de5cba365"}, - {file = "annotatedyaml-0.4.5.tar.gz", hash = "sha256:e251929cd7e741fa2e9ece13e24e29bb8f1b5c6ca3a9ef7292a66a3ae8b9390f"}, -] - -[package.dependencies] -propcache = ">0.1" -pyyaml = ">=6.0.1" -voluptuous = ">0.15" - -[[package]] -name = "anyio" -version = "4.11.0" -description = "High-level concurrency and networking framework on top of asyncio or Trio" -optional = false -python-versions = ">=3.9" -groups = ["main", "dev"] -files = [ - {file = "anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc"}, - {file = "anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4"}, -] - -[package.dependencies] -idna = ">=2.8" -sniffio = ">=1.1" - -[package.extras] -trio = ["trio (>=0.31.0)"] - -[[package]] -name = "astral" -version = "2.2" -description = "Calculations for the position of the sun and moon." -optional = false -python-versions = ">=3.6" -groups = ["main", "dev"] -files = [ - {file = "astral-2.2-py2.py3-none-any.whl", hash = "sha256:b9ef70faf32e81a8ba174d21e8f29dc0b53b409ef035f27e0749ddc13cb5982a"}, - {file = "astral-2.2.tar.gz", hash = "sha256:e41d9967d5c48be421346552f0f4dedad43ff39a83574f5ff2ad32b6627b6fbe"}, -] - -[package.dependencies] -pytz = "*" - -[[package]] -name = "astroid" -version = "3.3.11" -description = "An abstract syntax tree for Python with inference support." -optional = false -python-versions = ">=3.9.0" -groups = ["dev"] -files = [ - {file = "astroid-3.3.11-py3-none-any.whl", hash = "sha256:54c760ae8322ece1abd213057c4b5bba7c49818853fc901ef09719a60dbf9dec"}, - {file = "astroid-3.3.11.tar.gz", hash = "sha256:1e5a5011af2920c7c67a53f65d536d65bfa7116feeaf2354d8b94f29573bb0ce"}, -] - -[[package]] -name = "async-interrupt" -version = "1.2.2" -description = "Context manager to raise an exception when a future is done" -optional = false -python-versions = ">=3.9" -groups = ["main", "dev"] -files = [ - {file = "async_interrupt-1.2.2-py3-none-any.whl", hash = "sha256:0a8deb884acfb5fe55188a693ae8a4381bbbd2cb6e670dac83869489513eec2c"}, - {file = "async_interrupt-1.2.2.tar.gz", hash = "sha256:be4331a029b8625777905376a6dc1370984c8c810f30b79703f3ee039d262bf7"}, -] - -[[package]] -name = "async-timeout" -version = "5.0.1" -description = "Timeout context manager for asyncio programs" -optional = false -python-versions = ">=3.8" -groups = ["main", "dev"] -files = [ - {file = "async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c"}, - {file = "async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3"}, -] - -[[package]] -name = "atomicwrites-homeassistant" -version = "1.4.1" -description = "Atomic file writes." -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -groups = ["main", "dev"] -files = [ - {file = "atomicwrites-homeassistant-1.4.1.tar.gz", hash = "sha256:256a672106f16745445228d966240b77b55f46a096d20305901a57aa5d1f4c2f"}, - {file = "atomicwrites_homeassistant-1.4.1-py2.py3-none-any.whl", hash = "sha256:01457de800961db7d5b575f3c92e7fb56e435d88512c366afb0873f4f092bb0d"}, -] - -[[package]] -name = "attrs" -version = "25.3.0" -description = "Classes Without Boilerplate" -optional = false -python-versions = ">=3.8" -groups = ["main", "dev"] -files = [ - {file = "attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3"}, - {file = "attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b"}, -] - -[package.extras] -benchmark = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pympler", "pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] -cov = ["cloudpickle ; platform_python_implementation == \"CPython\"", "coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] -dev = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pre-commit-uv", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] -docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier"] -tests = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] -tests-mypy = ["mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\""] - -[[package]] -name = "audioop-lts" -version = "0.2.1" -description = "LTS Port of Python audioop" -optional = false -python-versions = ">=3.13" -groups = ["main", "dev"] -files = [ - {file = "audioop_lts-0.2.1-cp313-abi3-macosx_10_13_universal2.whl", hash = "sha256:fd1345ae99e17e6910f47ce7d52673c6a1a70820d78b67de1b7abb3af29c426a"}, - {file = "audioop_lts-0.2.1-cp313-abi3-macosx_10_13_x86_64.whl", hash = "sha256:e175350da05d2087e12cea8e72a70a1a8b14a17e92ed2022952a4419689ede5e"}, - {file = "audioop_lts-0.2.1-cp313-abi3-macosx_11_0_arm64.whl", hash = "sha256:4a8dd6a81770f6ecf019c4b6d659e000dc26571b273953cef7cd1d5ce2ff3ae6"}, - {file = "audioop_lts-0.2.1-cp313-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1cd3c0b6f2ca25c7d2b1c3adeecbe23e65689839ba73331ebc7d893fcda7ffe"}, - {file = "audioop_lts-0.2.1-cp313-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ff3f97b3372c97782e9c6d3d7fdbe83bce8f70de719605bd7ee1839cd1ab360a"}, - {file = "audioop_lts-0.2.1-cp313-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a351af79edefc2a1bd2234bfd8b339935f389209943043913a919df4b0f13300"}, - {file = "audioop_lts-0.2.1-cp313-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2aeb6f96f7f6da80354330470b9134d81b4cf544cdd1c549f2f45fe964d28059"}, - {file = "audioop_lts-0.2.1-cp313-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c589f06407e8340e81962575fcffbba1e92671879a221186c3d4662de9fe804e"}, - {file = "audioop_lts-0.2.1-cp313-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fbae5d6925d7c26e712f0beda5ed69ebb40e14212c185d129b8dfbfcc335eb48"}, - {file = "audioop_lts-0.2.1-cp313-abi3-musllinux_1_2_i686.whl", hash = "sha256:d2d5434717f33117f29b5691fbdf142d36573d751716249a288fbb96ba26a281"}, - {file = "audioop_lts-0.2.1-cp313-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:f626a01c0a186b08f7ff61431c01c055961ee28769591efa8800beadd27a2959"}, - {file = "audioop_lts-0.2.1-cp313-abi3-musllinux_1_2_s390x.whl", hash = "sha256:05da64e73837f88ee5c6217d732d2584cf638003ac72df124740460531e95e47"}, - {file = "audioop_lts-0.2.1-cp313-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:56b7a0a4dba8e353436f31a932f3045d108a67b5943b30f85a5563f4d8488d77"}, - {file = "audioop_lts-0.2.1-cp313-abi3-win32.whl", hash = "sha256:6e899eb8874dc2413b11926b5fb3857ec0ab55222840e38016a6ba2ea9b7d5e3"}, - {file = "audioop_lts-0.2.1-cp313-abi3-win_amd64.whl", hash = "sha256:64562c5c771fb0a8b6262829b9b4f37a7b886c01b4d3ecdbae1d629717db08b4"}, - {file = "audioop_lts-0.2.1-cp313-abi3-win_arm64.whl", hash = "sha256:c45317debeb64002e980077642afbd977773a25fa3dfd7ed0c84dccfc1fafcb0"}, - {file = "audioop_lts-0.2.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:3827e3fce6fee4d69d96a3d00cd2ab07f3c0d844cb1e44e26f719b34a5b15455"}, - {file = "audioop_lts-0.2.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:161249db9343b3c9780ca92c0be0d1ccbfecdbccac6844f3d0d44b9c4a00a17f"}, - {file = "audioop_lts-0.2.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5b7b4ff9de7a44e0ad2618afdc2ac920b91f4a6d3509520ee65339d4acde5abf"}, - {file = "audioop_lts-0.2.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:72e37f416adb43b0ced93419de0122b42753ee74e87070777b53c5d2241e7fab"}, - {file = "audioop_lts-0.2.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:534ce808e6bab6adb65548723c8cbe189a3379245db89b9d555c4210b4aaa9b6"}, - {file = "audioop_lts-0.2.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d2de9b6fb8b1cf9f03990b299a9112bfdf8b86b6987003ca9e8a6c4f56d39543"}, - {file = "audioop_lts-0.2.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f24865991b5ed4b038add5edbf424639d1358144f4e2a3e7a84bc6ba23e35074"}, - {file = "audioop_lts-0.2.1-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bdb3b7912ccd57ea53197943f1bbc67262dcf29802c4a6df79ec1c715d45a78"}, - {file = "audioop_lts-0.2.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:120678b208cca1158f0a12d667af592e067f7a50df9adc4dc8f6ad8d065a93fb"}, - {file = "audioop_lts-0.2.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:54cd4520fc830b23c7d223693ed3e1b4d464997dd3abc7c15dce9a1f9bd76ab2"}, - {file = "audioop_lts-0.2.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:d6bd20c7a10abcb0fb3d8aaa7508c0bf3d40dfad7515c572014da4b979d3310a"}, - {file = "audioop_lts-0.2.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:f0ed1ad9bd862539ea875fb339ecb18fcc4148f8d9908f4502df28f94d23491a"}, - {file = "audioop_lts-0.2.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e1af3ff32b8c38a7d900382646e91f2fc515fd19dea37e9392275a5cbfdbff63"}, - {file = "audioop_lts-0.2.1-cp313-cp313t-win32.whl", hash = "sha256:f51bb55122a89f7a0817d7ac2319744b4640b5b446c4c3efcea5764ea99ae509"}, - {file = "audioop_lts-0.2.1-cp313-cp313t-win_amd64.whl", hash = "sha256:f0f2f336aa2aee2bce0b0dcc32bbba9178995454c7b979cf6ce086a8801e14c7"}, - {file = "audioop_lts-0.2.1-cp313-cp313t-win_arm64.whl", hash = "sha256:78bfb3703388c780edf900be66e07de5a3d4105ca8e8720c5c4d67927e0b15d0"}, - {file = "audioop_lts-0.2.1.tar.gz", hash = "sha256:e81268da0baa880431b68b1308ab7257eb33f356e57a5f9b1f915dfb13dd1387"}, -] - -[[package]] -name = "awesomeversion" -version = "25.5.0" -description = "One version package to rule them all, One version package to find them, One version package to bring them all, and in the darkness bind them." -optional = false -python-versions = "<4.0,>=3.9" -groups = ["main", "dev"] -files = [ - {file = "awesomeversion-25.5.0-py3-none-any.whl", hash = "sha256:34a676ae10e10d3a96829fcc890a1d377fe1a7a2b98ee19951631951c2aebff6"}, - {file = "awesomeversion-25.5.0.tar.gz", hash = "sha256:d64c9f3579d2f60a5aa506a9dd0b38a74ab5f45e04800f943a547c1102280f31"}, -] - -[[package]] -name = "bandit" -version = "1.8.6" -description = "Security oriented static analyser for python code." -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "bandit-1.8.6-py3-none-any.whl", hash = "sha256:3348e934d736fcdb68b6aa4030487097e23a501adf3e7827b63658df464dddd0"}, - {file = "bandit-1.8.6.tar.gz", hash = "sha256:dbfe9c25fc6961c2078593de55fd19f2559f9e45b99f1272341f5b95dea4e56b"}, -] - -[package.dependencies] -colorama = {version = ">=0.3.9", markers = "platform_system == \"Windows\""} -PyYAML = ">=5.3.1" -rich = "*" -stevedore = ">=1.20.0" - -[package.extras] -baseline = ["GitPython (>=3.1.30)"] -sarif = ["jschema-to-python (>=1.2.3)", "sarif-om (>=1.0.4)"] -test = ["beautifulsoup4 (>=4.8.0)", "coverage (>=4.5.4)", "fixtures (>=3.0.0)", "flake8 (>=4.0.0)", "pylint (==1.9.4)", "stestr (>=2.5.0)", "testscenarios (>=0.5.0)", "testtools (>=2.3.0)"] -toml = ["tomli (>=1.1.0) ; python_version < \"3.11\""] -yaml = ["PyYAML"] - -[[package]] -name = "bcrypt" -version = "4.3.0" -description = "Modern password hashing for your software and your servers" -optional = false -python-versions = ">=3.8" -groups = ["main", "dev"] -files = [ - {file = "bcrypt-4.3.0-cp313-cp313t-macosx_10_12_universal2.whl", hash = "sha256:f01e060f14b6b57bbb72fc5b4a83ac21c443c9a2ee708e04a10e9192f90a6281"}, - {file = "bcrypt-4.3.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5eeac541cefd0bb887a371ef73c62c3cd78535e4887b310626036a7c0a817bb"}, - {file = "bcrypt-4.3.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:59e1aa0e2cd871b08ca146ed08445038f42ff75968c7ae50d2fdd7860ade2180"}, - {file = "bcrypt-4.3.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:0042b2e342e9ae3d2ed22727c1262f76cc4f345683b5c1715f0250cf4277294f"}, - {file = "bcrypt-4.3.0-cp313-cp313t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74a8d21a09f5e025a9a23e7c0fd2c7fe8e7503e4d356c0a2c1486ba010619f09"}, - {file = "bcrypt-4.3.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:0142b2cb84a009f8452c8c5a33ace5e3dfec4159e7735f5afe9a4d50a8ea722d"}, - {file = "bcrypt-4.3.0-cp313-cp313t-manylinux_2_34_aarch64.whl", hash = "sha256:12fa6ce40cde3f0b899729dbd7d5e8811cb892d31b6f7d0334a1f37748b789fd"}, - {file = "bcrypt-4.3.0-cp313-cp313t-manylinux_2_34_x86_64.whl", hash = "sha256:5bd3cca1f2aa5dbcf39e2aa13dd094ea181f48959e1071265de49cc2b82525af"}, - {file = "bcrypt-4.3.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:335a420cfd63fc5bc27308e929bee231c15c85cc4c496610ffb17923abf7f231"}, - {file = "bcrypt-4.3.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:0e30e5e67aed0187a1764911af023043b4542e70a7461ad20e837e94d23e1d6c"}, - {file = "bcrypt-4.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:3b8d62290ebefd49ee0b3ce7500f5dbdcf13b81402c05f6dafab9a1e1b27212f"}, - {file = "bcrypt-4.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2ef6630e0ec01376f59a006dc72918b1bf436c3b571b80fa1968d775fa02fe7d"}, - {file = "bcrypt-4.3.0-cp313-cp313t-win32.whl", hash = "sha256:7a4be4cbf241afee43f1c3969b9103a41b40bcb3a3f467ab19f891d9bc4642e4"}, - {file = "bcrypt-4.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5c1949bf259a388863ced887c7861da1df681cb2388645766c89fdfd9004c669"}, - {file = "bcrypt-4.3.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:f81b0ed2639568bf14749112298f9e4e2b28853dab50a8b357e31798686a036d"}, - {file = "bcrypt-4.3.0-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:864f8f19adbe13b7de11ba15d85d4a428c7e2f344bac110f667676a0ff84924b"}, - {file = "bcrypt-4.3.0-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e36506d001e93bffe59754397572f21bb5dc7c83f54454c990c74a468cd589e"}, - {file = "bcrypt-4.3.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:842d08d75d9fe9fb94b18b071090220697f9f184d4547179b60734846461ed59"}, - {file = "bcrypt-4.3.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7c03296b85cb87db865d91da79bf63d5609284fc0cab9472fdd8367bbd830753"}, - {file = "bcrypt-4.3.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:62f26585e8b219cdc909b6a0069efc5e4267e25d4a3770a364ac58024f62a761"}, - {file = "bcrypt-4.3.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:beeefe437218a65322fbd0069eb437e7c98137e08f22c4660ac2dc795c31f8bb"}, - {file = "bcrypt-4.3.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:97eea7408db3a5bcce4a55d13245ab3fa566e23b4c67cd227062bb49e26c585d"}, - {file = "bcrypt-4.3.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:191354ebfe305e84f344c5964c7cd5f924a3bfc5d405c75ad07f232b6dffb49f"}, - {file = "bcrypt-4.3.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:41261d64150858eeb5ff43c753c4b216991e0ae16614a308a15d909503617732"}, - {file = "bcrypt-4.3.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:33752b1ba962ee793fa2b6321404bf20011fe45b9afd2a842139de3011898fef"}, - {file = "bcrypt-4.3.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:50e6e80a4bfd23a25f5c05b90167c19030cf9f87930f7cb2eacb99f45d1c3304"}, - {file = "bcrypt-4.3.0-cp38-abi3-win32.whl", hash = "sha256:67a561c4d9fb9465ec866177e7aebcad08fe23aaf6fbd692a6fab69088abfc51"}, - {file = "bcrypt-4.3.0-cp38-abi3-win_amd64.whl", hash = "sha256:584027857bc2843772114717a7490a37f68da563b3620f78a849bcb54dc11e62"}, - {file = "bcrypt-4.3.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0d3efb1157edebfd9128e4e46e2ac1a64e0c1fe46fb023158a407c7892b0f8c3"}, - {file = "bcrypt-4.3.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08bacc884fd302b611226c01014eca277d48f0a05187666bca23aac0dad6fe24"}, - {file = "bcrypt-4.3.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f6746e6fec103fcd509b96bacdfdaa2fbde9a553245dbada284435173a6f1aef"}, - {file = "bcrypt-4.3.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:afe327968aaf13fc143a56a3360cb27d4ad0345e34da12c7290f1b00b8fe9a8b"}, - {file = "bcrypt-4.3.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d9af79d322e735b1fc33404b5765108ae0ff232d4b54666d46730f8ac1a43676"}, - {file = "bcrypt-4.3.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f1e3ffa1365e8702dc48c8b360fef8d7afeca482809c5e45e653af82ccd088c1"}, - {file = "bcrypt-4.3.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:3004df1b323d10021fda07a813fd33e0fd57bef0e9a480bb143877f6cba996fe"}, - {file = "bcrypt-4.3.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:531457e5c839d8caea9b589a1bcfe3756b0547d7814e9ce3d437f17da75c32b0"}, - {file = "bcrypt-4.3.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:17a854d9a7a476a89dcef6c8bd119ad23e0f82557afbd2c442777a16408e614f"}, - {file = "bcrypt-4.3.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:6fb1fd3ab08c0cbc6826a2e0447610c6f09e983a281b919ed721ad32236b8b23"}, - {file = "bcrypt-4.3.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e965a9c1e9a393b8005031ff52583cedc15b7884fce7deb8b0346388837d6cfe"}, - {file = "bcrypt-4.3.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:79e70b8342a33b52b55d93b3a59223a844962bef479f6a0ea318ebbcadf71505"}, - {file = "bcrypt-4.3.0-cp39-abi3-win32.whl", hash = "sha256:b4d4e57f0a63fd0b358eb765063ff661328f69a04494427265950c71b992a39a"}, - {file = "bcrypt-4.3.0-cp39-abi3-win_amd64.whl", hash = "sha256:e53e074b120f2877a35cc6c736b8eb161377caae8925c17688bd46ba56daaa5b"}, - {file = "bcrypt-4.3.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c950d682f0952bafcceaf709761da0a32a942272fad381081b51096ffa46cea1"}, - {file = "bcrypt-4.3.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:107d53b5c67e0bbc3f03ebf5b030e0403d24dda980f8e244795335ba7b4a027d"}, - {file = "bcrypt-4.3.0-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:b693dbb82b3c27a1604a3dff5bfc5418a7e6a781bb795288141e5f80cf3a3492"}, - {file = "bcrypt-4.3.0-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:b6354d3760fcd31994a14c89659dee887f1351a06e5dac3c1142307172a79f90"}, - {file = "bcrypt-4.3.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:a839320bf27d474e52ef8cb16449bb2ce0ba03ca9f44daba6d93fa1d8828e48a"}, - {file = "bcrypt-4.3.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:bdc6a24e754a555d7316fa4774e64c6c3997d27ed2d1964d55920c7c227bc4ce"}, - {file = "bcrypt-4.3.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:55a935b8e9a1d2def0626c4269db3fcd26728cbff1e84f0341465c31c4ee56d8"}, - {file = "bcrypt-4.3.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:57967b7a28d855313a963aaea51bf6df89f833db4320da458e5b3c5ab6d4c938"}, - {file = "bcrypt-4.3.0.tar.gz", hash = "sha256:3a3fd2204178b6d2adcf09cb4f6426ffef54762577a7c9b54c159008cb288c18"}, -] - -[package.extras] -tests = ["pytest (>=3.2.1,!=3.3.0)"] -typecheck = ["mypy"] - -[[package]] -name = "bleak" -version = "1.1.1" -description = "Bluetooth Low Energy platform Agnostic Klient" -optional = false -python-versions = ">=3.9" -groups = ["main", "dev"] -files = [ - {file = "bleak-1.1.1-py3-none-any.whl", hash = "sha256:e601371396e357d95ee3c256db65b7da624c94ef6f051d47dfce93ea8361c22e"}, - {file = "bleak-1.1.1.tar.gz", hash = "sha256:eeef18053eb3bd569a25bff62cd4eb9ee56be4d84f5321023a7c4920943e6ccb"}, -] - -[package.dependencies] -dbus-fast = {version = ">=1.83.0", markers = "platform_system == \"Linux\""} -pyobjc-core = {version = ">=10.3", markers = "platform_system == \"Darwin\""} -pyobjc-framework-CoreBluetooth = {version = ">=10.3", markers = "platform_system == \"Darwin\""} -pyobjc-framework-libdispatch = {version = ">=10.3", markers = "platform_system == \"Darwin\""} -winrt-runtime = {version = ">=3.1", markers = "platform_system == \"Windows\""} -"winrt-Windows.Devices.Bluetooth" = {version = ">=3.1", markers = "platform_system == \"Windows\""} -"winrt-Windows.Devices.Bluetooth.Advertisement" = {version = ">=3.1", markers = "platform_system == \"Windows\""} -"winrt-Windows.Devices.Bluetooth.GenericAttributeProfile" = {version = ">=3.1", markers = "platform_system == \"Windows\""} -"winrt-Windows.Devices.Enumeration" = {version = ">=3.1", markers = "platform_system == \"Windows\""} -"winrt-Windows.Foundation" = {version = ">=3.1", markers = "platform_system == \"Windows\""} -"winrt-Windows.Foundation.Collections" = {version = ">=3.1", markers = "platform_system == \"Windows\""} -"winrt-Windows.Storage.Streams" = {version = ">=3.1", markers = "platform_system == \"Windows\""} - -[package.extras] -pythonista = ["bleak-pythonista (>=0.1.1)"] - -[[package]] -name = "bleak-retry-connector" -version = "4.4.3" -description = "A connector for Bleak Clients that handles transient connection failures" -optional = false -python-versions = ">=3.10" -groups = ["main", "dev"] -files = [ - {file = "bleak_retry_connector-4.4.3-py3-none-any.whl", hash = "sha256:17a478d525706488973b181fc789e960bc3fb4bcd94ccb0eee7b7b682442577b"}, - {file = "bleak_retry_connector-4.4.3.tar.gz", hash = "sha256:70aa305dbd26eaf0586dd24723daac93ee3dd6a465e9782bf02b711fcbc4a527"}, -] - -[package.dependencies] -bleak = {version = ">=1", markers = "python_version >= \"3.10\" and python_version < \"3.14\""} -bluetooth-adapters = {version = ">=0.15.2", markers = "python_version >= \"3.10\" and python_version < \"3.14\" and platform_system == \"Linux\""} -dbus-fast = {version = ">=1.14.0", markers = "platform_system == \"Linux\""} - -[[package]] -name = "bluetooth-adapters" -version = "2.1.1" -description = "Tools to enumerate and find Bluetooth Adapters" -optional = false -python-versions = ">=3.9" -groups = ["main", "dev"] -files = [ - {file = "bluetooth_adapters-2.1.1-py3-none-any.whl", hash = "sha256:1f93026e530dcb2f4515a92955fa6f85934f928b009a181ee57edc8b4affd25c"}, - {file = "bluetooth_adapters-2.1.1.tar.gz", hash = "sha256:f289e0f08814f74252a28862f488283680584744430d7eac45820f9c20ba041a"}, -] - -[package.dependencies] -aiooui = ">=0.1.1" -bleak = ">=1" -dbus-fast = {version = ">=1.21.0", markers = "platform_system == \"Linux\""} -uart-devices = ">=0.1.0" -usb-devices = ">=0.4.5" - -[package.extras] -docs = ["Sphinx (>=5,<8)", "myst-parser (>=0.18,<3.1)", "sphinx-rtd-theme (>=1,<4)"] - -[[package]] -name = "bluetooth-auto-recovery" -version = "1.5.3" -description = "Recover bluetooth adapters that are in an stuck state" -optional = false -python-versions = ">=3.9" -groups = ["main", "dev"] -files = [ - {file = "bluetooth_auto_recovery-1.5.3-py3-none-any.whl", hash = "sha256:5d66b859a54ef20fdf1bd3cf6762f153e86651babe716836770da9d9c47b01c4"}, - {file = "bluetooth_auto_recovery-1.5.3.tar.gz", hash = "sha256:0b36aa6be84474fff81c1ce328f016a6553272ac47050b1fa60f03e36a8db46d"}, -] - -[package.dependencies] -bluetooth-adapters = ">=0.16.0" -btsocket = ">=0.2.0" -PyRIC = ">=0.1.6.3" -usb-devices = ">=0.4.1" - -[package.extras] -docs = ["Sphinx (>=5,<8)", "myst-parser (>=0.18,<3.1)", "sphinx-rtd-theme (>=1,<4)"] - -[[package]] -name = "bluetooth-data-tools" -version = "1.28.2" -description = "Tools for converting bluetooth data and packets" -optional = false -python-versions = ">=3.10" -groups = ["main", "dev"] -files = [ - {file = "bluetooth_data_tools-1.28.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:59de191b17a6d7ab23f00b375638667556424c53f08efd288fc9694cf347edb8"}, - {file = "bluetooth_data_tools-1.28.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:12498ee9387680205aaa856f964d70517a1ac2d4d64bbb78c35cb1f152137bf1"}, - {file = "bluetooth_data_tools-1.28.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9a559839fc1fd5f84439608b3df598ed583354a671306ff2cd4ef9e667cba855"}, - {file = "bluetooth_data_tools-1.28.2-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:191b8e40b35cfd4201f01d7638bd9e845cb42479a6cc6943fd3cbcd2c978d434"}, - {file = "bluetooth_data_tools-1.28.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:52b7bb1268d5e56a658efd666242a0b7b407f460a3257512b77a2fd3ed0bd9f7"}, - {file = "bluetooth_data_tools-1.28.2-cp310-cp310-manylinux_2_31_armv7l.manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:04d2d0310eae1577554edf2b3306f9ed18b9621edff57e153204430cfa83a541"}, - {file = "bluetooth_data_tools-1.28.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:82283c317e465dd998af4ddb5553f79992213b4c1055d0ed1977e6786a5401d8"}, - {file = "bluetooth_data_tools-1.28.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:d26a2398886c37771e35fa4f2c36e965da771fe3e984e76c078a98bcc5eb3733"}, - {file = "bluetooth_data_tools-1.28.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:ab975c1567aa3fd20e787c8ddc9d55984d0883a251c7d65c55dc3a173f9ef796"}, - {file = "bluetooth_data_tools-1.28.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:0c8454962e005d48e23af3fe240235e16c81590805dfe0b27b08b82337df16a8"}, - {file = "bluetooth_data_tools-1.28.2-cp310-cp310-win32.whl", hash = "sha256:8e492b786ac561f628bf964f522d669259b608d1f434102d003afdc46dcab473"}, - {file = "bluetooth_data_tools-1.28.2-cp310-cp310-win_amd64.whl", hash = "sha256:567dae42c2e1d7da5aeca1ef181bd660db8fc2f3fe3af7e999e82a030a1a422d"}, - {file = "bluetooth_data_tools-1.28.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4042b36f1c50bcdc0afb6be78a0bac11a8be6a73a3284825502ce6e82661423e"}, - {file = "bluetooth_data_tools-1.28.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:728cfb9fd366b01eec9eaa226353cdd43e099288974f6e9273dc90a577fc970a"}, - {file = "bluetooth_data_tools-1.28.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2e3a2c9fba041b8486f1a4d4098b40241168a2d79ab0f5cdbccafadac7e41747"}, - {file = "bluetooth_data_tools-1.28.2-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5d3cd8bb926742f01ced40b635a860c1cf5a6b82c3ce88e8431ee77e1062b7d6"}, - {file = "bluetooth_data_tools-1.28.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a791a07d60ae70e2fc174688abd1e28fdc54a0c337b6306ce6c6a66c06e41db7"}, - {file = "bluetooth_data_tools-1.28.2-cp311-cp311-manylinux_2_31_armv7l.manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d2049946c4a489db86c7f5cc32ef50894952244be07a5919a58f8b4b03f742b1"}, - {file = "bluetooth_data_tools-1.28.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b3344421a012641f21ffbc7e3d5955dbc19150ad48d800b4b61a7a2cb4ee0d87"}, - {file = "bluetooth_data_tools-1.28.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:9e068aa7b0c147f8bae735298c041058693e8ee3763d769ca6cbd938210ed715"}, - {file = "bluetooth_data_tools-1.28.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0eed475d3dfe8af519348047f150b52dcaa5aa84c5a5bae023a6df3005fe95b1"}, - {file = "bluetooth_data_tools-1.28.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:dfffdaab077173886c64c5c89329d00c7140302fd7f537e3f7bd00d8b851c9d8"}, - {file = "bluetooth_data_tools-1.28.2-cp311-cp311-win32.whl", hash = "sha256:02045a0dc566122f3e30e4ac5861f35c5749cd52eda4abd5a3918c27bfff28fb"}, - {file = "bluetooth_data_tools-1.28.2-cp311-cp311-win_amd64.whl", hash = "sha256:ed619bb4fd4a3794c9c11c140c59a727bb1f63d0888e135da8382d2dbc8c18f9"}, - {file = "bluetooth_data_tools-1.28.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8eef61057a754293e2ca54e153ba804bc313257881bd8360de091524b3dffc0"}, - {file = "bluetooth_data_tools-1.28.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c1567e45ed6b4987db1e70e7f59d7499a57f8eec2e4a38131eeedd8c5b52bc81"}, - {file = "bluetooth_data_tools-1.28.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:094a6a4aeaf82a6e939318f716294fa7d65a8f025f8b10471e2145320a57b412"}, - {file = "bluetooth_data_tools-1.28.2-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:59398ab013e33539a08cc0dcf19f0fc4a7dc6311d5dc5bfb7af03906fc3a902f"}, - {file = "bluetooth_data_tools-1.28.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:010564351a0b269371b5f5dc4ed1084b9524028f106cce49208db5b1bc236e84"}, - {file = "bluetooth_data_tools-1.28.2-cp312-cp312-manylinux_2_31_armv7l.manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:639f89a57936fb65a881ea7dd60dc6d8bc5b859e740d2c4e699f28f28f3da992"}, - {file = "bluetooth_data_tools-1.28.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:60dac54c9fe308a9dc4b7bc5c94d174c6e93931ac27cdfba8b2cd5c19d73f126"}, - {file = "bluetooth_data_tools-1.28.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:50493246908a5aaa305d00df86a83d915a00cc1a783e05d6666ea3ff293cc137"}, - {file = "bluetooth_data_tools-1.28.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:7fcbf974dc3a7d1a00cebacc424dcf1d18d4eac4bccecba1d8ff5a1e8eb0b348"}, - {file = "bluetooth_data_tools-1.28.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0a5e9eb874c9823d330dd0eadca9176a8a713395908f865c19156a233da7b5c2"}, - {file = "bluetooth_data_tools-1.28.2-cp312-cp312-win32.whl", hash = "sha256:d3346ef76577f5060955a80c46ae16004f76673d844f8c44a416bd8177d6695c"}, - {file = "bluetooth_data_tools-1.28.2-cp312-cp312-win_amd64.whl", hash = "sha256:872c31ba9042ed614aad459fd0358893554c67e2fa5c2d78d525cd96af3e31da"}, - {file = "bluetooth_data_tools-1.28.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:71df3e6221ee472cb38fd625cecc6e0a8733e093e40c08e80638e9387349b43b"}, - {file = "bluetooth_data_tools-1.28.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b2925335caf40bb9872a8733d823bb8e97bac2bc7ce988a695452e4a39507e29"}, - {file = "bluetooth_data_tools-1.28.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:535c037b3ccd86a5df890b338b901eea3e974692ae07b591c1f99e787d629170"}, - {file = "bluetooth_data_tools-1.28.2-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:080668765dc7d04d6b78a7bc0feaffd14b45ccee58b5c005a22b78e3730934fd"}, - {file = "bluetooth_data_tools-1.28.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98c2947f86112fc308973df735f030ede800473dd61f9e32d62d55bfb5c00748"}, - {file = "bluetooth_data_tools-1.28.2-cp313-cp313-manylinux_2_31_armv7l.manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d74c6b9187b444e548cd01ce56c74eb0c1ba592043b9a1f48a9c2ed19a8a236a"}, - {file = "bluetooth_data_tools-1.28.2-cp313-cp313-manylinux_2_36_x86_64.whl", hash = "sha256:ad09f0dbc343e51c34f32672aa877373d747eebe956c640117ce9472c86f1cb2"}, - {file = "bluetooth_data_tools-1.28.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c833481774fe319ef239351bb8a028cc2efe44ad7cf23681bd2cd2a4dfb71599"}, - {file = "bluetooth_data_tools-1.28.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:a989a4a5e8e4d70410fd9bba7b03f970bed7b8f79531087565931314437420be"}, - {file = "bluetooth_data_tools-1.28.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6f30e619ca3b46716a7f8c2bde35776d36e6b98e1922f0642034618e1056b3b3"}, - {file = "bluetooth_data_tools-1.28.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cf3714c9e27aaa7db0800816bf766919cd1ac18080bac0102c2ad466db02f47a"}, - {file = "bluetooth_data_tools-1.28.2-cp313-cp313-win32.whl", hash = "sha256:8f28eeee5fecaebeb9fc1012e4220bc3c1ee6ee82bf8a17b9183995933f6d938"}, - {file = "bluetooth_data_tools-1.28.2-cp313-cp313-win_amd64.whl", hash = "sha256:e748587be85a8133b0a43e34e2c6f65dbf5113765a03d4f89c26039b8289decb"}, - {file = "bluetooth_data_tools-1.28.2.tar.gz", hash = "sha256:2afa97695fc61c8d55d19ffa9485a498051410f399a183852d1bf29f675c3537"}, -] - -[package.dependencies] -cryptography = ">=41.0.3" - -[package.extras] -docs = ["Sphinx (>=5,<9)", "myst-parser (>=0.18,<4.1)", "sphinx-rtd-theme (>=1,<4)"] - -[[package]] -name = "boolean-py" -version = "5.0" -description = "Define boolean algebras, create and parse boolean expressions and create custom boolean DSL." -optional = false -python-versions = "*" -groups = ["dev"] -files = [ - {file = "boolean_py-5.0-py3-none-any.whl", hash = "sha256:ef28a70bd43115208441b53a045d1549e2f0ec6e3d08a9d142cbc41c1938e8d9"}, - {file = "boolean_py-5.0.tar.gz", hash = "sha256:60cbc4bad079753721d32649545505362c754e121570ada4658b852a3a318d95"}, -] - -[package.extras] -dev = ["build", "twine"] -docs = ["Sphinx (>=3.3.1)", "doc8 (>=0.8.1)", "sphinx-rtd-theme (>=0.5.0)", "sphinxcontrib-apidoc (>=0.3.0)"] -linting = ["black", "isort", "pycodestyle"] -testing = ["pytest (>=6,!=7.0.0)", "pytest-xdist (>=2)"] - -[[package]] -name = "boto3" -version = "1.40.38" -description = "The AWS SDK for Python" -optional = false -python-versions = ">=3.9" -groups = ["main", "dev"] -files = [ - {file = "boto3-1.40.38-py3-none-any.whl", hash = "sha256:fac337b4f0615e4d6ceee44686e662f51d8e57916ed2bc763468e3e8c611a658"}, - {file = "boto3-1.40.38.tar.gz", hash = "sha256:932ebdd8dbf8ab5694d233df86d5d0950291e0b146c27cb46da8adb4f00f6ca4"}, -] - -[package.dependencies] -botocore = ">=1.40.38,<1.41.0" -jmespath = ">=0.7.1,<2.0.0" -s3transfer = ">=0.14.0,<0.15.0" - -[package.extras] -crt = ["botocore[crt] (>=1.21.0,<2.0a0)"] - -[[package]] -name = "botocore" -version = "1.40.38" -description = "Low-level, data-driven core of boto 3." -optional = false -python-versions = ">=3.9" -groups = ["main", "dev"] -files = [ - {file = "botocore-1.40.38-py3-none-any.whl", hash = "sha256:7d60a7557db3a58f9394e7ecec1f6b87495ce947eb713f29d53aee83a6e9dc71"}, - {file = "botocore-1.40.38.tar.gz", hash = "sha256:18039009e1eca2bff12e576e8dd3c80cd9b312294f1469c831de03169582ad59"}, -] - -[package.dependencies] -jmespath = ">=0.7.1,<2.0.0" -python-dateutil = ">=2.1,<3.0.0" -urllib3 = {version = ">=1.25.4,<2.2.0 || >2.2.0,<3", markers = "python_version >= \"3.10\""} - -[package.extras] -crt = ["awscrt (==0.27.6)"] - -[[package]] -name = "btsocket" -version = "0.3.0" -description = "Python library for BlueZ Bluetooth Management API" -optional = false -python-versions = "*" -groups = ["main", "dev"] -files = [ - {file = "btsocket-0.3.0-py2.py3-none-any.whl", hash = "sha256:949821c1b580a88e73804ad610f5173d6ae258e7b4e389da4f94d614344f1a9c"}, - {file = "btsocket-0.3.0.tar.gz", hash = "sha256:7ea495de0ff883f0d9f8eea59c72ca7fed492994df668fe476b84d814a147a0d"}, -] - -[package.extras] -dev = ["bumpversion", "coverage", "pycodestyle", "pygments", "sphinx", "sphinx-rtd-theme", "twine"] -docs = ["pygments", "sphinx", "sphinx-rtd-theme"] -rel = ["bumpversion", "twine"] -test = ["coverage", "pycodestyle"] - -[[package]] -name = "certifi" -version = "2025.8.3" -description = "Python package for providing Mozilla's CA Bundle." -optional = false -python-versions = ">=3.7" -groups = ["main", "dev"] -files = [ - {file = "certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5"}, - {file = "certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407"}, -] - -[[package]] -name = "cffi" -version = "2.0.0" -description = "Foreign Function Interface for Python calling C code." -optional = false -python-versions = ">=3.9" -groups = ["main", "dev"] -files = [ - {file = "cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44"}, - {file = "cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49"}, - {file = "cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c"}, - {file = "cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb"}, - {file = "cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0"}, - {file = "cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4"}, - {file = "cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453"}, - {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495"}, - {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5"}, - {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb"}, - {file = "cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a"}, - {file = "cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739"}, - {file = "cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe"}, - {file = "cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c"}, - {file = "cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92"}, - {file = "cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93"}, - {file = "cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5"}, - {file = "cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664"}, - {file = "cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26"}, - {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9"}, - {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414"}, - {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743"}, - {file = "cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5"}, - {file = "cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5"}, - {file = "cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d"}, - {file = "cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d"}, - {file = "cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c"}, - {file = "cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe"}, - {file = "cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062"}, - {file = "cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e"}, - {file = "cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037"}, - {file = "cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba"}, - {file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94"}, - {file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187"}, - {file = "cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18"}, - {file = "cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5"}, - {file = "cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6"}, - {file = "cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb"}, - {file = "cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca"}, - {file = "cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b"}, - {file = "cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b"}, - {file = "cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2"}, - {file = "cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3"}, - {file = "cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26"}, - {file = "cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c"}, - {file = "cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b"}, - {file = "cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27"}, - {file = "cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75"}, - {file = "cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91"}, - {file = "cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5"}, - {file = "cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13"}, - {file = "cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b"}, - {file = "cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c"}, - {file = "cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef"}, - {file = "cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775"}, - {file = "cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205"}, - {file = "cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1"}, - {file = "cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f"}, - {file = "cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25"}, - {file = "cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad"}, - {file = "cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9"}, - {file = "cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d"}, - {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c"}, - {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8"}, - {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc"}, - {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592"}, - {file = "cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512"}, - {file = "cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4"}, - {file = "cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e"}, - {file = "cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6"}, - {file = "cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9"}, - {file = "cffi-2.0.0-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf"}, - {file = "cffi-2.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:de8dad4425a6ca6e4e5e297b27b5c824ecc7581910bf9aee86cb6835e6812aa7"}, - {file = "cffi-2.0.0-cp39-cp39-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c"}, - {file = "cffi-2.0.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3f4d46d8b35698056ec29bca21546e1551a205058ae1a181d871e278b0b28165"}, - {file = "cffi-2.0.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e6e73b9e02893c764e7e8d5bb5ce277f1a009cd5243f8228f75f842bf937c534"}, - {file = "cffi-2.0.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:cb527a79772e5ef98fb1d700678fe031e353e765d1ca2d409c92263c6d43e09f"}, - {file = "cffi-2.0.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:61d028e90346df14fedc3d1e5441df818d095f3b87d286825dfcbd6459b7ef63"}, - {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0f6084a0ea23d05d20c3edcda20c3d006f9b6f3fefeac38f59262e10cef47ee2"}, - {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:1cd13c99ce269b3ed80b417dcd591415d3372bcac067009b6e0f59c7d4015e65"}, - {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:89472c9762729b5ae1ad974b777416bfda4ac5642423fa93bd57a09204712322"}, - {file = "cffi-2.0.0-cp39-cp39-win32.whl", hash = "sha256:2081580ebb843f759b9f617314a24ed5738c51d2aee65d31e02f6f7a2b97707a"}, - {file = "cffi-2.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9"}, - {file = "cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529"}, -] - -[package.dependencies] -pycparser = {version = "*", markers = "implementation_name != \"PyPy\""} - -[[package]] -name = "cfgv" -version = "3.4.0" -description = "Validate configuration and produce human readable error messages." -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9"}, - {file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"}, -] - -[[package]] -name = "charset-normalizer" -version = "3.4.3" -description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." -optional = false -python-versions = ">=3.7" -groups = ["main", "dev"] -files = [ - {file = "charset_normalizer-3.4.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:fb7f67a1bfa6e40b438170ebdc8158b78dc465a5a67b6dde178a46987b244a72"}, - {file = "charset_normalizer-3.4.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc9370a2da1ac13f0153780040f465839e6cccb4a1e44810124b4e22483c93fe"}, - {file = "charset_normalizer-3.4.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:07a0eae9e2787b586e129fdcbe1af6997f8d0e5abaa0bc98c0e20e124d67e601"}, - {file = "charset_normalizer-3.4.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:74d77e25adda8581ffc1c720f1c81ca082921329452eba58b16233ab1842141c"}, - {file = "charset_normalizer-3.4.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d0e909868420b7049dafd3a31d45125b31143eec59235311fc4c57ea26a4acd2"}, - {file = "charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c6f162aabe9a91a309510d74eeb6507fab5fff92337a15acbe77753d88d9dcf0"}, - {file = "charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:4ca4c094de7771a98d7fbd67d9e5dbf1eb73efa4f744a730437d8a3a5cf994f0"}, - {file = "charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:02425242e96bcf29a49711b0ca9f37e451da7c70562bc10e8ed992a5a7a25cc0"}, - {file = "charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:78deba4d8f9590fe4dae384aeff04082510a709957e968753ff3c48399f6f92a"}, - {file = "charset_normalizer-3.4.3-cp310-cp310-win32.whl", hash = "sha256:d79c198e27580c8e958906f803e63cddb77653731be08851c7df0b1a14a8fc0f"}, - {file = "charset_normalizer-3.4.3-cp310-cp310-win_amd64.whl", hash = "sha256:c6e490913a46fa054e03699c70019ab869e990270597018cef1d8562132c2669"}, - {file = "charset_normalizer-3.4.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b256ee2e749283ef3ddcff51a675ff43798d92d746d1a6e4631bf8c707d22d0b"}, - {file = "charset_normalizer-3.4.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13faeacfe61784e2559e690fc53fa4c5ae97c6fcedb8eb6fb8d0a15b475d2c64"}, - {file = "charset_normalizer-3.4.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:00237675befef519d9af72169d8604a067d92755e84fe76492fef5441db05b91"}, - {file = "charset_normalizer-3.4.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:585f3b2a80fbd26b048a0be90c5aae8f06605d3c92615911c3a2b03a8a3b796f"}, - {file = "charset_normalizer-3.4.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e78314bdc32fa80696f72fa16dc61168fda4d6a0c014e0380f9d02f0e5d8a07"}, - {file = "charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:96b2b3d1a83ad55310de8c7b4a2d04d9277d5591f40761274856635acc5fcb30"}, - {file = "charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:939578d9d8fd4299220161fdd76e86c6a251987476f5243e8864a7844476ba14"}, - {file = "charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:fd10de089bcdcd1be95a2f73dbe6254798ec1bda9f450d5828c96f93e2536b9c"}, - {file = "charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1e8ac75d72fa3775e0b7cb7e4629cec13b7514d928d15ef8ea06bca03ef01cae"}, - {file = "charset_normalizer-3.4.3-cp311-cp311-win32.whl", hash = "sha256:6cf8fd4c04756b6b60146d98cd8a77d0cdae0e1ca20329da2ac85eed779b6849"}, - {file = "charset_normalizer-3.4.3-cp311-cp311-win_amd64.whl", hash = "sha256:31a9a6f775f9bcd865d88ee350f0ffb0e25936a7f930ca98995c05abf1faf21c"}, - {file = "charset_normalizer-3.4.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e28e334d3ff134e88989d90ba04b47d84382a828c061d0d1027b1b12a62b39b1"}, - {file = "charset_normalizer-3.4.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0cacf8f7297b0c4fcb74227692ca46b4a5852f8f4f24b3c766dd94a1075c4884"}, - {file = "charset_normalizer-3.4.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c6fd51128a41297f5409deab284fecbe5305ebd7e5a1f959bee1c054622b7018"}, - {file = "charset_normalizer-3.4.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cfb2aad70f2c6debfbcb717f23b7eb55febc0bb23dcffc0f076009da10c6392"}, - {file = "charset_normalizer-3.4.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1606f4a55c0fd363d754049cdf400175ee96c992b1f8018b993941f221221c5f"}, - {file = "charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:027b776c26d38b7f15b26a5da1044f376455fb3766df8fc38563b4efbc515154"}, - {file = "charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:42e5088973e56e31e4fa58eb6bd709e42fc03799c11c42929592889a2e54c491"}, - {file = "charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cc34f233c9e71701040d772aa7490318673aa7164a0efe3172b2981218c26d93"}, - {file = "charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:320e8e66157cc4e247d9ddca8e21f427efc7a04bbd0ac8a9faf56583fa543f9f"}, - {file = "charset_normalizer-3.4.3-cp312-cp312-win32.whl", hash = "sha256:fb6fecfd65564f208cbf0fba07f107fb661bcd1a7c389edbced3f7a493f70e37"}, - {file = "charset_normalizer-3.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:86df271bf921c2ee3818f0522e9a5b8092ca2ad8b065ece5d7d9d0e9f4849bcc"}, - {file = "charset_normalizer-3.4.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:14c2a87c65b351109f6abfc424cab3927b3bdece6f706e4d12faaf3d52ee5efe"}, - {file = "charset_normalizer-3.4.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41d1fc408ff5fdfb910200ec0e74abc40387bccb3252f3f27c0676731df2b2c8"}, - {file = "charset_normalizer-3.4.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1bb60174149316da1c35fa5233681f7c0f9f514509b8e399ab70fea5f17e45c9"}, - {file = "charset_normalizer-3.4.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30d006f98569de3459c2fc1f2acde170b7b2bd265dc1943e87e1a4efe1b67c31"}, - {file = "charset_normalizer-3.4.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:416175faf02e4b0810f1f38bcb54682878a4af94059a1cd63b8747244420801f"}, - {file = "charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6aab0f181c486f973bc7262a97f5aca3ee7e1437011ef0c2ec04b5a11d16c927"}, - {file = "charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:fdabf8315679312cfa71302f9bd509ded4f2f263fb5b765cf1433b39106c3cc9"}, - {file = "charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:bd28b817ea8c70215401f657edef3a8aa83c29d447fb0b622c35403780ba11d5"}, - {file = "charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:18343b2d246dc6761a249ba1fb13f9ee9a2bcd95decc767319506056ea4ad4dc"}, - {file = "charset_normalizer-3.4.3-cp313-cp313-win32.whl", hash = "sha256:6fb70de56f1859a3f71261cbe41005f56a7842cc348d3aeb26237560bfa5e0ce"}, - {file = "charset_normalizer-3.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:cf1ebb7d78e1ad8ec2a8c4732c7be2e736f6e5123a4146c5b89c9d1f585f8cef"}, - {file = "charset_normalizer-3.4.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3cd35b7e8aedeb9e34c41385fda4f73ba609e561faedfae0a9e75e44ac558a15"}, - {file = "charset_normalizer-3.4.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b89bc04de1d83006373429975f8ef9e7932534b8cc9ca582e4db7d20d91816db"}, - {file = "charset_normalizer-3.4.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2001a39612b241dae17b4687898843f254f8748b796a2e16f1051a17078d991d"}, - {file = "charset_normalizer-3.4.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8dcfc373f888e4fb39a7bc57e93e3b845e7f462dacc008d9749568b1c4ece096"}, - {file = "charset_normalizer-3.4.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18b97b8404387b96cdbd30ad660f6407799126d26a39ca65729162fd810a99aa"}, - {file = "charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ccf600859c183d70eb47e05a44cd80a4ce77394d1ac0f79dbd2dd90a69a3a049"}, - {file = "charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:53cd68b185d98dde4ad8990e56a58dea83a4162161b1ea9272e5c9182ce415e0"}, - {file = "charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:30a96e1e1f865f78b030d65241c1ee850cdf422d869e9028e2fc1d5e4db73b92"}, - {file = "charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d716a916938e03231e86e43782ca7878fb602a125a91e7acb8b5112e2e96ac16"}, - {file = "charset_normalizer-3.4.3-cp314-cp314-win32.whl", hash = "sha256:c6dbd0ccdda3a2ba7c2ecd9d77b37f3b5831687d8dc1b6ca5f56a4880cc7b7ce"}, - {file = "charset_normalizer-3.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:73dc19b562516fc9bcf6e5d6e596df0b4eb98d87e4f79f3ae71840e6ed21361c"}, - {file = "charset_normalizer-3.4.3-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:0f2be7e0cf7754b9a30eb01f4295cc3d4358a479843b31f328afd210e2c7598c"}, - {file = "charset_normalizer-3.4.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c60e092517a73c632ec38e290eba714e9627abe9d301c8c8a12ec32c314a2a4b"}, - {file = "charset_normalizer-3.4.3-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:252098c8c7a873e17dd696ed98bbe91dbacd571da4b87df3736768efa7a792e4"}, - {file = "charset_normalizer-3.4.3-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3653fad4fe3ed447a596ae8638b437f827234f01a8cd801842e43f3d0a6b281b"}, - {file = "charset_normalizer-3.4.3-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8999f965f922ae054125286faf9f11bc6932184b93011d138925a1773830bbe9"}, - {file = "charset_normalizer-3.4.3-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:d95bfb53c211b57198bb91c46dd5a2d8018b3af446583aab40074bf7988401cb"}, - {file = "charset_normalizer-3.4.3-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:5b413b0b1bfd94dbf4023ad6945889f374cd24e3f62de58d6bb102c4d9ae534a"}, - {file = "charset_normalizer-3.4.3-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:b5e3b2d152e74e100a9e9573837aba24aab611d39428ded46f4e4022ea7d1942"}, - {file = "charset_normalizer-3.4.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:a2d08ac246bb48479170408d6c19f6385fa743e7157d716e144cad849b2dd94b"}, - {file = "charset_normalizer-3.4.3-cp38-cp38-win32.whl", hash = "sha256:ec557499516fc90fd374bf2e32349a2887a876fbf162c160e3c01b6849eaf557"}, - {file = "charset_normalizer-3.4.3-cp38-cp38-win_amd64.whl", hash = "sha256:5d8d01eac18c423815ed4f4a2ec3b439d654e55ee4ad610e153cf02faf67ea40"}, - {file = "charset_normalizer-3.4.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:70bfc5f2c318afece2f5838ea5e4c3febada0be750fcf4775641052bbba14d05"}, - {file = "charset_normalizer-3.4.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:23b6b24d74478dc833444cbd927c338349d6ae852ba53a0d02a2de1fce45b96e"}, - {file = "charset_normalizer-3.4.3-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:34a7f768e3f985abdb42841e20e17b330ad3aaf4bb7e7aeeb73db2e70f077b99"}, - {file = "charset_normalizer-3.4.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fb731e5deb0c7ef82d698b0f4c5bb724633ee2a489401594c5c88b02e6cb15f7"}, - {file = "charset_normalizer-3.4.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:257f26fed7d7ff59921b78244f3cd93ed2af1800ff048c33f624c87475819dd7"}, - {file = "charset_normalizer-3.4.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1ef99f0456d3d46a50945c98de1774da86f8e992ab5c77865ea8b8195341fc19"}, - {file = "charset_normalizer-3.4.3-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:2c322db9c8c89009a990ef07c3bcc9f011a3269bc06782f916cd3d9eed7c9312"}, - {file = "charset_normalizer-3.4.3-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:511729f456829ef86ac41ca78c63a5cb55240ed23b4b737faca0eb1abb1c41bc"}, - {file = "charset_normalizer-3.4.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:88ab34806dea0671532d3f82d82b85e8fc23d7b2dd12fa837978dad9bb392a34"}, - {file = "charset_normalizer-3.4.3-cp39-cp39-win32.whl", hash = "sha256:16a8770207946ac75703458e2c743631c79c59c5890c80011d536248f8eaa432"}, - {file = "charset_normalizer-3.4.3-cp39-cp39-win_amd64.whl", hash = "sha256:d22dbedd33326a4a5190dd4fe9e9e693ef12160c77382d9e87919bce54f3d4ca"}, - {file = "charset_normalizer-3.4.3-py3-none-any.whl", hash = "sha256:ce571ab16d890d23b5c278547ba694193a45011ff86a9162a71307ed9f86759a"}, - {file = "charset_normalizer-3.4.3.tar.gz", hash = "sha256:6fce4b8500244f6fcb71465d4a4930d132ba9ab8e71a7859e6a5d59851068d14"}, -] - -[[package]] -name = "ciso8601" -version = "2.3.2" -description = "Fast ISO8601 date time parser for Python written in C" -optional = false -python-versions = "*" -groups = ["main", "dev"] -files = [ - {file = "ciso8601-2.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1bb2d4d20d7ed65fcc7137652d7d980c6eb2aa19c935579309170137d33064ce"}, - {file = "ciso8601-2.3.2-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:3039f11ced0bc971341ab63be222860eb2cc942d51a7aa101b1809b633ad2288"}, - {file = "ciso8601-2.3.2-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:d64634b02cfb194e54569d8de3ace89cec745644cab38157aea0b03d32031eda"}, - {file = "ciso8601-2.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b0dcb8dc5998bc50346cec9d3b8b5deda8ddabeda70a923c110efb5100cd9754"}, - {file = "ciso8601-2.3.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13a3ca99eadbee4a9bb7dfb2bcf266a21828033853cd99803a9893d3473ac0e9"}, - {file = "ciso8601-2.3.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d61daee5e8daee87eba34151b9952ec8c3327ad9e54686b6247dcb9b2b135312"}, - {file = "ciso8601-2.3.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2f20654de6b0374eade96d8dcb0642196632067b6dd2e24068c563ac6b8551c6"}, - {file = "ciso8601-2.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:0283884c33dbe0555f9a24749ac947f93eac7b131fdfeeee110ad999947d1680"}, - {file = "ciso8601-2.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f0e856903cb6019ab26849af7270ef183b2314f87fd17686a8c98315eff794df"}, - {file = "ciso8601-2.3.2-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:d99297a5925ef3c9ac316cab082c1b1623d976acdb5056fbb8cb12a854116351"}, - {file = "ciso8601-2.3.2-cp311-cp311-macosx_11_0_x86_64.whl", hash = "sha256:2e740d2dcac81b5adb0cff641706d5a9e54ff4f3bb7e24437cdacdab3937c0a3"}, - {file = "ciso8601-2.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e883a08b294694313bd3a85c1a136f4326ca26050552742c489159c52e296060"}, - {file = "ciso8601-2.3.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6994b393b1e1147dbc2f13d6d508f6e95b96d7f770299a4af70b7c1d380242c1"}, - {file = "ciso8601-2.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2d31a04bea97f21b797fd414b465c00283b70d9523e8e51bc303bec04195a278"}, - {file = "ciso8601-2.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ce014a3559592320a2a7a7205257e57dd1277580038a30f153627c5d30ed7a07"}, - {file = "ciso8601-2.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:b069800ea5613eea7d323716133a74bd0fba4a781286167a20639b6628a7e068"}, - {file = "ciso8601-2.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:75870a1e496a17e9e8d2ac90125600e1bafe51679d2836b2f6cb66908fef7ad6"}, - {file = "ciso8601-2.3.2-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:c117c415c43aa3db68ee16a2446cb85c5e88459650421d773f6f6444ce5e5819"}, - {file = "ciso8601-2.3.2-cp312-cp312-macosx_11_0_x86_64.whl", hash = "sha256:ce5f76297b6138dc5c085d4c5a0a631afded99f250233fe583dc365f67fe8a8d"}, - {file = "ciso8601-2.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8e3205e4cfd63100f454ea67100c7c6123af32da0022bdc6e81058e95476a8ad"}, - {file = "ciso8601-2.3.2-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5308a14ac72898f91332ccfded2f18a6c558ccd184ccff84c4fb36c7e4c2a0e6"}, - {file = "ciso8601-2.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e825cb5ecd232775a94ef3c456ab19752ee8e66eaeb20562ea45472eaa8614ec"}, - {file = "ciso8601-2.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7a8f96f91bdeabee7ebca2c6e48185bea45e195f406ff748c87a3c9ecefb25cc"}, - {file = "ciso8601-2.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:3fe497819e50a245253a3b2d62ec4c68f8cf337d79dc18e2f3b0a74d24dc5e93"}, - {file = "ciso8601-2.3.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:361a49da3e53811ddc371ff2183d32ee673321899e4653c4d55ed06d0a81ef3d"}, - {file = "ciso8601-2.3.2-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:af26301e0e0cfc6cda225fd2a8b1888bf3828a7d24756774325bda7d29ab2468"}, - {file = "ciso8601-2.3.2-cp38-cp38-macosx_11_0_x86_64.whl", hash = "sha256:af399c2671dfe8fead4f34908a6e6ef3689db9606f2028269b578afd2326b96e"}, - {file = "ciso8601-2.3.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fa8978a69a6061380b352442160d468915d102c18b0b805a950311e6e0f3b821"}, - {file = "ciso8601-2.3.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:347db58040ad1cb3d2175f5699f0fb1abcb9e894ad744e3460b01bd101bb78a1"}, - {file = "ciso8601-2.3.2-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:d7860ad2b52007becfd604cfe596f0b7ffa8ffe4f7336b58ef1a2234dc53fa10"}, - {file = "ciso8601-2.3.2-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:91dab638ffaff1da12e0a6de4cfca520430426a1c0eaba5841b1311f45516d49"}, - {file = "ciso8601-2.3.2-cp38-cp38-win_amd64.whl", hash = "sha256:2e9a072465ecdbaa3bd2b17e26cc7a0376f9729021c8000656dd97a9343f8723"}, - {file = "ciso8601-2.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0fbbe659093d4aef1e66de0ee9a10487439527be4b2f6a6710960f98a41e2cc5"}, - {file = "ciso8601-2.3.2-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:8ccb16db7ca83cc39df3c73285e9ab4920a90f0dbef566f60f0c6cca44becaba"}, - {file = "ciso8601-2.3.2-cp39-cp39-macosx_11_0_x86_64.whl", hash = "sha256:dac06a1bd3c12ab699c29024c5f052e7016cb904e085a5e2b26e6b92fd2dd1dc"}, - {file = "ciso8601-2.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a323aa0143ad8e99d7a0b0ac3005419c505e073e6f850f0443b5994b31a52d14"}, - {file = "ciso8601-2.3.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5e9290e7e1b1c3a6df3967e3f1b22c334c980e841f5a1967ab6ef92b30a540d8"}, - {file = "ciso8601-2.3.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fc2a6bb31030b875c7706554b99e1d724250e0fc8160aa2f3ae32520b8dccbc5"}, - {file = "ciso8601-2.3.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:69e137cf862c724a9477b62d89fb8190f141ed6d036f6c4cf824be6d9a7b819e"}, - {file = "ciso8601-2.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:6591d8f191b0a12fa5ac53e1bc0e799f6f2068d0fa5684815706c59a4831f412"}, - {file = "ciso8601-2.3.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ecc2f7090e7b8427288b9528fa9571682426f2c7d45d39cf940321192d8796c8"}, - {file = "ciso8601-2.3.2-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c585a05d745c36f974030d1831ed899f8b00afd760f6eff6b8de7eef72cb1336"}, - {file = "ciso8601-2.3.2-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fbbe0af7ef440d679ce546f926fc441e31025c6a96c1bb54087df0e5e6c8e021"}, - {file = "ciso8601-2.3.2-pp38-pypy38_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:69136ef63e7d5178727f358a9cfe4dfda52f132eafcddfa7e6d5933ee1d73b7a"}, - {file = "ciso8601-2.3.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ff397592a0eadd5e0aec395a285751707c655439abb874ad93e34d04d925ec8d"}, - {file = "ciso8601-2.3.2-pp39-pypy39_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7eb6c8756806f4b8320fe57e3b048dafc54e99af7586160ff9318f35fc521268"}, - {file = "ciso8601-2.3.2.tar.gz", hash = "sha256:ec1616969aa46c51310b196022e5d3926f8d3fa52b80ec17f6b4133623bd5434"}, -] - -[[package]] -name = "click" -version = "8.3.0" -description = "Composable command line interface toolkit" -optional = false -python-versions = ">=3.10" -groups = ["main"] -files = [ - {file = "click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc"}, - {file = "click-8.3.0.tar.gz", hash = "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4"}, -] - -[package.dependencies] -colorama = {version = "*", markers = "platform_system == \"Windows\""} - -[[package]] -name = "colorama" -version = "0.4.6" -description = "Cross-platform colored terminal text." -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" -groups = ["main", "dev"] -files = [ - {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, - {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, -] -markers = {main = "platform_system == \"Windows\""} - -[[package]] -name = "coverage" -version = "7.10.0" -description = "Code coverage measurement for Python" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "coverage-7.10.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cbd823f7ea5286c26406ad9e54268544d82f3d1cadb6d4f3b85e9877f0cab1ef"}, - {file = "coverage-7.10.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ab3f7a5dbaab937df0b9e9e8ec6eab235ba9a6f29d71fd3b24335affaed886cc"}, - {file = "coverage-7.10.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8c63aaf850523d8cbe3f5f1a5c78f689b223797bef902635f2493ab43498f36c"}, - {file = "coverage-7.10.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4c3133ce3fa84023f7c6921c4dca711be0b658784c5a51a797168229eae26172"}, - {file = "coverage-7.10.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3747d1d0af85b17d3a156cd30e4bbacf893815e846dc6c07050e9769da2b138e"}, - {file = "coverage-7.10.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:241923b350437f6a7cb343d9df72998305ef940c3c40009f06e05029a047677c"}, - {file = "coverage-7.10.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:13e82e499309307104d58ac66f9eed237f7aaceab4325416645be34064d9a2be"}, - {file = "coverage-7.10.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:bf73cdde4f6c9cd4457b00bf1696236796ac3a241f859a55e0f84a4c58326a7f"}, - {file = "coverage-7.10.0-cp310-cp310-win32.whl", hash = "sha256:2396e13275b37870a3345f58bce8b15a7e0a985771d13a4b16ce9129954e07d6"}, - {file = "coverage-7.10.0-cp310-cp310-win_amd64.whl", hash = "sha256:9d45c7c71fb3d2da92ab893602e3f28f2d1560cec765a27e1824a6e0f7e92cfd"}, - {file = "coverage-7.10.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4abc01843581a6f9dd72d4d15761861190973a2305416639435ef509288f7a04"}, - {file = "coverage-7.10.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a2093297773111d7d748fe4a99b68747e57994531fb5c57bbe439af17c11c169"}, - {file = "coverage-7.10.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:58240e27815bf105bd975c2fd42e700839f93d5aad034ef976411193ca32dbfd"}, - {file = "coverage-7.10.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d019eac999b40ad48521ea057958b07a9f549c0c6d257a20e5c7c4ba91af8d1c"}, - {file = "coverage-7.10.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:35e0a1f5454bc80faf4ceab10d1d48f025f92046c9c0f3bec2e1a9dda55137f8"}, - {file = "coverage-7.10.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a93dd7759c416dd1cc754123b926d065055cb9a33b6699e64a1e5bdfae1ff459"}, - {file = "coverage-7.10.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7b3d737266048368a6ffd68f1ecd662c54de56535c82eb8f98a55ac216a72cbd"}, - {file = "coverage-7.10.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:93227c2707cb0effd9163cd0d8f0d9ab628982f7a3e915d6d64c7107867b9a07"}, - {file = "coverage-7.10.0-cp311-cp311-win32.whl", hash = "sha256:69270af3014ab3058ad6108c6d0e218166f568b5a7a070dc3d62c0a63aca1c4d"}, - {file = "coverage-7.10.0-cp311-cp311-win_amd64.whl", hash = "sha256:43c16bbb661a7b4dafac0ab69e44d6dbcc6a64c4d93aefd89edc6f8911b6ab4a"}, - {file = "coverage-7.10.0-cp311-cp311-win_arm64.whl", hash = "sha256:14e7c23fcb74ed808efb4eb48fcd25a759f0e20f685f83266d1df174860e4733"}, - {file = "coverage-7.10.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a2adcfdaf3b4d69b0c64ad024fe9dd6996782b52790fb6033d90f36f39e287df"}, - {file = "coverage-7.10.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d7b27c2c0840e8eeff3f1963782bd9d3bc767488d2e67a31de18d724327f9f6"}, - {file = "coverage-7.10.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0ed50429786e935517570b08576a661fd79032e6060985ab492b9d39ba8e66ee"}, - {file = "coverage-7.10.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7171c139ab6571d70460ecf788b1dcaf376bfc75a42e1946b8c031d062bbbad4"}, - {file = "coverage-7.10.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a726aac7e6e406e403cdee4c443a13aed3ea3d67d856414c5beacac2e70c04e"}, - {file = "coverage-7.10.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2886257481a14e953e96861a00c0fe7151117a523f0470a51e392f00640bba03"}, - {file = "coverage-7.10.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:536578b79521e59c385a2e0a14a5dc2a8edd58761a966d79368413e339fc9535"}, - {file = "coverage-7.10.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77fae95558f7804a9ceefabf3c38ad41af1da92b39781b87197c6440dcaaa967"}, - {file = "coverage-7.10.0-cp312-cp312-win32.whl", hash = "sha256:97803e14736493eb029558e1502fe507bd6a08af277a5c8eeccf05c3e970cb84"}, - {file = "coverage-7.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:4c73ab554e54ffd38d114d6bc4a7115fb0c840cf6d8622211bee3da26e4bd25d"}, - {file = "coverage-7.10.0-cp312-cp312-win_arm64.whl", hash = "sha256:3ae95d5a9aedab853641026b71b2ddd01983a0a7e9bf870a20ef3c8f5d904699"}, - {file = "coverage-7.10.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d883fee92b9245c0120fa25b5d36de71ccd4cfc29735906a448271e935d8d86d"}, - {file = "coverage-7.10.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c87e59e88268d30e33d3665ede4fbb77b513981a2df0059e7c106ca3de537586"}, - {file = "coverage-7.10.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f669d969f669a11d6ceee0b733e491d9a50573eb92a71ffab13b15f3aa2665d4"}, - {file = "coverage-7.10.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9582bd6c6771300a847d328c1c4204e751dbc339a9e249eecdc48cada41f72e6"}, - {file = "coverage-7.10.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:91f97e9637dc7977842776fdb7ad142075d6fa40bc1b91cb73685265e0d31d32"}, - {file = "coverage-7.10.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ae4fa92b6601a62367c6c9967ad32ad4e28a89af54b6bb37d740946b0e0534dd"}, - {file = "coverage-7.10.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:3a5cc8b97473e7b3623dd17a42d2194a2b49de8afecf8d7d03c8987237a9552c"}, - {file = "coverage-7.10.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dc1cbb7f623250e047c32bd7aa1bb62ebc62608d5004d74df095e1059141ac88"}, - {file = "coverage-7.10.0-cp313-cp313-win32.whl", hash = "sha256:1380cc5666d778e77f1587cd88cc317158111f44d54c0dd3975f0936993284e0"}, - {file = "coverage-7.10.0-cp313-cp313-win_amd64.whl", hash = "sha256:bf03cf176af098ee578b754a03add4690b82bdfe070adfb5d192d0b1cd15cf82"}, - {file = "coverage-7.10.0-cp313-cp313-win_arm64.whl", hash = "sha256:8041c78cd145088116db2329b2fb6e89dc338116c962fbe654b7e9f5d72ab957"}, - {file = "coverage-7.10.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:37cc2c06052771f48651160c080a86431884db9cd62ba622cab71049b90a95b3"}, - {file = "coverage-7.10.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:91f37270b16178b05fa107d85713d29bf21606e37b652d38646eef5f2dfbd458"}, - {file = "coverage-7.10.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f9b0b0168864d09bcb9a3837548f75121645c4cfd0efce0eb994c221955c5b10"}, - {file = "coverage-7.10.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:df0be435d3b616e7d3ee3f9ebbc0d784a213986fe5dff9c6f1042ee7cfd30157"}, - {file = "coverage-7.10.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:35e9aba1c4434b837b1d567a533feba5ce205e8e91179c97974b28a14c23d3a0"}, - {file = "coverage-7.10.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a0b0c481e74dfad631bdc2c883e57d8b058e5c90ba8ef087600995daf7bbec18"}, - {file = "coverage-7.10.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:8aec1b7c8922808a433c13cd44ace6fceac0609f4587773f6c8217a06102674b"}, - {file = "coverage-7.10.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:04ec59ceb3a594af0927f2e0d810e1221212abd9a2e6b5b917769ff48760b460"}, - {file = "coverage-7.10.0-cp313-cp313t-win32.whl", hash = "sha256:b6871e62d29646eb9b3f5f92def59e7575daea1587db21f99e2b19561187abda"}, - {file = "coverage-7.10.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ff99cff2be44f78920b76803f782e91ffb46ccc7fa89eccccc0da3ca94285b64"}, - {file = "coverage-7.10.0-cp313-cp313t-win_arm64.whl", hash = "sha256:3246b63501348fe47299d12c47a27cfc221cfbffa1c2d857bcc8151323a4ae4f"}, - {file = "coverage-7.10.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:1f628d91f941a375b4503cb486148dbeeffb48e17bc080e0f0adfee729361574"}, - {file = "coverage-7.10.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3a0e101d5af952d233557e445f42ebace20b06b4ceb615581595ced5386caa78"}, - {file = "coverage-7.10.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ec4c1abbcc53f9f650acb14ea71725d88246a9e14ed42f8dd1b4e1b694e9d842"}, - {file = "coverage-7.10.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9c95f3a7f041b4cc68a8e3fecfa6366170c13ac773841049f1cd19c8650094e0"}, - {file = "coverage-7.10.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a2cd597b69c16d24e310611f2ed6fcfb8f09429316038c03a57e7b4f5345244"}, - {file = "coverage-7.10.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5e18591906a40c2b3609196c9879136aa4a47c5405052ca6b065ab10cb0b71d0"}, - {file = "coverage-7.10.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:485c55744252ed3f300cc1a0f5f365e684a0f2651a7aed301f7a67125906b80e"}, - {file = "coverage-7.10.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4dabea1516e5b0e9577282b149c8015e4dceeb606da66fb8d9d75932d5799bf5"}, - {file = "coverage-7.10.0-cp314-cp314-win32.whl", hash = "sha256:ac455f0537af22333fdc23b824cff81110dff2d47300bb2490f947b7c9a16017"}, - {file = "coverage-7.10.0-cp314-cp314-win_amd64.whl", hash = "sha256:b3c94b532f52f95f36fbfde3e178510a4d04eea640b484b2fe8f1491338dc653"}, - {file = "coverage-7.10.0-cp314-cp314-win_arm64.whl", hash = "sha256:2f807f2c3a9da99c80dfa73f09ef5fc3bd21e70c73ba1c538f23396a3a772252"}, - {file = "coverage-7.10.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:0a889ef25215990f65073c32cadf37483363a6a22914186dedc15a6b1a597d50"}, - {file = "coverage-7.10.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:39c638ecf3123805bacbf71aff8091e93af490c676fca10ab4e442375076e483"}, - {file = "coverage-7.10.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f2f2c0df0cbcf7dffa14f88a99c530cdef3f4fcfe935fa4f95d28be2e7ebc570"}, - {file = "coverage-7.10.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:048d19a5d641a2296745ab59f34a27b89a08c48d6d432685f22aac0ec1ea447f"}, - {file = "coverage-7.10.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1209b65d302d7a762004be37ab9396cbd8c99525ed572bdf455477e3a9449e06"}, - {file = "coverage-7.10.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e44aa79a36a7a0aec6ea109905a4a7c28552d90f34e5941b36217ae9556657d5"}, - {file = "coverage-7.10.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:96124be864b89395770c9a14652afcddbcdafb99466f53a9281c51d1466fb741"}, - {file = "coverage-7.10.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:aad222e841f94b42bd1d6be71737fade66943853f0807cf87887c88f70883a2a"}, - {file = "coverage-7.10.0-cp314-cp314t-win32.whl", hash = "sha256:0eed5354d28caa5c8ad60e07e938f253e4b2810ea7dd56784339b6ce98b6f104"}, - {file = "coverage-7.10.0-cp314-cp314t-win_amd64.whl", hash = "sha256:3da35f9980058acb960b2644527cc3911f1e00f94d309d704b309fa984029109"}, - {file = "coverage-7.10.0-cp314-cp314t-win_arm64.whl", hash = "sha256:cb9e138dfa8a4b5c52c92a537651e2ca4f2ca48d8cb1bc01a2cbe7a5773c2426"}, - {file = "coverage-7.10.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:cf283ec9c6878826291b17442eb5c32d3d252dc77d25e082b460b2d2ea67ba3c"}, - {file = "coverage-7.10.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8a83488c9fc6fff487f2ab551f9b64c70672357b8949f0951b0cd778b3ed8165"}, - {file = "coverage-7.10.0-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b86df3a7494d12338c11e59f210a0498d6109bbc3a4037f44de517ebb30a9c6b"}, - {file = "coverage-7.10.0-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6de9b460809e5e4787b742e786a36ae2346a53982e2be317cdcb7a33c56412fb"}, - {file = "coverage-7.10.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de5ef8a5954d63fa26a6aaa4600e48f885ce70fe495e8fce2c43aa9241fc9434"}, - {file = "coverage-7.10.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:f178fe5e96f1e057527d5d0b20ab76b8616e0410169c33716cc226118eaf2c4f"}, - {file = "coverage-7.10.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:4a38c42f0182a012fa9ec25bc6057e51114c1ba125be304f3f776d6d283cb303"}, - {file = "coverage-7.10.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:bf09beb5c1785cb36aad042455c0afab561399b74bb8cdaf6e82b7d77322df99"}, - {file = "coverage-7.10.0-cp39-cp39-win32.whl", hash = "sha256:cb8dfbb5d3016cb8d1940444c0c69b40cdc6c8bde724b07716ee5ea47b5273c6"}, - {file = "coverage-7.10.0-cp39-cp39-win_amd64.whl", hash = "sha256:58ff22653cd93d563110d1ff2aef958f5f21be9e917762f8124d0e36f80f172a"}, - {file = "coverage-7.10.0-py3-none-any.whl", hash = "sha256:310a786330bb0463775c21d68e26e79973839b66d29e065c5787122b8dd4489f"}, - {file = "coverage-7.10.0.tar.gz", hash = "sha256:2768885aef484b5dcde56262cbdfba559b770bfc46994fe9485dc3614c7a5867"}, -] - -[package.extras] -toml = ["tomli ; python_full_version <= \"3.11.0a6\""] - -[[package]] -name = "cronsim" -version = "2.6" -description = "Cron expression parser and evaluator" -optional = false -python-versions = ">=3.8" -groups = ["main", "dev"] -files = [ - {file = "cronsim-2.6-py3-none-any.whl", hash = "sha256:5e153ff8ed64da7ee8d5caac470dbeda8024ab052c3010b1be149772b4801835"}, - {file = "cronsim-2.6.tar.gz", hash = "sha256:5aab98716ef90ab5ac6be294b2c3965dbf76dc869f048846a0af74ebb506c10d"}, -] - -[[package]] -name = "cryptography" -version = "45.0.3" -description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." -optional = false -python-versions = "!=3.9.0,!=3.9.1,>=3.7" -groups = ["main", "dev"] -files = [ - {file = "cryptography-45.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:7573d9eebaeceeb55285205dbbb8753ac1e962af3d9640791d12b36864065e71"}, - {file = "cryptography-45.0.3-cp311-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d377dde61c5d67eb4311eace661c3efda46c62113ff56bf05e2d679e02aebb5b"}, - {file = "cryptography-45.0.3-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fae1e637f527750811588e4582988932c222f8251f7b7ea93739acb624e1487f"}, - {file = "cryptography-45.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ca932e11218bcc9ef812aa497cdf669484870ecbcf2d99b765d6c27a86000942"}, - {file = "cryptography-45.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:af3f92b1dc25621f5fad065288a44ac790c5798e986a34d393ab27d2b27fcff9"}, - {file = "cryptography-45.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2f8f8f0b73b885ddd7f3d8c2b2234a7d3ba49002b0223f58cfde1bedd9563c56"}, - {file = "cryptography-45.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:9cc80ce69032ffa528b5e16d217fa4d8d4bb7d6ba8659c1b4d74a1b0f4235fca"}, - {file = "cryptography-45.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c824c9281cb628015bfc3c59335163d4ca0540d49de4582d6c2637312907e4b1"}, - {file = "cryptography-45.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:5833bb4355cb377ebd880457663a972cd044e7f49585aee39245c0d592904578"}, - {file = "cryptography-45.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9bb5bf55dcb69f7067d80354d0a348368da907345a2c448b0babc4215ccd3497"}, - {file = "cryptography-45.0.3-cp311-abi3-win32.whl", hash = "sha256:3ad69eeb92a9de9421e1f6685e85a10fbcfb75c833b42cc9bc2ba9fb00da4710"}, - {file = "cryptography-45.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:97787952246a77d77934d41b62fb1b6f3581d83f71b44796a4158d93b8f5c490"}, - {file = "cryptography-45.0.3-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:c92519d242703b675ccefd0f0562eb45e74d438e001f8ab52d628e885751fb06"}, - {file = "cryptography-45.0.3-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5edcb90da1843df85292ef3a313513766a78fbbb83f584a5a58fb001a5a9d57"}, - {file = "cryptography-45.0.3-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:38deed72285c7ed699864f964a3f4cf11ab3fb38e8d39cfcd96710cd2b5bb716"}, - {file = "cryptography-45.0.3-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5555365a50efe1f486eed6ac7062c33b97ccef409f5970a0b6f205a7cfab59c8"}, - {file = "cryptography-45.0.3-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9e4253ed8f5948a3589b3caee7ad9a5bf218ffd16869c516535325fece163dcc"}, - {file = "cryptography-45.0.3-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:cfd84777b4b6684955ce86156cfb5e08d75e80dc2585e10d69e47f014f0a5342"}, - {file = "cryptography-45.0.3-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:a2b56de3417fd5f48773ad8e91abaa700b678dc7fe1e0c757e1ae340779acf7b"}, - {file = "cryptography-45.0.3-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:57a6500d459e8035e813bd8b51b671977fb149a8c95ed814989da682314d0782"}, - {file = "cryptography-45.0.3-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f22af3c78abfbc7cbcdf2c55d23c3e022e1a462ee2481011d518c7fb9c9f3d65"}, - {file = "cryptography-45.0.3-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:232954730c362638544758a8160c4ee1b832dc011d2c41a306ad8f7cccc5bb0b"}, - {file = "cryptography-45.0.3-cp37-abi3-win32.whl", hash = "sha256:cb6ab89421bc90e0422aca911c69044c2912fc3debb19bb3c1bfe28ee3dff6ab"}, - {file = "cryptography-45.0.3-cp37-abi3-win_amd64.whl", hash = "sha256:d54ae41e6bd70ea23707843021c778f151ca258081586f0cfa31d936ae43d1b2"}, - {file = "cryptography-45.0.3-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:ed43d396f42028c1f47b5fec012e9e12631266e3825e95c00e3cf94d472dac49"}, - {file = "cryptography-45.0.3-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:fed5aaca1750e46db870874c9c273cd5182a9e9deb16f06f7bdffdb5c2bde4b9"}, - {file = "cryptography-45.0.3-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:00094838ecc7c6594171e8c8a9166124c1197b074cfca23645cee573910d76bc"}, - {file = "cryptography-45.0.3-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:92d5f428c1a0439b2040435a1d6bc1b26ebf0af88b093c3628913dd464d13fa1"}, - {file = "cryptography-45.0.3-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:ec64ee375b5aaa354b2b273c921144a660a511f9df8785e6d1c942967106438e"}, - {file = "cryptography-45.0.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:71320fbefd05454ef2d457c481ba9a5b0e540f3753354fff6f780927c25d19b0"}, - {file = "cryptography-45.0.3-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:edd6d51869beb7f0d472e902ef231a9b7689508e83880ea16ca3311a00bf5ce7"}, - {file = "cryptography-45.0.3-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:555e5e2d3a53b4fabeca32835878b2818b3f23966a4efb0d566689777c5a12c8"}, - {file = "cryptography-45.0.3-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:25286aacb947286620a31f78f2ed1a32cded7be5d8b729ba3fb2c988457639e4"}, - {file = "cryptography-45.0.3-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:050ce5209d5072472971e6efbfc8ec5a8f9a841de5a4db0ebd9c2e392cb81972"}, - {file = "cryptography-45.0.3-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:dc10ec1e9f21f33420cc05214989544727e776286c1c16697178978327b95c9c"}, - {file = "cryptography-45.0.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:9eda14f049d7f09c2e8fb411dda17dd6b16a3c76a1de5e249188a32aeb92de19"}, - {file = "cryptography-45.0.3.tar.gz", hash = "sha256:ec21313dd335c51d7877baf2972569f40a4291b76a0ce51391523ae358d05899"}, -] - -[package.dependencies] -cffi = {version = ">=1.14", markers = "platform_python_implementation != \"PyPy\""} - -[package.extras] -docs = ["sphinx (>=5.3.0)", "sphinx-inline-tabs ; python_full_version >= \"3.8.0\"", "sphinx-rtd-theme (>=3.0.0) ; python_full_version >= \"3.8.0\""] -docstest = ["pyenchant (>=3)", "readme-renderer (>=30.0)", "sphinxcontrib-spelling (>=7.3.1)"] -nox = ["nox (>=2024.4.15)", "nox[uv] (>=2024.3.2) ; python_full_version >= \"3.8.0\""] -pep8test = ["check-sdist ; python_full_version >= \"3.8.0\"", "click (>=8.0.1)", "mypy (>=1.4)", "ruff (>=0.3.6)"] -sdist = ["build (>=1.0.0)"] -ssh = ["bcrypt (>=3.1.5)"] -test = ["certifi (>=2024)", "cryptography-vectors (==45.0.3)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"] -test-randomorder = ["pytest-randomly"] - -[[package]] -name = "dbus-fast" -version = "2.44.3" -description = "A faster version of dbus-next" -optional = false -python-versions = ">=3.9" -groups = ["main", "dev"] -markers = "platform_system == \"Linux\"" -files = [ - {file = "dbus_fast-2.44.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:644880d8db53a6d92e88015f6ac6e0d9a5c1bfdacbc5356de816212cca33c629"}, - {file = "dbus_fast-2.44.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be7e2e39bc6a5e0fe758d9d7abb19f91a7540e3b45124764f318147b74c9b2e6"}, - {file = "dbus_fast-2.44.3-cp310-cp310-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:049236a2cacddc6f1f8583371d8fa54d0a01e2081c8f1311a6ad71b27b1512aa"}, - {file = "dbus_fast-2.44.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:69bb4820259e0969ae79585ffc98409bf781589c138a90d4799d5751c83ed04a"}, - {file = "dbus_fast-2.44.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:86350a3fc4304f50c56730b64bd3d709458648fa1b23f8e9449dfcce206defe4"}, - {file = "dbus_fast-2.44.3-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:89c418c8f18fff8eb17143184d4e0f68216c4d702f16cba4323a6b6be6aaab2a"}, - {file = "dbus_fast-2.44.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c700cdb06e74a6c462d180eff146105fe08f0dc4a8f1f8ff93022175c8e6fe76"}, - {file = "dbus_fast-2.44.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9018568987b878e577bc3e692f2eef6b7a4482490a373ec00098578fa919076c"}, - {file = "dbus_fast-2.44.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3879fb6d6e9260b310fed33457835e11b83e96144bfcf2cbb9abcd3e740c2836"}, - {file = "dbus_fast-2.44.3-cp311-cp311-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:0c68f14d5a329bd494a2da561da961ddfb3f3351d41225dcf0e59106f32bf5d6"}, - {file = "dbus_fast-2.44.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e3f10ee6ba45f37d067775c0719d072bc4a7e0bdc9a0411f5c7c93af0bfd9958"}, - {file = "dbus_fast-2.44.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7bec6cb61d9ce56715410e17e6e6d935df6d39bc01e0aae691135229a0d69072"}, - {file = "dbus_fast-2.44.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94ae76e470c5cf6eb507e2a92e698a9183b3558e3a09efcb7fe2152b92dd300b"}, - {file = "dbus_fast-2.44.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3f1df8582723ee1b1689243663f4e93fc406f0966ff3e9c26a21cb498de3b9ca"}, - {file = "dbus_fast-2.44.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:861352c19f57087e9b2ff7e16a1bab0cfb2e7dc982ce0249aad2a36e1af8f110"}, - {file = "dbus_fast-2.44.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5aafa42df91e17023885c508539df2f6312abb9d050f56e39345175cef05bfbb"}, - {file = "dbus_fast-2.44.3-cp312-cp312-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:4e5c2515bdc159eaa9ac9e99115016af65261cb4d1d237162295966ad1d8cac0"}, - {file = "dbus_fast-2.44.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3dab3b4802e1c518b8f3d98bfefe1f696125c00016faf1b6f1fd5170efc06d7e"}, - {file = "dbus_fast-2.44.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:42842e8f396be5d938c60cb449600df811373efd57dc630bb40d6d36f4e710a4"}, - {file = "dbus_fast-2.44.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:93ea055c644bdfd7c70614f7c860db9f5234736a15992df9e4a723fa55ef7622"}, - {file = "dbus_fast-2.44.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c9764e4188e21ad4a9f65856f3adacfc83d583a950d4dabc5ec5856db387784b"}, - {file = "dbus_fast-2.44.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d967a751cc2dd530d5b756a22bf67a603ebeca13c6f72d8b1cb8575b872caa16"}, - {file = "dbus_fast-2.44.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da0910f813350b951efe4964a19d7f4aaf253b6c1021b0d68340160a990dc2fc"}, - {file = "dbus_fast-2.44.3-cp313-cp313-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:253ad2417b0651ba32325661bb559228ceaedea9fb75d238972087a5f66551fd"}, - {file = "dbus_fast-2.44.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ebb4c56bef8f69e4e2606eb29a5c137ba448cf7d6958f4f2fba263d74623bd06"}, - {file = "dbus_fast-2.44.3-cp313-cp313-manylinux_2_36_x86_64.whl", hash = "sha256:6e0a6a27a1f53b32259d0789bca6f53decd88dec52722cac9a93327f8b7670c3"}, - {file = "dbus_fast-2.44.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2a990390c5d019e8e4d41268a3ead0eb6e48e977173d7685b0f5b5b3d0695c2f"}, - {file = "dbus_fast-2.44.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:5aca3c940eddb99f19bd3f0c6c50cd566fd98396dd9516d35dbf12af25b7a2c6"}, - {file = "dbus_fast-2.44.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0046e74c25b79ffb6ea5b07f33b5da0bdc2a75ad6aede3f7836654485239121d"}, - {file = "dbus_fast-2.44.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:fce364e03b98a6acb4694f1c24b05bfc33d10045af1469378a25ffe4fa046f40"}, - {file = "dbus_fast-2.44.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fd955b153622df80cc420fe53c265cd43b7c559100a9e52c83ab0425bc083604"}, - {file = "dbus_fast-2.44.3-cp39-cp39-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:6b00eef5437d27917d55d04b3edea60c12a3e2a94fd82e81b396311ff7bb1c88"}, - {file = "dbus_fast-2.44.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8468df924e5a3870b1e23377ea573e4b43a22ab1730084eab1b838fd18c9a589"}, - {file = "dbus_fast-2.44.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:e4dd64813f175403fac894b5f6f6ff028127ea3c6ca8eda41770f39ba9815572"}, - {file = "dbus_fast-2.44.3-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:f36183af2c6d3a00bd555e7d871d8c3214bb91c42439428dfcf7cc664081182a"}, - {file = "dbus_fast-2.44.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:0bb0dfc386ae246def7ee64ce058d099b1bc8c35cd5325e6cd80f57b8115fec7"}, - {file = "dbus_fast-2.44.3-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1b853f75e10b34bb2ba76706d10fdab5ba0cef9ebc1faec1969c84e5b155b3b8"}, - {file = "dbus_fast-2.44.3-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:de00d3d7731b2f915ac3f4ed2119442f3054efeb84c5bdd21717b92241b68f82"}, - {file = "dbus_fast-2.44.3-pp310-pypy310_pp73-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:92377b4f274e3e70b9fcffd9a0e37a9808748f8df4b9d510a81f36b9e8c0f42f"}, - {file = "dbus_fast-2.44.3-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux_2_5_x86_64.manylinux1_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b6ee1dc3e05b47e89b6be5b45d345b57a85b822f3a55299b569766384e74d0f9"}, - {file = "dbus_fast-2.44.3-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:780c960c546fe509dd2b7a8c7f5eeef3a88f99cdea77225a400a47411b9aea17"}, - {file = "dbus_fast-2.44.3-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4d4200a3c33684df692a545b16f72f52e70ecd68e8226273e828fc12fbcdde88"}, - {file = "dbus_fast-2.44.3-pp39-pypy39_pp73-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:e1643f9d47450e29fd14e62c583c71f332337dc157e9536692e5c0cd5e70ec53"}, - {file = "dbus_fast-2.44.3-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux_2_5_x86_64.manylinux1_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e1f3c673c40a3f82388b377d492aa31f9ba66c20ba1183f1bcd8f9b64eda599c"}, - {file = "dbus_fast-2.44.3.tar.gz", hash = "sha256:962b36abbe885159e31135c57a7d9659997c61a13d55ecb070a61dc502dbd87e"}, -] - -[[package]] -name = "dill" -version = "0.4.0" -description = "serialize all of Python" -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "dill-0.4.0-py3-none-any.whl", hash = "sha256:44f54bf6412c2c8464c14e8243eb163690a9800dbe2c367330883b19c7561049"}, - {file = "dill-0.4.0.tar.gz", hash = "sha256:0633f1d2df477324f53a895b02c901fb961bdbf65a17122586ea7019292cbcf0"}, -] - -[package.extras] -graph = ["objgraph (>=1.7.2)"] -profile = ["gprof2dot (>=2022.7.29)"] - -[[package]] -name = "distlib" -version = "0.4.0" -description = "Distribution utilities" -optional = false -python-versions = "*" -groups = ["dev"] -files = [ - {file = "distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16"}, - {file = "distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d"}, -] - -[[package]] -name = "envs" -version = "1.4" -description = "Easy access of environment variables from Python with support for strings, booleans, list, tuples, and dicts." -optional = false -python-versions = ">=3.6,<4.0" -groups = ["main", "dev"] -files = [ - {file = "envs-1.4-py3-none-any.whl", hash = "sha256:4a1fcf85e4d4443e77c348ff7cdd3bfc4c0178b181d447057de342e4172e5ed1"}, - {file = "envs-1.4.tar.gz", hash = "sha256:9d8435c6985d1cdd68299e04c58e2bdb8ae6cf66b2596a8079e6f9a93f2a0398"}, -] - -[package.extras] -cli = ["Jinja2[cli] (>=3.0.3,<4.0.0)", "click[cli] (>=8.0.3,<9.0.0)", "terminaltables[cli] (>=3.1.10,<4.0.0)"] - -[[package]] -name = "execnet" -version = "2.1.1" -description = "execnet: rapid multi-Python deployment" -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "execnet-2.1.1-py3-none-any.whl", hash = "sha256:26dee51f1b80cebd6d0ca8e74dd8745419761d3bef34163928cbebbdc4749fdc"}, - {file = "execnet-2.1.1.tar.gz", hash = "sha256:5189b52c6121c24feae288166ab41b32549c7e2348652736540b9e6e7d4e72e3"}, -] - -[package.extras] -testing = ["hatch", "pre-commit", "pytest", "tox"] - -[[package]] -name = "filelock" -version = "3.19.1" -description = "A platform independent file lock." -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "filelock-3.19.1-py3-none-any.whl", hash = "sha256:d38e30481def20772f5baf097c122c3babc4fcdb7e14e57049eb9d88c6dc017d"}, - {file = "filelock-3.19.1.tar.gz", hash = "sha256:66eda1888b0171c998b35be2bcc0f6d75c388a7ce20c3f3f37aa8e96c2dddf58"}, -] - -[[package]] -name = "fnv-hash-fast" -version = "1.5.0" -description = "A fast version of fnv1a" -optional = false -python-versions = "<4.0,>=3.10" -groups = ["main", "dev"] -files = [ - {file = "fnv_hash_fast-1.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:979ff5af776ad9f544ee5fccfbfb54dcd98717f57ae0e0cfb8cfe562d0324885"}, - {file = "fnv_hash_fast-1.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bb45441577b638f65756a46d7432459460335008557f34959c216d6d1b3f09b2"}, - {file = "fnv_hash_fast-1.5.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3505142822a4c0eede472498f9528db5fc5a0ed08d3005dd8f1e0598b0a66f2b"}, - {file = "fnv_hash_fast-1.5.0-cp311-cp311-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:6232702a03e6f34d6596042d842af3443aa7072818e6c4a1f3bf084f1051067b"}, - {file = "fnv_hash_fast-1.5.0-cp311-cp311-manylinux_2_17_x86_64.manylinux_2_5_x86_64.manylinux1_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a1db24e0f3addd6d87ab64c341e15504585d81f71af4fb289d61a50e0d14f1ea"}, - {file = "fnv_hash_fast-1.5.0-cp311-cp311-manylinux_2_31_armv7l.manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ba29b624c6ab25801171e6c87ba28095a66230fd25af43cd16a9104813650d24"}, - {file = "fnv_hash_fast-1.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:49757db065cf8ef4e5de23139d023706ae58d85e0505d50eef380b486ccb4217"}, - {file = "fnv_hash_fast-1.5.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:9f091cfdd9b5891d80cd59951d1893e738bbf722ba021f0f1262c08f8388ac33"}, - {file = "fnv_hash_fast-1.5.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:b8d4abd9ff5d2cd34fa2c7b6d3847e9162cf65ec019f741807a489a04bf8c33b"}, - {file = "fnv_hash_fast-1.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d35ef4f1b0ac3c04c8673e311b55d436c87ff9bb972716fbdd8d4fd6865dddb9"}, - {file = "fnv_hash_fast-1.5.0-cp311-cp311-win32.whl", hash = "sha256:b374f16ab57e60bc08937d488b550b0ac83e97db45c9f6cec989cbcdee54c85e"}, - {file = "fnv_hash_fast-1.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:0851115a98cbdabaa291b2ee5dc35810d8d56a4769a0c32bd5eec8b5aa6ccb6b"}, - {file = "fnv_hash_fast-1.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ccf029f0f3eeca99c99a8fc7b7096f7af710e9791e208fcfa01d7ea55fb4999c"}, - {file = "fnv_hash_fast-1.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3a39408ac586ee9ccc24cf445576a37a51d1c483b76888f0d340bc19feec585c"}, - {file = "fnv_hash_fast-1.5.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a2f5ec4ab8d551e2e5a751b318b8829672d769f6f4b3e49719fd87e6577a839e"}, - {file = "fnv_hash_fast-1.5.0-cp312-cp312-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:ad864cbfec3aec3390a99e9ace7901cca98863f72627afb234f5cc23e8aa203e"}, - {file = "fnv_hash_fast-1.5.0-cp312-cp312-manylinux_2_17_x86_64.manylinux_2_5_x86_64.manylinux1_x86_64.manylinux2014_x86_64.whl", hash = "sha256:abd37c3f36c2bf73cfa0817d0259e7cfc62db2c325be15c7dca9037a76cfd646"}, - {file = "fnv_hash_fast-1.5.0-cp312-cp312-manylinux_2_31_armv7l.manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fd5c0f051598cb01b8e7a61cc9ca47a097c500a46e5abada8dcd549aee3b3a39"}, - {file = "fnv_hash_fast-1.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c3f765093a7f6e87c38c08c331959a87925d05813c91de5fff109ff498dd9b17"}, - {file = "fnv_hash_fast-1.5.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:14c5b6409749d79191e5857646dd1204b2111838e75fe220114ab3a952061766"}, - {file = "fnv_hash_fast-1.5.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:aa80edbbaded0cd7ef8655c3368c3b63c904824391c13eb0862c4588f516193d"}, - {file = "fnv_hash_fast-1.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4dadc880e49ef50fcd427abd7e47f52990045bf48945af29ba110b44e500f45c"}, - {file = "fnv_hash_fast-1.5.0-cp312-cp312-win32.whl", hash = "sha256:9ac99977bd88874a7a3b27e9a59ecf0aa3b30835d81ac3abc5144207a4d3dbfc"}, - {file = "fnv_hash_fast-1.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:d01f5c5c365109a786a09187ff129f79162c5b423ba9610640ecde692d4d866c"}, - {file = "fnv_hash_fast-1.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0294a449e672583589e8e5cce9d60dfc5e29db3fb05737ccae98deba28b7d77f"}, - {file = "fnv_hash_fast-1.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:643002874f4620c408fdf881041e7d8b23683e56b1d588604a3640758c4e6dfe"}, - {file = "fnv_hash_fast-1.5.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:13904ceb14e09c5d6092eca8f6e1a65ea8bb606328b4b86d055365f23657ca58"}, - {file = "fnv_hash_fast-1.5.0-cp313-cp313-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:5747cc25ee940eaa70c05d0b3d0a49808e952b7dd8388453980b94ea9e95e837"}, - {file = "fnv_hash_fast-1.5.0-cp313-cp313-manylinux_2_17_x86_64.manylinux_2_5_x86_64.manylinux1_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9640989256fcb9e95a383ebde372b79bb4b7e14d296e5242fb32c422a6d83480"}, - {file = "fnv_hash_fast-1.5.0-cp313-cp313-manylinux_2_31_armv7l.manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9e3b79e3fada2925810efd1605f265f0335cafe48f1389c96c51261b3e2e05ff"}, - {file = "fnv_hash_fast-1.5.0-cp313-cp313-manylinux_2_36_x86_64.whl", hash = "sha256:ccd18302d1a2d800f6403be7d8cb02293f2e39363bc64cd843ed040396d36f1a"}, - {file = "fnv_hash_fast-1.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:14c7672ae4cfaf8f88418dc23ef50977f4603c602932038ae52fae44b1b03aec"}, - {file = "fnv_hash_fast-1.5.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:90fff41560a95d5262f2237259a94d0c8c662e131b13540e9db51dbec1a14912"}, - {file = "fnv_hash_fast-1.5.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b9b52650bd9107cfe8a81087b6bd9fa995f0ba23dafa1a7cb343aed99c136062"}, - {file = "fnv_hash_fast-1.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4a4b3fa3e5e3273872d021bc2d6ef26db273bdd82a1bedd49b3f798dbcb34bba"}, - {file = "fnv_hash_fast-1.5.0-cp313-cp313-win32.whl", hash = "sha256:381175ad08ee8b0c69c14283a60a20d953c24bc19e2d80e5932eb590211c50dc"}, - {file = "fnv_hash_fast-1.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:db8e61e38d5eddf4a4115e82bbee35f0b1b1d5affe8736f78ffc833751746cf2"}, - {file = "fnv_hash_fast-1.5.0.tar.gz", hash = "sha256:c3f0d077a5e0eee6bc12938a6f560b6394b5736f3e30db83b2eca8e0fb948a74"}, -] - -[package.dependencies] -fnvhash = ">=0.1.0,<0.2.0" - -[[package]] -name = "fnvhash" -version = "0.1.0" -description = "Pure Python FNV hash implementation" -optional = false -python-versions = "*" -groups = ["main", "dev"] -files = [ - {file = "fnvhash-0.1.0.tar.gz", hash = "sha256:3e82d505054f9f3987b2b5b649f7e7b6f48349f6af8a1b8e4d66779699c85a8e"}, -] - -[[package]] -name = "freezegun" -version = "1.5.2" -description = "Let your Python tests travel through time" -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "freezegun-1.5.2-py3-none-any.whl", hash = "sha256:5aaf3ba229cda57afab5bd311f0108d86b6fb119ae89d2cd9c43ec8c1733c85b"}, - {file = "freezegun-1.5.2.tar.gz", hash = "sha256:a54ae1d2f9c02dbf42e02c18a3ab95ab4295818b549a34dac55592d72a905181"}, -] - -[package.dependencies] -python-dateutil = ">=2.7" - -[[package]] -name = "frozenlist" -version = "1.7.0" -description = "A list-like structure which implements collections.abc.MutableSequence" -optional = false -python-versions = ">=3.9" -groups = ["main", "dev"] -files = [ - {file = "frozenlist-1.7.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cc4df77d638aa2ed703b878dd093725b72a824c3c546c076e8fdf276f78ee84a"}, - {file = "frozenlist-1.7.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:716a9973a2cc963160394f701964fe25012600f3d311f60c790400b00e568b61"}, - {file = "frozenlist-1.7.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a0fd1bad056a3600047fb9462cff4c5322cebc59ebf5d0a3725e0ee78955001d"}, - {file = "frozenlist-1.7.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3789ebc19cb811163e70fe2bd354cea097254ce6e707ae42e56f45e31e96cb8e"}, - {file = "frozenlist-1.7.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:af369aa35ee34f132fcfad5be45fbfcde0e3a5f6a1ec0712857f286b7d20cca9"}, - {file = "frozenlist-1.7.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ac64b6478722eeb7a3313d494f8342ef3478dff539d17002f849101b212ef97c"}, - {file = "frozenlist-1.7.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f89f65d85774f1797239693cef07ad4c97fdd0639544bad9ac4b869782eb1981"}, - {file = "frozenlist-1.7.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1073557c941395fdfcfac13eb2456cb8aad89f9de27bae29fabca8e563b12615"}, - {file = "frozenlist-1.7.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ed8d2fa095aae4bdc7fdd80351009a48d286635edffee66bf865e37a9125c50"}, - {file = "frozenlist-1.7.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:24c34bea555fe42d9f928ba0a740c553088500377448febecaa82cc3e88aa1fa"}, - {file = "frozenlist-1.7.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:69cac419ac6a6baad202c85aaf467b65ac860ac2e7f2ac1686dc40dbb52f6577"}, - {file = "frozenlist-1.7.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:960d67d0611f4c87da7e2ae2eacf7ea81a5be967861e0c63cf205215afbfac59"}, - {file = "frozenlist-1.7.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:41be2964bd4b15bf575e5daee5a5ce7ed3115320fb3c2b71fca05582ffa4dc9e"}, - {file = "frozenlist-1.7.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:46d84d49e00c9429238a7ce02dc0be8f6d7cd0cd405abd1bebdc991bf27c15bd"}, - {file = "frozenlist-1.7.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:15900082e886edb37480335d9d518cec978afc69ccbc30bd18610b7c1b22a718"}, - {file = "frozenlist-1.7.0-cp310-cp310-win32.whl", hash = "sha256:400ddd24ab4e55014bba442d917203c73b2846391dd42ca5e38ff52bb18c3c5e"}, - {file = "frozenlist-1.7.0-cp310-cp310-win_amd64.whl", hash = "sha256:6eb93efb8101ef39d32d50bce242c84bcbddb4f7e9febfa7b524532a239b4464"}, - {file = "frozenlist-1.7.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:aa51e147a66b2d74de1e6e2cf5921890de6b0f4820b257465101d7f37b49fb5a"}, - {file = "frozenlist-1.7.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9b35db7ce1cd71d36ba24f80f0c9e7cff73a28d7a74e91fe83e23d27c7828750"}, - {file = "frozenlist-1.7.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:34a69a85e34ff37791e94542065c8416c1afbf820b68f720452f636d5fb990cd"}, - {file = "frozenlist-1.7.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4a646531fa8d82c87fe4bb2e596f23173caec9185bfbca5d583b4ccfb95183e2"}, - {file = "frozenlist-1.7.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:79b2ffbba483f4ed36a0f236ccb85fbb16e670c9238313709638167670ba235f"}, - {file = "frozenlist-1.7.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a26f205c9ca5829cbf82bb2a84b5c36f7184c4316617d7ef1b271a56720d6b30"}, - {file = "frozenlist-1.7.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bcacfad3185a623fa11ea0e0634aac7b691aa925d50a440f39b458e41c561d98"}, - {file = "frozenlist-1.7.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:72c1b0fe8fe451b34f12dce46445ddf14bd2a5bcad7e324987194dc8e3a74c86"}, - {file = "frozenlist-1.7.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:61d1a5baeaac6c0798ff6edfaeaa00e0e412d49946c53fae8d4b8e8b3566c4ae"}, - {file = "frozenlist-1.7.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7edf5c043c062462f09b6820de9854bf28cc6cc5b6714b383149745e287181a8"}, - {file = "frozenlist-1.7.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:d50ac7627b3a1bd2dcef6f9da89a772694ec04d9a61b66cf87f7d9446b4a0c31"}, - {file = "frozenlist-1.7.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:ce48b2fece5aeb45265bb7a58259f45027db0abff478e3077e12b05b17fb9da7"}, - {file = "frozenlist-1.7.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:fe2365ae915a1fafd982c146754e1de6ab3478def8a59c86e1f7242d794f97d5"}, - {file = "frozenlist-1.7.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:45a6f2fdbd10e074e8814eb98b05292f27bad7d1883afbe009d96abdcf3bc898"}, - {file = "frozenlist-1.7.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:21884e23cffabb157a9dd7e353779077bf5b8f9a58e9b262c6caad2ef5f80a56"}, - {file = "frozenlist-1.7.0-cp311-cp311-win32.whl", hash = "sha256:284d233a8953d7b24f9159b8a3496fc1ddc00f4db99c324bd5fb5f22d8698ea7"}, - {file = "frozenlist-1.7.0-cp311-cp311-win_amd64.whl", hash = "sha256:387cbfdcde2f2353f19c2f66bbb52406d06ed77519ac7ee21be0232147c2592d"}, - {file = "frozenlist-1.7.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3dbf9952c4bb0e90e98aec1bd992b3318685005702656bc6f67c1a32b76787f2"}, - {file = "frozenlist-1.7.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1f5906d3359300b8a9bb194239491122e6cf1444c2efb88865426f170c262cdb"}, - {file = "frozenlist-1.7.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3dabd5a8f84573c8d10d8859a50ea2dec01eea372031929871368c09fa103478"}, - {file = "frozenlist-1.7.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa57daa5917f1738064f302bf2626281a1cb01920c32f711fbc7bc36111058a8"}, - {file = "frozenlist-1.7.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c193dda2b6d49f4c4398962810fa7d7c78f032bf45572b3e04dd5249dff27e08"}, - {file = "frozenlist-1.7.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfe2b675cf0aaa6d61bf8fbffd3c274b3c9b7b1623beb3809df8a81399a4a9c4"}, - {file = "frozenlist-1.7.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8fc5d5cda37f62b262405cf9652cf0856839c4be8ee41be0afe8858f17f4c94b"}, - {file = "frozenlist-1.7.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0d5ce521d1dd7d620198829b87ea002956e4319002ef0bc8d3e6d045cb4646e"}, - {file = "frozenlist-1.7.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:488d0a7d6a0008ca0db273c542098a0fa9e7dfaa7e57f70acef43f32b3f69dca"}, - {file = "frozenlist-1.7.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:15a7eaba63983d22c54d255b854e8108e7e5f3e89f647fc854bd77a237e767df"}, - {file = "frozenlist-1.7.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1eaa7e9c6d15df825bf255649e05bd8a74b04a4d2baa1ae46d9c2d00b2ca2cb5"}, - {file = "frozenlist-1.7.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e4389e06714cfa9d47ab87f784a7c5be91d3934cd6e9a7b85beef808297cc025"}, - {file = "frozenlist-1.7.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:73bd45e1488c40b63fe5a7df892baf9e2a4d4bb6409a2b3b78ac1c6236178e01"}, - {file = "frozenlist-1.7.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:99886d98e1643269760e5fe0df31e5ae7050788dd288947f7f007209b8c33f08"}, - {file = "frozenlist-1.7.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:290a172aae5a4c278c6da8a96222e6337744cd9c77313efe33d5670b9f65fc43"}, - {file = "frozenlist-1.7.0-cp312-cp312-win32.whl", hash = "sha256:426c7bc70e07cfebc178bc4c2bf2d861d720c4fff172181eeb4a4c41d4ca2ad3"}, - {file = "frozenlist-1.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:563b72efe5da92e02eb68c59cb37205457c977aa7a449ed1b37e6939e5c47c6a"}, - {file = "frozenlist-1.7.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee80eeda5e2a4e660651370ebffd1286542b67e268aa1ac8d6dbe973120ef7ee"}, - {file = "frozenlist-1.7.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d1a81c85417b914139e3a9b995d4a1c84559afc839a93cf2cb7f15e6e5f6ed2d"}, - {file = "frozenlist-1.7.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cbb65198a9132ebc334f237d7b0df163e4de83fb4f2bdfe46c1e654bdb0c5d43"}, - {file = "frozenlist-1.7.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dab46c723eeb2c255a64f9dc05b8dd601fde66d6b19cdb82b2e09cc6ff8d8b5d"}, - {file = "frozenlist-1.7.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6aeac207a759d0dedd2e40745575ae32ab30926ff4fa49b1635def65806fddee"}, - {file = "frozenlist-1.7.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bd8c4e58ad14b4fa7802b8be49d47993182fdd4023393899632c88fd8cd994eb"}, - {file = "frozenlist-1.7.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04fb24d104f425da3540ed83cbfc31388a586a7696142004c577fa61c6298c3f"}, - {file = "frozenlist-1.7.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6a5c505156368e4ea6b53b5ac23c92d7edc864537ff911d2fb24c140bb175e60"}, - {file = "frozenlist-1.7.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8bd7eb96a675f18aa5c553eb7ddc24a43c8c18f22e1f9925528128c052cdbe00"}, - {file = "frozenlist-1.7.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:05579bf020096fe05a764f1f84cd104a12f78eaab68842d036772dc6d4870b4b"}, - {file = "frozenlist-1.7.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:376b6222d114e97eeec13d46c486facd41d4f43bab626b7c3f6a8b4e81a5192c"}, - {file = "frozenlist-1.7.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:0aa7e176ebe115379b5b1c95b4096fb1c17cce0847402e227e712c27bdb5a949"}, - {file = "frozenlist-1.7.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3fbba20e662b9c2130dc771e332a99eff5da078b2b2648153a40669a6d0e36ca"}, - {file = "frozenlist-1.7.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f3f4410a0a601d349dd406b5713fec59b4cee7e71678d5b17edda7f4655a940b"}, - {file = "frozenlist-1.7.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e2cdfaaec6a2f9327bf43c933c0319a7c429058e8537c508964a133dffee412e"}, - {file = "frozenlist-1.7.0-cp313-cp313-win32.whl", hash = "sha256:5fc4df05a6591c7768459caba1b342d9ec23fa16195e744939ba5914596ae3e1"}, - {file = "frozenlist-1.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:52109052b9791a3e6b5d1b65f4b909703984b770694d3eb64fad124c835d7cba"}, - {file = "frozenlist-1.7.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a6f86e4193bb0e235ef6ce3dde5cbabed887e0b11f516ce8a0f4d3b33078ec2d"}, - {file = "frozenlist-1.7.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:82d664628865abeb32d90ae497fb93df398a69bb3434463d172b80fc25b0dd7d"}, - {file = "frozenlist-1.7.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:912a7e8375a1c9a68325a902f3953191b7b292aa3c3fb0d71a216221deca460b"}, - {file = "frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9537c2777167488d539bc5de2ad262efc44388230e5118868e172dd4a552b146"}, - {file = "frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f34560fb1b4c3e30ba35fa9a13894ba39e5acfc5f60f57d8accde65f46cc5e74"}, - {file = "frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:acd03d224b0175f5a850edc104ac19040d35419eddad04e7cf2d5986d98427f1"}, - {file = "frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f2038310bc582f3d6a09b3816ab01737d60bf7b1ec70f5356b09e84fb7408ab1"}, - {file = "frozenlist-1.7.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b8c05e4c8e5f36e5e088caa1bf78a687528f83c043706640a92cb76cd6999384"}, - {file = "frozenlist-1.7.0-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:765bb588c86e47d0b68f23c1bee323d4b703218037765dcf3f25c838c6fecceb"}, - {file = "frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:32dc2e08c67d86d0969714dd484fd60ff08ff81d1a1e40a77dd34a387e6ebc0c"}, - {file = "frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:c0303e597eb5a5321b4de9c68e9845ac8f290d2ab3f3e2c864437d3c5a30cd65"}, - {file = "frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:a47f2abb4e29b3a8d0b530f7c3598badc6b134562b1a5caee867f7c62fee51e3"}, - {file = "frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:3d688126c242a6fabbd92e02633414d40f50bb6002fa4cf995a1d18051525657"}, - {file = "frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:4e7e9652b3d367c7bd449a727dc79d5043f48b88d0cbfd4f9f1060cf2b414104"}, - {file = "frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:1a85e345b4c43db8b842cab1feb41be5cc0b10a1830e6295b69d7310f99becaf"}, - {file = "frozenlist-1.7.0-cp313-cp313t-win32.whl", hash = "sha256:3a14027124ddb70dfcee5148979998066897e79f89f64b13328595c4bdf77c81"}, - {file = "frozenlist-1.7.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3bf8010d71d4507775f658e9823210b7427be36625b387221642725b515dcf3e"}, - {file = "frozenlist-1.7.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:cea3dbd15aea1341ea2de490574a4a37ca080b2ae24e4b4f4b51b9057b4c3630"}, - {file = "frozenlist-1.7.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7d536ee086b23fecc36c2073c371572374ff50ef4db515e4e503925361c24f71"}, - {file = "frozenlist-1.7.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:dfcebf56f703cb2e346315431699f00db126d158455e513bd14089d992101e44"}, - {file = "frozenlist-1.7.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:974c5336e61d6e7eb1ea5b929cb645e882aadab0095c5a6974a111e6479f8878"}, - {file = "frozenlist-1.7.0-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c70db4a0ab5ab20878432c40563573229a7ed9241506181bba12f6b7d0dc41cb"}, - {file = "frozenlist-1.7.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1137b78384eebaf70560a36b7b229f752fb64d463d38d1304939984d5cb887b6"}, - {file = "frozenlist-1.7.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e793a9f01b3e8b5c0bc646fb59140ce0efcc580d22a3468d70766091beb81b35"}, - {file = "frozenlist-1.7.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:74739ba8e4e38221d2c5c03d90a7e542cb8ad681915f4ca8f68d04f810ee0a87"}, - {file = "frozenlist-1.7.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1e63344c4e929b1a01e29bc184bbb5fd82954869033765bfe8d65d09e336a677"}, - {file = "frozenlist-1.7.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2ea2a7369eb76de2217a842f22087913cdf75f63cf1307b9024ab82dfb525938"}, - {file = "frozenlist-1.7.0-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:836b42f472a0e006e02499cef9352ce8097f33df43baaba3e0a28a964c26c7d2"}, - {file = "frozenlist-1.7.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:e22b9a99741294b2571667c07d9f8cceec07cb92aae5ccda39ea1b6052ed4319"}, - {file = "frozenlist-1.7.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:9a19e85cc503d958abe5218953df722748d87172f71b73cf3c9257a91b999890"}, - {file = "frozenlist-1.7.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:f22dac33bb3ee8fe3e013aa7b91dc12f60d61d05b7fe32191ffa84c3aafe77bd"}, - {file = "frozenlist-1.7.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:9ccec739a99e4ccf664ea0775149f2749b8a6418eb5b8384b4dc0a7d15d304cb"}, - {file = "frozenlist-1.7.0-cp39-cp39-win32.whl", hash = "sha256:b3950f11058310008a87757f3eee16a8e1ca97979833239439586857bc25482e"}, - {file = "frozenlist-1.7.0-cp39-cp39-win_amd64.whl", hash = "sha256:43a82fce6769c70f2f5a06248b614a7d268080a9d20f7457ef10ecee5af82b63"}, - {file = "frozenlist-1.7.0-py3-none-any.whl", hash = "sha256:9a5af342e34f7e97caf8c995864c7a396418ae2859cc6fdf1b1073020d516a7e"}, - {file = "frozenlist-1.7.0.tar.gz", hash = "sha256:2e310d81923c2437ea8670467121cc3e9b0f76d3043cc1d2331d56c7fb7a3a8f"}, -] - -[[package]] -name = "go2rtc-client" -version = "0.2.1" -description = "Python client for go2rtc" -optional = false -python-versions = ">=3.12.0" -groups = ["dev"] -files = [ - {file = "go2rtc_client-0.2.1-py3-none-any.whl", hash = "sha256:de767a6608cecfc69cd44f54db649af0233bc92ab5c6b408d6b60515b622c620"}, - {file = "go2rtc_client-0.2.1.tar.gz", hash = "sha256:9b60e22a0f554c39f30b92b4abaf68efe41e6942aa2f25900efae7798c9747a8"}, -] - -[package.dependencies] -aiohttp = ">=3.10,<4.0" -awesomeversion = ">=24.6" -mashumaro = ">=3.13,<4.0" -orjson = ">=3.10,<4.0" -webrtc-models = ">=0.1,<1.0" - -[[package]] -name = "greenlet" -version = "3.2.4" -description = "Lightweight in-process concurrent programming" -optional = false -python-versions = ">=3.9" -groups = ["main", "dev"] -markers = "platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\"" -files = [ - {file = "greenlet-3.2.4-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:8c68325b0d0acf8d91dde4e6f930967dd52a5302cd4062932a6b2e7c2969f47c"}, - {file = "greenlet-3.2.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:94385f101946790ae13da500603491f04a76b6e4c059dab271b3ce2e283b2590"}, - {file = "greenlet-3.2.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f10fd42b5ee276335863712fa3da6608e93f70629c631bf77145021600abc23c"}, - {file = "greenlet-3.2.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c8c9e331e58180d0d83c5b7999255721b725913ff6bc6cf39fa2a45841a4fd4b"}, - {file = "greenlet-3.2.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:58b97143c9cc7b86fc458f215bd0932f1757ce649e05b640fea2e79b54cedb31"}, - {file = "greenlet-3.2.4-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c2ca18a03a8cfb5b25bc1cbe20f3d9a4c80d8c3b13ba3df49ac3961af0b1018d"}, - {file = "greenlet-3.2.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:9fe0a28a7b952a21e2c062cd5756d34354117796c6d9215a87f55e38d15402c5"}, - {file = "greenlet-3.2.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8854167e06950ca75b898b104b63cc646573aa5fef1353d4508ecdd1ee76254f"}, - {file = "greenlet-3.2.4-cp310-cp310-win_amd64.whl", hash = "sha256:73f49b5368b5359d04e18d15828eecc1806033db5233397748f4ca813ff1056c"}, - {file = "greenlet-3.2.4-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:96378df1de302bc38e99c3a9aa311967b7dc80ced1dcc6f171e99842987882a2"}, - {file = "greenlet-3.2.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1ee8fae0519a337f2329cb78bd7a8e128ec0f881073d43f023c7b8d4831d5246"}, - {file = "greenlet-3.2.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:94abf90142c2a18151632371140b3dba4dee031633fe614cb592dbb6c9e17bc3"}, - {file = "greenlet-3.2.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:4d1378601b85e2e5171b99be8d2dc85f594c79967599328f95c1dc1a40f1c633"}, - {file = "greenlet-3.2.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0db5594dce18db94f7d1650d7489909b57afde4c580806b8d9203b6e79cdc079"}, - {file = "greenlet-3.2.4-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2523e5246274f54fdadbce8494458a2ebdcdbc7b802318466ac5606d3cded1f8"}, - {file = "greenlet-3.2.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1987de92fec508535687fb807a5cea1560f6196285a4cde35c100b8cd632cc52"}, - {file = "greenlet-3.2.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:55e9c5affaa6775e2c6b67659f3a71684de4c549b3dd9afca3bc773533d284fa"}, - {file = "greenlet-3.2.4-cp311-cp311-win_amd64.whl", hash = "sha256:9c40adce87eaa9ddb593ccb0fa6a07caf34015a29bf8d344811665b573138db9"}, - {file = "greenlet-3.2.4-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3b67ca49f54cede0186854a008109d6ee71f66bd57bb36abd6d0a0267b540cdd"}, - {file = "greenlet-3.2.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddf9164e7a5b08e9d22511526865780a576f19ddd00d62f8a665949327fde8bb"}, - {file = "greenlet-3.2.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f28588772bb5fb869a8eb331374ec06f24a83a9c25bfa1f38b6993afe9c1e968"}, - {file = "greenlet-3.2.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:5c9320971821a7cb77cfab8d956fa8e39cd07ca44b6070db358ceb7f8797c8c9"}, - {file = "greenlet-3.2.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c60a6d84229b271d44b70fb6e5fa23781abb5d742af7b808ae3f6efd7c9c60f6"}, - {file = "greenlet-3.2.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b3812d8d0c9579967815af437d96623f45c0f2ae5f04e366de62a12d83a8fb0"}, - {file = "greenlet-3.2.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:abbf57b5a870d30c4675928c37278493044d7c14378350b3aa5d484fa65575f0"}, - {file = "greenlet-3.2.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:20fb936b4652b6e307b8f347665e2c615540d4b42b3b4c8a321d8286da7e520f"}, - {file = "greenlet-3.2.4-cp312-cp312-win_amd64.whl", hash = "sha256:a7d4e128405eea3814a12cc2605e0e6aedb4035bf32697f72deca74de4105e02"}, - {file = "greenlet-3.2.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:1a921e542453fe531144e91e1feedf12e07351b1cf6c9e8a3325ea600a715a31"}, - {file = "greenlet-3.2.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd3c8e693bff0fff6ba55f140bf390fa92c994083f838fece0f63be121334945"}, - {file = "greenlet-3.2.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:710638eb93b1fa52823aa91bf75326f9ecdfd5e0466f00789246a5280f4ba0fc"}, - {file = "greenlet-3.2.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c5111ccdc9c88f423426df3fd1811bfc40ed66264d35aa373420a34377efc98a"}, - {file = "greenlet-3.2.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d76383238584e9711e20ebe14db6c88ddcedc1829a9ad31a584389463b5aa504"}, - {file = "greenlet-3.2.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671"}, - {file = "greenlet-3.2.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b"}, - {file = "greenlet-3.2.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:d25c5091190f2dc0eaa3f950252122edbbadbb682aa7b1ef2f8af0f8c0afefae"}, - {file = "greenlet-3.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:554b03b6e73aaabec3745364d6239e9e012d64c68ccd0b8430c64ccc14939a8b"}, - {file = "greenlet-3.2.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:49a30d5fda2507ae77be16479bdb62a660fa51b1eb4928b524975b3bde77b3c0"}, - {file = "greenlet-3.2.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:299fd615cd8fc86267b47597123e3f43ad79c9d8a22bebdce535e53550763e2f"}, - {file = "greenlet-3.2.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c17b6b34111ea72fc5a4e4beec9711d2226285f0386ea83477cbb97c30a3f3a5"}, - {file = "greenlet-3.2.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b4a1870c51720687af7fa3e7cda6d08d801dae660f75a76f3845b642b4da6ee1"}, - {file = "greenlet-3.2.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:061dc4cf2c34852b052a8620d40f36324554bc192be474b9e9770e8c042fd735"}, - {file = "greenlet-3.2.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44358b9bf66c8576a9f57a590d5f5d6e72fa4228b763d0e43fee6d3b06d3a337"}, - {file = "greenlet-3.2.4-cp314-cp314-win_amd64.whl", hash = "sha256:e37ab26028f12dbb0ff65f29a8d3d44a765c61e729647bf2ddfbbed621726f01"}, - {file = "greenlet-3.2.4-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:b6a7c19cf0d2742d0809a4c05975db036fdff50cd294a93632d6a310bf9ac02c"}, - {file = "greenlet-3.2.4-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:27890167f55d2387576d1f41d9487ef171849ea0359ce1510ca6e06c8bece11d"}, - {file = "greenlet-3.2.4-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:18d9260df2b5fbf41ae5139e1be4e796d99655f023a636cd0e11e6406cca7d58"}, - {file = "greenlet-3.2.4-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:671df96c1f23c4a0d4077a325483c1503c96a1b7d9db26592ae770daa41233d4"}, - {file = "greenlet-3.2.4-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:16458c245a38991aa19676900d48bd1a6f2ce3e16595051a4db9d012154e8433"}, - {file = "greenlet-3.2.4-cp39-cp39-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9913f1a30e4526f432991f89ae263459b1c64d1608c0d22a5c79c287b3c70df"}, - {file = "greenlet-3.2.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b90654e092f928f110e0007f572007c9727b5265f7632c2fa7415b4689351594"}, - {file = "greenlet-3.2.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:81701fd84f26330f0d5f4944d4e92e61afe6319dcd9775e39396e39d7c3e5f98"}, - {file = "greenlet-3.2.4-cp39-cp39-win32.whl", hash = "sha256:65458b409c1ed459ea899e939f0e1cdb14f58dbc803f2f93c5eab5694d32671b"}, - {file = "greenlet-3.2.4-cp39-cp39-win_amd64.whl", hash = "sha256:d2e685ade4dafd447ede19c31277a224a239a0a1a4eca4e6390efedf20260cfb"}, - {file = "greenlet-3.2.4.tar.gz", hash = "sha256:0dca0d95ff849f9a364385f36ab49f50065d76964944638be9691e1832e9f86d"}, -] - -[package.extras] -docs = ["Sphinx", "furo"] -test = ["objgraph", "psutil", "setuptools"] - -[[package]] -name = "h11" -version = "0.16.0" -description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" -optional = false -python-versions = ">=3.8" -groups = ["main", "dev"] -files = [ - {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"}, - {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, -] - -[[package]] -name = "habluetooth" -version = "5.6.4" -description = "High availability Bluetooth" -optional = false -python-versions = ">=3.11" -groups = ["main", "dev"] -files = [ - {file = "habluetooth-5.6.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a8f4ebcae127e186c392905a68452292beb03fb4bc7733e569520044a8d177eb"}, - {file = "habluetooth-5.6.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bd5b083955e47a8c6490779c98a0e5443ec3e7eb527bcf23b26be6ef99406f7e"}, - {file = "habluetooth-5.6.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4ebc03a1f1c4fa7cd443765cb9ef8706d74240522dce377651f616d5ad4ad5b"}, - {file = "habluetooth-5.6.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:24eb60c9c96c6ec71e993fe45877f197d110ad33cf12606853c9517ccef529ec"}, - {file = "habluetooth-5.6.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:419ee7f6ae0247d485b0f5f8f8665ea680bdd6c2ec62a290ab14e4354c82a994"}, - {file = "habluetooth-5.6.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e8686f591442629852a6f83cda824c0c2ca519d972f959a992fc2665d17fafde"}, - {file = "habluetooth-5.6.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:68860a521c355963f2d3dc3f9654eca5b05416277ebc905dc0b650b6dcf2e60a"}, - {file = "habluetooth-5.6.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7c0e3f6717a2a961217b7786b4c00bb27f7aab4706449186d1ea8977bca44523"}, - {file = "habluetooth-5.6.4-cp311-cp311-win32.whl", hash = "sha256:a3755d11d10ea715de717503faf7c6342d2b46ea79ef8f76a3165330d1f64143"}, - {file = "habluetooth-5.6.4-cp311-cp311-win_amd64.whl", hash = "sha256:bddb194ef1ce7e7280a654bb47d1e59d7f2546c755fa6ea66292216d88cec731"}, - {file = "habluetooth-5.6.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e5d538a95eeac3f13071ffbedc0362de36f501fcf3804cadcee1b3a5b3af43e4"}, - {file = "habluetooth-5.6.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f744e49935d4537a10300b25298df11f8bd3c1d17207be6a7dc1cdcf2b235316"}, - {file = "habluetooth-5.6.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e2aff14b04a71c3553602b99bd86392e7f7097a78182304886996505143e5d1"}, - {file = "habluetooth-5.6.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5c456fa7110e38c60872ac7f881644ce1b0229b19dd41a01dcebc31bc20c8b8f"}, - {file = "habluetooth-5.6.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f9ae64297ae906dd1b2d744f12d24474134aedeb5b1cfbb7b1ccd39a57447f1"}, - {file = "habluetooth-5.6.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ee94cf96ad6244cb9ae7aa3fa0e9d67bf1d98cfe833d0dbfbca4a2ae76e5f928"}, - {file = "habluetooth-5.6.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:eb782568aa210e3a09a373743e9621ed7a1e96568ecf97b024a88af1f3f58cb7"}, - {file = "habluetooth-5.6.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7d865288fe6c7a51478e2688ebfab2107b07cd42de6b54c08d44a8b57dfcf586"}, - {file = "habluetooth-5.6.4-cp312-cp312-win32.whl", hash = "sha256:409c320a2ad3214c1879674d9cc59fc136f8bb19b2e0ac1a6d24f316654012e7"}, - {file = "habluetooth-5.6.4-cp312-cp312-win_amd64.whl", hash = "sha256:b6602e61be6a21fb3cddcda588611278247d5a1fa42bc3590e76c0b554924b29"}, - {file = "habluetooth-5.6.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5cf1914a6c9db804e1da93a33552dbecbde7e2706a4224029ef15931e044781d"}, - {file = "habluetooth-5.6.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2262dcc0a9d7d7561afd05f98ac8cb471b5e006d9d09a6661b309c1877964175"}, - {file = "habluetooth-5.6.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f605273127378a28e02ada14f122933f4061c89600aa9ca67a798f6091dae29"}, - {file = "habluetooth-5.6.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1827ec0ed938f39ef7745c053f78c3320870a454f29a8ce82b02933846adeb70"}, - {file = "habluetooth-5.6.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:21be74c2dfed3411dd52c58c8d614e9187e13da2308d42ffd9590630ef6cd2b3"}, - {file = "habluetooth-5.6.4-cp313-cp313-manylinux_2_36_x86_64.whl", hash = "sha256:83fc0f21d165c99f3cba74a0c67becd0fdf911c31dc70370a1f0668a5cbfef6a"}, - {file = "habluetooth-5.6.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f69fe990a4e0bac6c1755cdace1839ca305076cf364984aa3eb59eb054936df3"}, - {file = "habluetooth-5.6.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:69ea4a09cea97af776ec5d933e86da356af9565a19885ef8743d7fd57e8cf0b2"}, - {file = "habluetooth-5.6.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5250bbd8216b34838c34ad469aefd052db41b1c9af21fb9a61c3884f82e46c83"}, - {file = "habluetooth-5.6.4-cp313-cp313-win32.whl", hash = "sha256:a27cd4487943d9b4218453a221d7a89ffc96f048b1301e984c04de90d249fc4c"}, - {file = "habluetooth-5.6.4-cp313-cp313-win_amd64.whl", hash = "sha256:c09d1bf5e648579306c4b1a328a05b654e8492ab97502912a72d241995f9289b"}, - {file = "habluetooth-5.6.4-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cab943f42bfc573492a4dc044f6872186c8c7b412f470748bcb0e00855884276"}, - {file = "habluetooth-5.6.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:872735ae9ffb045acc3a87b55233177ac02c68c38c8fe8dc8dc527bd2627a300"}, - {file = "habluetooth-5.6.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ead8368ff986da9d4167812e55c79c69dbf5c7f707ebfdd31e9e6afc87aca70"}, - {file = "habluetooth-5.6.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1d2c05f812a44c955e58bde1639a23f05ee0feb48222596e0c364d4e07185989"}, - {file = "habluetooth-5.6.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:add79f79f1442f5a887d0874bfa3ffcf3ccc6916bf16747c449b306dedb88dd3"}, - {file = "habluetooth-5.6.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:fe70a41ccdf305106ebdbe9d4d4dbfcb4039bc15463b37fe994ba3c5a7988a78"}, - {file = "habluetooth-5.6.4-cp314-cp314-win32.whl", hash = "sha256:0af0ce0005949be15e7f629a38167d76aff171e189a4372f67f42a5b4da33031"}, - {file = "habluetooth-5.6.4-cp314-cp314-win_amd64.whl", hash = "sha256:a136c3b31c971ff34319b0177b4b3226f439f6555c043571a17a197218c10ce5"}, - {file = "habluetooth-5.6.4-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9354c1e4b15187ee46f8d67bdde91feb683474c6c7258dd982443d0e368199ec"}, - {file = "habluetooth-5.6.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3337c57aa32f7256b712160854053bf8e86af958fa33a40a1225ba638735cba8"}, - {file = "habluetooth-5.6.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:adc204fb01efa57ad7772bac9a69ad78a44ef11fe59dafffe780d6cc657a9612"}, - {file = "habluetooth-5.6.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1760ebeb50242a7dcff86a3df90bb403f93b3df91d150c03791dbf7bcef9ac7f"}, - {file = "habluetooth-5.6.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ab386898fd63b3fe52343fcb5d99bca0b15269574f1d897a3fde27e23d12f91c"}, - {file = "habluetooth-5.6.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5adb089d1d0da72d064c54fbb4251690dc91333003b62d954e0fa47b746c59d9"}, - {file = "habluetooth-5.6.4-cp314-cp314t-win32.whl", hash = "sha256:20f56d3bd1b20fde5a5989dd6039ab36afb43fd82ac464f276a776f9d3bdad79"}, - {file = "habluetooth-5.6.4-cp314-cp314t-win_amd64.whl", hash = "sha256:88c4eeb1696a34697b36e8a36c482f728e6bbc494a2047786bfd7f39aeb08249"}, - {file = "habluetooth-5.6.4.tar.gz", hash = "sha256:8dce896b19bcb5991c17b9836361e4bf740101bee8cbf028902f96be3010c06c"}, -] - -[package.dependencies] -async-interrupt = ">=1.1.1" -bleak = ">=1.0.1" -bleak-retry-connector = ">=4.2.0" -bluetooth-adapters = ">=2.1.0" -bluetooth-auto-recovery = ">=1.5.1" -bluetooth-data-tools = ">=1.28.0" -btsocket = ">=0.3.0" -dbus-fast = {version = ">=2.30.2", markers = "platform_system == \"Linux\""} - -[[package]] -name = "hass-nabucasa" -version = "1.1.1" -description = "Home Assistant cloud integration by Nabu Casa, Inc." -optional = false -python-versions = ">=3.13" -groups = ["main", "dev"] -files = [ - {file = "hass_nabucasa-1.1.1-py3-none-any.whl", hash = "sha256:262c2b227497d090fc4923bcd5c6c0c943a8b6a5b7e2d8255885c5f31fdb6b0a"}, - {file = "hass_nabucasa-1.1.1.tar.gz", hash = "sha256:fcc5afbb3557d105428cdfc12882fbd7de297ae64cf1444cafe560940e9407c4"}, -] - -[package.dependencies] -acme = "5.0.0" -aiohttp = ">=3.6.1" -async_timeout = ">=4" -atomicwrites-homeassistant = "1.4.1" -attrs = ">=19.3" -ciso8601 = ">=2.3.0" -cryptography = ">=42.0.0" -josepy = ">=2,<3" -pycognito = "2024.5.1" -PyJWT = ">=2.8.0" -sentence-stream = "1.2.0" -snitun = "0.44.0" -webrtc-models = "<1.0.0" -yarl = ">=1.20,<2" - -[package.extras] -test = ["codespell (==2.4.1)", "freezegun (==1.5.5)", "mypy (==1.17.1)", "pre-commit (==4.3.0)", "pre-commit-hooks (==6.0.0)", "pylint (==3.3.8)", "pytest (==8.4.2)", "pytest-aiohttp (==1.1.0)", "pytest-socket (==0.7.0)", "pytest-timeout (==2.4.0)", "ruff (==0.12.12)", "syrupy (==4.9.1)", "tomli (==2.2.1)", "types_atomicwrites (==1.4.5.1)", "types_pyOpenSSL (==24.1.0.20240722)", "xmltodict (==0.15.1)"] - -[[package]] -name = "home-assistant-bluetooth" -version = "1.13.1" -description = "Home Assistant Bluetooth Models and Helpers" -optional = false -python-versions = ">=3.11" -groups = ["main", "dev"] -files = [ - {file = "home_assistant_bluetooth-1.13.1-py3-none-any.whl", hash = "sha256:cdf13b5b45f7744165677831e309ee78fbaf0c2866c6b5931e14d1e4e7dae5d7"}, - {file = "home_assistant_bluetooth-1.13.1.tar.gz", hash = "sha256:0ae0e2a8491cc762ee9e694b8bc7665f1e2b4618926f63969a23a2e3a48ce55e"}, -] - -[package.dependencies] -habluetooth = ">=3.0" - -[[package]] -name = "homeassistant" -version = "2025.9.3" -description = "Open-source home automation platform running on Python 3." -optional = false -python-versions = ">=3.13.2" -groups = ["main", "dev"] -files = [ - {file = "homeassistant-2025.9.3-py3-none-any.whl", hash = "sha256:d0e3c74a905b39f069b22e483aac7b2e7958179cc39b24bfc75275630b160c84"}, - {file = "homeassistant-2025.9.3.tar.gz", hash = "sha256:8b0e5134ce95a2219f0a61a0a9fa8c428b7d02b3e52a4fed5c950964db3b2b38"}, -] - -[package.dependencies] -aiodns = "3.5.0" -aiohasupervisor = "0.3.2" -aiohttp = "3.12.15" -aiohttp-asyncmdnsresolver = "0.1.1" -aiohttp_cors = "0.8.1" -aiohttp-fast-zlib = "0.3.0" -aiozoneinfo = "0.2.3" -annotatedyaml = "0.4.5" -astral = "2.2" -async-interrupt = "1.2.2" -atomicwrites-homeassistant = "1.4.1" -attrs = "25.3.0" -audioop-lts = "0.2.1" -awesomeversion = "25.5.0" -bcrypt = "4.3.0" -certifi = ">=2021.5.30" -ciso8601 = "2.3.2" -cronsim = "2.6" -cryptography = "45.0.3" -fnv-hash-fast = "1.5.0" -hass-nabucasa = "1.1.1" -home-assistant-bluetooth = "1.13.1" -httpx = "0.28.1" -ifaddr = "0.2.0" -Jinja2 = "3.1.6" -lru-dict = "1.3.0" -orjson = "3.11.3" -packaging = ">=23.1" -Pillow = "11.3.0" -propcache = "0.3.2" -psutil-home-assistant = "0.0.1" -PyJWT = "2.10.1" -pyOpenSSL = "25.1.0" -python-slugify = "8.0.4" -PyYAML = "6.0.2" -requests = "2.32.4" -securetar = "2025.2.1" -SQLAlchemy = "2.0.41" -standard-aifc = "3.13.0" -standard-telnetlib = "3.13.0" -typing-extensions = ">=4.15.0,<5.0" -ulid-transform = "1.4.0" -urllib3 = ">=2.0" -uv = "0.8.9" -voluptuous = "0.15.2" -voluptuous-openapi = "0.1.0" -voluptuous-serialize = "2.7.0" -webrtc-models = "0.3.0" -yarl = "1.20.1" -zeroconf = "0.147.0" - -[[package]] -name = "homeassistant-stubs" -version = "2025.9.3" -description = "PEP 484 typing stubs for Home Assistant Core" -optional = false -python-versions = ">=3.13.2" -groups = ["dev"] -files = [ - {file = "homeassistant_stubs-2025.9.3-py3-none-any.whl", hash = "sha256:e9d32d24b9215b4a15ed631bd4da1e69575bc036137b929d43e07ee55bfeda46"}, - {file = "homeassistant_stubs-2025.9.3.tar.gz", hash = "sha256:0b954bd450e16869d96dbf37775516c9f851ffd95dc35b1e41318152c5c21ac6"}, -] - -[package.dependencies] -homeassistant = "2025.9.3" - -[[package]] -name = "httpcore" -version = "1.0.9" -description = "A minimal low-level HTTP client." -optional = false -python-versions = ">=3.8" -groups = ["main", "dev"] -files = [ - {file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"}, - {file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"}, -] - -[package.dependencies] -certifi = "*" -h11 = ">=0.16" - -[package.extras] -asyncio = ["anyio (>=4.0,<5.0)"] -http2 = ["h2 (>=3,<5)"] -socks = ["socksio (==1.*)"] -trio = ["trio (>=0.22.0,<1.0)"] - -[[package]] -name = "httpx" -version = "0.28.1" -description = "The next generation HTTP client." -optional = false -python-versions = ">=3.8" -groups = ["main", "dev"] -files = [ - {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"}, - {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"}, -] - -[package.dependencies] -anyio = "*" -certifi = "*" -httpcore = "==1.*" -idna = "*" - -[package.extras] -brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi ; platform_python_implementation != \"CPython\""] -cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] -http2 = ["h2 (>=3,<5)"] -socks = ["socksio (==1.*)"] -zstd = ["zstandard (>=0.18.0)"] - -[[package]] -name = "identify" -version = "2.6.14" -description = "File identification library for Python" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "identify-2.6.14-py2.py3-none-any.whl", hash = "sha256:11a073da82212c6646b1f39bb20d4483bfb9543bd5566fec60053c4bb309bf2e"}, - {file = "identify-2.6.14.tar.gz", hash = "sha256:663494103b4f717cb26921c52f8751363dc89db64364cd836a9bf1535f53cd6a"}, -] - -[package.extras] -license = ["ukkonen"] - -[[package]] -name = "idna" -version = "3.10" -description = "Internationalized Domain Names in Applications (IDNA)" -optional = false -python-versions = ">=3.6" -groups = ["main", "dev"] -files = [ - {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, - {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, -] - -[package.extras] -all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] - -[[package]] -name = "ifaddr" -version = "0.2.0" -description = "Cross-platform network interface and IP address enumeration library" -optional = false -python-versions = "*" -groups = ["main", "dev"] -files = [ - {file = "ifaddr-0.2.0-py3-none-any.whl", hash = "sha256:085e0305cfe6f16ab12d72e2024030f5d52674afad6911bb1eee207177b8a748"}, - {file = "ifaddr-0.2.0.tar.gz", hash = "sha256:cc0cbfcaabf765d44595825fb96a99bb12c79716b73b44330ea38ee2b0c4aed4"}, -] - -[[package]] -name = "iniconfig" -version = "2.1.0" -description = "brain-dead simple config-ini parsing" -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"}, - {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"}, -] - -[[package]] -name = "isort" -version = "6.0.1" -description = "A Python utility / library to sort Python imports." -optional = false -python-versions = ">=3.9.0" -groups = ["dev"] -files = [ - {file = "isort-6.0.1-py3-none-any.whl", hash = "sha256:2dc5d7f65c9678d94c88dfc29161a320eec67328bc97aad576874cb4be1e9615"}, - {file = "isort-6.0.1.tar.gz", hash = "sha256:1cb5df28dfbc742e490c5e41bad6da41b805b0a8be7bc93cd0fb2a8a890ac450"}, -] - -[package.extras] -colors = ["colorama"] -plugins = ["setuptools"] - -[[package]] -name = "jinja2" -version = "3.1.6" -description = "A very fast and expressive template engine." -optional = false -python-versions = ">=3.7" -groups = ["main", "dev"] -files = [ - {file = "jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"}, - {file = "jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d"}, -] - -[package.dependencies] -MarkupSafe = ">=2.0" - -[package.extras] -i18n = ["Babel (>=2.7)"] - -[[package]] -name = "jmespath" -version = "1.0.1" -description = "JSON Matching Expressions" -optional = false -python-versions = ">=3.7" -groups = ["main", "dev"] -files = [ - {file = "jmespath-1.0.1-py3-none-any.whl", hash = "sha256:02e2e4cc71b5bcab88332eebf907519190dd9e6e82107fa7f83b1003a6252980"}, - {file = "jmespath-1.0.1.tar.gz", hash = "sha256:90261b206d6defd58fdd5e85f478bf633a2901798906be2ad389150c5c60edbe"}, -] - -[[package]] -name = "josepy" -version = "2.1.0" -description = "JOSE protocol implementation in Python" -optional = false -python-versions = "<4.0,>=3.9.2" -groups = ["main", "dev"] -files = [ - {file = "josepy-2.1.0-py3-none-any.whl", hash = "sha256:0eadf09b96821bdae9a8b14145425cb9fe0bbee64c6fdfce3ccd4ceb7d7efbbd"}, - {file = "josepy-2.1.0.tar.gz", hash = "sha256:9beafbaa107ec7128e6c21d86b2bc2aea2f590158e50aca972dca3753046091f"}, -] - -[package.dependencies] -cryptography = ">=1.5" - -[package.extras] -docs = ["sphinx (>=4.3.0)", "sphinx-rtd-theme (>=1.0)"] - -[[package]] -name = "license-expression" -version = "30.4.3" -description = "license-expression is a comprehensive utility library to parse, compare, simplify and normalize license expressions (such as SPDX license expressions) using boolean logic." -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "license_expression-30.4.3-py3-none-any.whl", hash = "sha256:fd3db53418133e0eef917606623bc125fbad3d1225ba8d23950999ee87c99280"}, - {file = "license_expression-30.4.3.tar.gz", hash = "sha256:49f439fea91c4d1a642f9f2902b58db1d42396c5e331045f41ce50df9b40b1f2"}, -] - -[package.dependencies] -"boolean.py" = ">=4.0" - -[package.extras] -dev = ["Sphinx (>=5.0.2)", "doc8 (>=0.11.2)", "pytest (>=7.0.1)", "pytest-xdist (>=2)", "ruff", "sphinx-autobuild", "sphinx-copybutton", "sphinx-reredirects (>=0.1.2)", "sphinx-rtd-dark-mode (>=1.3.0)", "sphinx-rtd-theme (>=1.0.0)", "sphinxcontrib-apidoc (>=0.4.0)", "twine"] - -[[package]] -name = "lru-dict" -version = "1.3.0" -description = "An Dict like LRU container." -optional = false -python-versions = ">=3.8" -groups = ["main", "dev"] -files = [ - {file = "lru-dict-1.3.0.tar.gz", hash = "sha256:54fd1966d6bd1fcde781596cb86068214edeebff1db13a2cea11079e3fd07b6b"}, - {file = "lru_dict-1.3.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4073333894db9840f066226d50e6f914a2240711c87d60885d8c940b69a6673f"}, - {file = "lru_dict-1.3.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0ad6361e4dd63b47b2fc8eab344198f37387e1da3dcfacfee19bafac3ec9f1eb"}, - {file = "lru_dict-1.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c637ab54b8cd9802fe19b260261e38820d748adf7606e34045d3c799b6dde813"}, - {file = "lru_dict-1.3.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fce5f95489ca1fc158cc9fe0f4866db9cec82c2be0470926a9080570392beaf"}, - {file = "lru_dict-1.3.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b2bf2e24cf5f19c3ff69bf639306e83dced273e6fa775b04e190d7f5cd16f794"}, - {file = "lru_dict-1.3.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e90059f7701bef3c4da073d6e0434a9c7dc551d5adce30e6b99ef86b186f4b4a"}, - {file = "lru_dict-1.3.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ecb7ae557239c64077e9b26a142eb88e63cddb104111a5122de7bebbbd00098"}, - {file = "lru_dict-1.3.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:6af36166d22dba851e06a13e35bbf33845d3dd88872e6aebbc8e3e7db70f4682"}, - {file = "lru_dict-1.3.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:8ee38d420c77eed548df47b7d74b5169a98e71c9e975596e31ab808e76d11f09"}, - {file = "lru_dict-1.3.0-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:0e1845024c31e6ff246c9eb5e6f6f1a8bb564c06f8a7d6d031220044c081090b"}, - {file = "lru_dict-1.3.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:3ca5474b1649555d014be1104e5558a92497509021a5ba5ea6e9b492303eb66b"}, - {file = "lru_dict-1.3.0-cp310-cp310-win32.whl", hash = "sha256:ebb03a9bd50c2ed86d4f72a54e0aae156d35a14075485b2127c4b01a3f4a63fa"}, - {file = "lru_dict-1.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:04cda617f4e4c27009005d0a8185ef02829b14b776d2791f5c994cc9d668bc24"}, - {file = "lru_dict-1.3.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:20c595764695d20bdc3ab9b582e0cc99814da183544afb83783a36d6741a0dac"}, - {file = "lru_dict-1.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d9b30a8f50c3fa72a494eca6be5810a1b5c89e4f0fda89374f0d1c5ad8d37d51"}, - {file = "lru_dict-1.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9710737584650a4251b9a566cbb1a86f83437adb209c9ba43a4e756d12faf0d7"}, - {file = "lru_dict-1.3.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b84c321ae34f2f40aae80e18b6fa08b31c90095792ab64bb99d2e385143effaa"}, - {file = "lru_dict-1.3.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:eed24272b4121b7c22f234daed99899817d81d671b3ed030c876ac88bc9dc890"}, - {file = "lru_dict-1.3.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9bd13af06dab7c6ee92284fd02ed9a5613a07d5c1b41948dc8886e7207f86dfd"}, - {file = "lru_dict-1.3.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a1efc59bfba6aac33684d87b9e02813b0e2445b2f1c444dae2a0b396ad0ed60c"}, - {file = "lru_dict-1.3.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:cfaf75ac574447afcf8ad998789071af11d2bcf6f947643231f692948839bd98"}, - {file = "lru_dict-1.3.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:c95f8751e2abd6f778da0399c8e0239321d560dbc58cb063827123137d213242"}, - {file = "lru_dict-1.3.0-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:abd0c284b26b5c4ee806ca4f33ab5e16b4bf4d5ec9e093e75a6f6287acdde78e"}, - {file = "lru_dict-1.3.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:2a47740652b25900ac5ce52667b2eade28d8b5fdca0ccd3323459df710e8210a"}, - {file = "lru_dict-1.3.0-cp311-cp311-win32.whl", hash = "sha256:a690c23fc353681ed8042d9fe8f48f0fb79a57b9a45daea2f0be1eef8a1a4aa4"}, - {file = "lru_dict-1.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:efd3f4e0385d18f20f7ea6b08af2574c1bfaa5cb590102ef1bee781bdfba84bc"}, - {file = "lru_dict-1.3.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:c279068f68af3b46a5d649855e1fb87f5705fe1f744a529d82b2885c0e1fc69d"}, - {file = "lru_dict-1.3.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:350e2233cfee9f326a0d7a08e309372d87186565e43a691b120006285a0ac549"}, - {file = "lru_dict-1.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4eafb188a84483b3231259bf19030859f070321b00326dcb8e8c6cbf7db4b12f"}, - {file = "lru_dict-1.3.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:73593791047e36b37fdc0b67b76aeed439fcea80959c7d46201240f9ec3b2563"}, - {file = "lru_dict-1.3.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1958cb70b9542773d6241974646e5410e41ef32e5c9e437d44040d59bd80daf2"}, - {file = "lru_dict-1.3.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bc1cd3ed2cee78a47f11f3b70be053903bda197a873fd146e25c60c8e5a32cd6"}, - {file = "lru_dict-1.3.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82eb230d48eaebd6977a92ddaa6d788f14cf4f4bcf5bbffa4ddfd60d051aa9d4"}, - {file = "lru_dict-1.3.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:5ad659cbc349d0c9ba8e536b5f40f96a70c360f43323c29f4257f340d891531c"}, - {file = "lru_dict-1.3.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:ba490b8972531d153ac0d4e421f60d793d71a2f4adbe2f7740b3c55dce0a12f1"}, - {file = "lru_dict-1.3.0-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:c0131351b8a7226c69f1eba5814cbc9d1d8daaf0fdec1ae3f30508e3de5262d4"}, - {file = "lru_dict-1.3.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:0e88dba16695f17f41701269fa046197a3fd7b34a8dba744c8749303ddaa18df"}, - {file = "lru_dict-1.3.0-cp312-cp312-win32.whl", hash = "sha256:6ffaf595e625b388babc8e7d79b40f26c7485f61f16efe76764e32dce9ea17fc"}, - {file = "lru_dict-1.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:cf9da32ef2582434842ab6ba6e67290debfae72771255a8e8ab16f3e006de0aa"}, - {file = "lru_dict-1.3.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:c265f16c936a8ff3bb4b8a4bda0be94c15ec28b63e99fdb1439c1ffe4cd437db"}, - {file = "lru_dict-1.3.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:784ca9d3b0730b3ec199c0a58f66264c63dd5d438119c739c349a6a9be8e5f6e"}, - {file = "lru_dict-1.3.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:e13b2f58f647178470adaa14603bb64cc02eeed32601772ccea30e198252883c"}, - {file = "lru_dict-1.3.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7ffbce5c2e80f57937679553c8f27e61ec327c962bf7ea0b15f1d74277fd5363"}, - {file = "lru_dict-1.3.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7969cb034b3ccc707aff877c73c225c32d7e2a7981baa8f92f5dd4d468fe8c33"}, - {file = "lru_dict-1.3.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ca9ab676609cce85dd65d91c275e47da676d13d77faa72de286fbea30fbaa596"}, - {file = "lru_dict-1.3.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f27c078b5d75989952acbf9b77e14c3dadc468a4aafe85174d548afbc5efc38b"}, - {file = "lru_dict-1.3.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:6123aefe97762ad74215d05320a7f389f196f0594c8813534284d4eafeca1a96"}, - {file = "lru_dict-1.3.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:cd869cadba9a63e1e7fe2dced4a5747d735135b86016b0a63e8c9e324ab629ac"}, - {file = "lru_dict-1.3.0-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:40a8daddc29c7edb09dfe44292cf111f1e93a8344349778721d430d336b50505"}, - {file = "lru_dict-1.3.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:6a03170e4152836987a88dcebde61aaeb73ab7099a00bb86509d45b3fe424230"}, - {file = "lru_dict-1.3.0-cp38-cp38-win32.whl", hash = "sha256:3b4f121afe10f5a82b8e317626eb1e1c325b3f104af56c9756064cd833b1950b"}, - {file = "lru_dict-1.3.0-cp38-cp38-win_amd64.whl", hash = "sha256:1470f5828c7410e16c24b5150eb649647986e78924816e6fb0264049dea14a2b"}, - {file = "lru_dict-1.3.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:a3c9f746a9917e784fffcedeac4c8c47a3dbd90cbe13b69e9140182ad97ce4b7"}, - {file = "lru_dict-1.3.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:2789296819525a1f3204072dfcf3df6db8bcf69a8fc740ffd3de43a684ea7002"}, - {file = "lru_dict-1.3.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:170b66d29945391460351588a7bd8210a95407ae82efe0b855e945398a1d24ea"}, - {file = "lru_dict-1.3.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:774ca88501a9effe8797c3db5a6685cf20978c9cb0fe836b6813cfe1ca60d8c9"}, - {file = "lru_dict-1.3.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:df2e119c6ae412d2fd641a55f8a1e2e51f45a3de3449c18b1b86c319ab79e0c4"}, - {file = "lru_dict-1.3.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:28aa1ea42a7e48174bf513dc2416fea7511a547961e678dc6f5670ca987c18cb"}, - {file = "lru_dict-1.3.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9537e1cee6fa582cb68f2fb9ce82d51faf2ccc0a638b275d033fdcb1478eb80b"}, - {file = "lru_dict-1.3.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:64545fca797fe2c68c5168efb5f976c6e1459e058cab02445207a079180a3557"}, - {file = "lru_dict-1.3.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:a193a14c66cfc0c259d05dddc5e566a4b09e8f1765e941503d065008feebea9d"}, - {file = "lru_dict-1.3.0-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:3cb1de0ce4137b060abaafed8474cc0ebd12cedd88aaa7f7b3ebb1ddfba86ae0"}, - {file = "lru_dict-1.3.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8551ccab1349d4bebedab333dfc8693c74ff728f4b565fe15a6bf7d296bd7ea9"}, - {file = "lru_dict-1.3.0-cp39-cp39-win32.whl", hash = "sha256:6cb0be5e79c3f34d69b90d8559f0221e374b974b809a22377122c4b1a610ff67"}, - {file = "lru_dict-1.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:9f725f2a0bdf1c18735372d5807af4ea3b77888208590394d4660e3d07971f21"}, - {file = "lru_dict-1.3.0-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:f8f7824db5a64581180ab9d09842e6dd9fcdc46aac9cb592a0807cd37ea55680"}, - {file = "lru_dict-1.3.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:acd04b7e7b0c0c192d738df9c317093335e7282c64c9d1bb6b7ebb54674b4e24"}, - {file = "lru_dict-1.3.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e5c20f236f27551e3f0adbf1a987673fb1e9c38d6d284502cd38f5a3845ef681"}, - {file = "lru_dict-1.3.0-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca3703ff03b03a1848c563bc2663d0ad813c1cd42c4d9cf75b623716d4415d9a"}, - {file = "lru_dict-1.3.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:a9fb71ba262c6058a0017ce83d343370d0a0dbe2ae62c2eef38241ec13219330"}, - {file = "lru_dict-1.3.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:f5b88a7c39e307739a3701194993455968fcffe437d1facab93546b1b8a334c1"}, - {file = "lru_dict-1.3.0-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2682bfca24656fb7a643621520d57b7fe684ed5fa7be008704c1235d38e16a32"}, - {file = "lru_dict-1.3.0-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:96fc87ddf569181827458ec5ad8fa446c4690cffacda66667de780f9fcefd44d"}, - {file = "lru_dict-1.3.0-pp38-pypy38_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dcec98e2c7da7631f0811730303abc4bdfe70d013f7a11e174a2ccd5612a7c59"}, - {file = "lru_dict-1.3.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:6bba2863060caeaedd8386b0c8ee9a7ce4d57a7cb80ceeddf440b4eff2d013ba"}, - {file = "lru_dict-1.3.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:3c497fb60279f1e1d7dfbe150b1b069eaa43f7e172dab03f206282f4994676c5"}, - {file = "lru_dict-1.3.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8d9509d817a47597988615c1a322580c10100acad10c98dfcf3abb41e0e5877f"}, - {file = "lru_dict-1.3.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0213ab4e3d9a8d386c18e485ad7b14b615cb6f05df6ef44fb2a0746c6ea9278b"}, - {file = "lru_dict-1.3.0-pp39-pypy39_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b50fbd69cd3287196796ab4d50e4cc741eb5b5a01f89d8e930df08da3010c385"}, - {file = "lru_dict-1.3.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:5247d1f011f92666010942434020ddc5a60951fefd5d12a594f0e5d9f43e3b3b"}, -] - -[package.extras] -test = ["pytest"] - -[[package]] -name = "mando" -version = "0.7.1" -description = "Create Python CLI apps with little to no effort at all!" -optional = false -python-versions = "*" -groups = ["dev"] -files = [ - {file = "mando-0.7.1-py2.py3-none-any.whl", hash = "sha256:26ef1d70928b6057ee3ca12583d73c63e05c49de8972d620c278a7b206581a8a"}, - {file = "mando-0.7.1.tar.gz", hash = "sha256:18baa999b4b613faefb00eac4efadcf14f510b59b924b66e08289aa1de8c3500"}, -] - -[package.dependencies] -six = "*" - -[package.extras] -restructuredtext = ["rst2ansi"] - -[[package]] -name = "markdown-it-py" -version = "4.0.0" -description = "Python port of markdown-it. Markdown parsing, done right!" -optional = false -python-versions = ">=3.10" -groups = ["dev"] -files = [ - {file = "markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147"}, - {file = "markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3"}, -] - -[package.dependencies] -mdurl = ">=0.1,<1.0" - -[package.extras] -benchmarking = ["psutil", "pytest", "pytest-benchmark"] -compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "markdown-it-pyrs", "mistletoe (>=1.0,<2.0)", "mistune (>=3.0,<4.0)", "panflute (>=2.3,<3.0)"] -linkify = ["linkify-it-py (>=1,<3)"] -plugins = ["mdit-py-plugins (>=0.5.0)"] -profiling = ["gprof2dot"] -rtd = ["ipykernel", "jupyter_sphinx", "mdit-py-plugins (>=0.5.0)", "myst-parser", "pyyaml", "sphinx", "sphinx-book-theme (>=1.0,<2.0)", "sphinx-copybutton", "sphinx-design"] -testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions", "requests"] - -[[package]] -name = "markupsafe" -version = "3.0.2" -description = "Safely add untrusted strings to HTML/XML markup." -optional = false -python-versions = ">=3.9" -groups = ["main", "dev"] -files = [ - {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8"}, - {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158"}, - {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38a9ef736c01fccdd6600705b09dc574584b89bea478200c5fbf112a6b0d5579"}, - {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbcb445fa71794da8f178f0f6d66789a28d7319071af7a496d4d507ed566270d"}, - {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57cb5a3cf367aeb1d316576250f65edec5bb3be939e9247ae594b4bcbc317dfb"}, - {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3809ede931876f5b2ec92eef964286840ed3540dadf803dd570c3b7e13141a3b"}, - {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e07c3764494e3776c602c1e78e298937c3315ccc9043ead7e685b7f2b8d47b3c"}, - {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b424c77b206d63d500bcb69fa55ed8d0e6a3774056bdc4839fc9298a7edca171"}, - {file = "MarkupSafe-3.0.2-cp310-cp310-win32.whl", hash = "sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50"}, - {file = "MarkupSafe-3.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-win32.whl", hash = "sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:eaa0a10b7f72326f1372a713e73c3f739b524b3af41feb43e4921cb529f5929a"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:48032821bbdf20f5799ff537c7ac3d1fba0ba032cfc06194faffa8cda8b560ff"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a9d3f5f0901fdec14d8d2f66ef7d035f2157240a433441719ac9a3fba440b13"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88b49a3b9ff31e19998750c38e030fc7bb937398b1f78cfa599aaef92d693144"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cfad01eed2c2e0c01fd0ecd2ef42c492f7f93902e39a42fc9ee1692961443a29"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1225beacc926f536dc82e45f8a4d68502949dc67eea90eab715dea3a21c1b5f0"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:3169b1eefae027567d1ce6ee7cae382c57fe26e82775f460f0b2778beaad66c0"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:eb7972a85c54febfb25b5c4b4f3af4dcc731994c7da0d8a0b4a6eb0640e1d178"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-win32.whl", hash = "sha256:8c4e8c3ce11e1f92f6536ff07154f9d49677ebaaafc32db9db4620bc11ed480f"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:6e296a513ca3d94054c2c881cc913116e90fd030ad1c656b3869762b754f5f8a"}, - {file = "markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0"}, -] - -[[package]] -name = "mashumaro" -version = "3.16" -description = "Fast and well tested serialization library" -optional = false -python-versions = ">=3.9" -groups = ["main", "dev"] -files = [ - {file = "mashumaro-3.16-py3-none-any.whl", hash = "sha256:d72782cdad5e164748ca883023bc5a214a80835cdca75826bf0bcbff827e0bd3"}, - {file = "mashumaro-3.16.tar.gz", hash = "sha256:3844137cf053bbac30c4cbd0ee9984e839a5731a0ef96fd3dd9388359af3f2e1"}, -] - -[package.dependencies] -typing-extensions = ">=4.1.0" - -[package.extras] -msgpack = ["msgpack (>=0.5.6)"] -orjson = ["orjson"] -toml = ["tomli (>=1.1.0) ; python_version < \"3.11\"", "tomli-w (>=1.0)"] -yaml = ["pyyaml (>=3.13)"] - -[[package]] -name = "mccabe" -version = "0.7.0" -description = "McCabe checker, plugin for flake8" -optional = false -python-versions = ">=3.6" -groups = ["dev"] -files = [ - {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"}, - {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, -] - -[[package]] -name = "mdurl" -version = "0.1.2" -description = "Markdown URL utilities" -optional = false -python-versions = ">=3.7" -groups = ["dev"] -files = [ - {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, - {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, -] - -[[package]] -name = "mock-open" -version = "1.4.0" -description = "A better mock for file I/O" -optional = false -python-versions = "*" -groups = ["dev"] -files = [ - {file = "mock-open-1.4.0.tar.gz", hash = "sha256:c3ecb6b8c32a5899a4f5bf4495083b598b520c698bba00e1ce2ace6e9c239100"}, -] - -[[package]] -name = "multidict" -version = "6.6.4" -description = "multidict implementation" -optional = false -python-versions = ">=3.9" -groups = ["main", "dev"] -files = [ - {file = "multidict-6.6.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b8aa6f0bd8125ddd04a6593437bad6a7e70f300ff4180a531654aa2ab3f6d58f"}, - {file = "multidict-6.6.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b9e5853bbd7264baca42ffc53391b490d65fe62849bf2c690fa3f6273dbcd0cb"}, - {file = "multidict-6.6.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0af5f9dee472371e36d6ae38bde009bd8ce65ac7335f55dcc240379d7bed1495"}, - {file = "multidict-6.6.4-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:d24f351e4d759f5054b641c81e8291e5d122af0fca5c72454ff77f7cbe492de8"}, - {file = "multidict-6.6.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:db6a3810eec08280a172a6cd541ff4a5f6a97b161d93ec94e6c4018917deb6b7"}, - {file = "multidict-6.6.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a1b20a9d56b2d81e2ff52ecc0670d583eaabaa55f402e8d16dd062373dbbe796"}, - {file = "multidict-6.6.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8c9854df0eaa610a23494c32a6f44a3a550fb398b6b51a56e8c6b9b3689578db"}, - {file = "multidict-6.6.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4bb7627fd7a968f41905a4d6343b0d63244a0623f006e9ed989fa2b78f4438a0"}, - {file = "multidict-6.6.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:caebafea30ed049c57c673d0b36238b1748683be2593965614d7b0e99125c877"}, - {file = "multidict-6.6.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ad887a8250eb47d3ab083d2f98db7f48098d13d42eb7a3b67d8a5c795f224ace"}, - {file = "multidict-6.6.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:ed8358ae7d94ffb7c397cecb62cbac9578a83ecefc1eba27b9090ee910e2efb6"}, - {file = "multidict-6.6.4-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:ecab51ad2462197a4c000b6d5701fc8585b80eecb90583635d7e327b7b6923eb"}, - {file = "multidict-6.6.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:c5c97aa666cf70e667dfa5af945424ba1329af5dd988a437efeb3a09430389fb"}, - {file = "multidict-6.6.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:9a950b7cf54099c1209f455ac5970b1ea81410f2af60ed9eb3c3f14f0bfcf987"}, - {file = "multidict-6.6.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:163c7ea522ea9365a8a57832dea7618e6cbdc3cd75f8c627663587459a4e328f"}, - {file = "multidict-6.6.4-cp310-cp310-win32.whl", hash = "sha256:17d2cbbfa6ff20821396b25890f155f40c986f9cfbce5667759696d83504954f"}, - {file = "multidict-6.6.4-cp310-cp310-win_amd64.whl", hash = "sha256:ce9a40fbe52e57e7edf20113a4eaddfacac0561a0879734e636aa6d4bb5e3fb0"}, - {file = "multidict-6.6.4-cp310-cp310-win_arm64.whl", hash = "sha256:01d0959807a451fe9fdd4da3e139cb5b77f7328baf2140feeaf233e1d777b729"}, - {file = "multidict-6.6.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c7a0e9b561e6460484318a7612e725df1145d46b0ef57c6b9866441bf6e27e0c"}, - {file = "multidict-6.6.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6bf2f10f70acc7a2446965ffbc726e5fc0b272c97a90b485857e5c70022213eb"}, - {file = "multidict-6.6.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:66247d72ed62d5dd29752ffc1d3b88f135c6a8de8b5f63b7c14e973ef5bda19e"}, - {file = "multidict-6.6.4-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:105245cc6b76f51e408451a844a54e6823bbd5a490ebfe5bdfc79798511ceded"}, - {file = "multidict-6.6.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cbbc54e58b34c3bae389ef00046be0961f30fef7cb0dd9c7756aee376a4f7683"}, - {file = "multidict-6.6.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:56c6b3652f945c9bc3ac6c8178cd93132b8d82dd581fcbc3a00676c51302bc1a"}, - {file = "multidict-6.6.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b95494daf857602eccf4c18ca33337dd2be705bccdb6dddbfc9d513e6addb9d9"}, - {file = "multidict-6.6.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e5b1413361cef15340ab9dc61523e653d25723e82d488ef7d60a12878227ed50"}, - {file = "multidict-6.6.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e167bf899c3d724f9662ef00b4f7fef87a19c22b2fead198a6f68b263618df52"}, - {file = "multidict-6.6.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:aaea28ba20a9026dfa77f4b80369e51cb767c61e33a2d4043399c67bd95fb7c6"}, - {file = "multidict-6.6.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:8c91cdb30809a96d9ecf442ec9bc45e8cfaa0f7f8bdf534e082c2443a196727e"}, - {file = "multidict-6.6.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1a0ccbfe93ca114c5d65a2471d52d8829e56d467c97b0e341cf5ee45410033b3"}, - {file = "multidict-6.6.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:55624b3f321d84c403cb7d8e6e982f41ae233d85f85db54ba6286f7295dc8a9c"}, - {file = "multidict-6.6.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:4a1fb393a2c9d202cb766c76208bd7945bc194eba8ac920ce98c6e458f0b524b"}, - {file = "multidict-6.6.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:43868297a5759a845fa3a483fb4392973a95fb1de891605a3728130c52b8f40f"}, - {file = "multidict-6.6.4-cp311-cp311-win32.whl", hash = "sha256:ed3b94c5e362a8a84d69642dbeac615452e8af9b8eb825b7bc9f31a53a1051e2"}, - {file = "multidict-6.6.4-cp311-cp311-win_amd64.whl", hash = "sha256:d8c112f7a90d8ca5d20213aa41eac690bb50a76da153e3afb3886418e61cb22e"}, - {file = "multidict-6.6.4-cp311-cp311-win_arm64.whl", hash = "sha256:3bb0eae408fa1996d87247ca0d6a57b7fc1dcf83e8a5c47ab82c558c250d4adf"}, - {file = "multidict-6.6.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0ffb87be160942d56d7b87b0fdf098e81ed565add09eaa1294268c7f3caac4c8"}, - {file = "multidict-6.6.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d191de6cbab2aff5de6c5723101705fd044b3e4c7cfd587a1929b5028b9714b3"}, - {file = "multidict-6.6.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:38a0956dd92d918ad5feff3db8fcb4a5eb7dba114da917e1a88475619781b57b"}, - {file = "multidict-6.6.4-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6865f6d3b7900ae020b495d599fcf3765653bc927951c1abb959017f81ae8287"}, - {file = "multidict-6.6.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a2088c126b6f72db6c9212ad827d0ba088c01d951cee25e758c450da732c138"}, - {file = "multidict-6.6.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0f37bed7319b848097085d7d48116f545985db988e2256b2e6f00563a3416ee6"}, - {file = "multidict-6.6.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:01368e3c94032ba6ca0b78e7ccb099643466cf24f8dc8eefcfdc0571d56e58f9"}, - {file = "multidict-6.6.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8fe323540c255db0bffee79ad7f048c909f2ab0edb87a597e1c17da6a54e493c"}, - {file = "multidict-6.6.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8eb3025f17b0a4c3cd08cda49acf312a19ad6e8a4edd9dbd591e6506d999402"}, - {file = "multidict-6.6.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bbc14f0365534d35a06970d6a83478b249752e922d662dc24d489af1aa0d1be7"}, - {file = "multidict-6.6.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:75aa52fba2d96bf972e85451b99d8e19cc37ce26fd016f6d4aa60da9ab2b005f"}, - {file = "multidict-6.6.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4fefd4a815e362d4f011919d97d7b4a1e566f1dde83dc4ad8cfb5b41de1df68d"}, - {file = "multidict-6.6.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:db9801fe021f59a5b375ab778973127ca0ac52429a26e2fd86aa9508f4d26eb7"}, - {file = "multidict-6.6.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:a650629970fa21ac1fb06ba25dabfc5b8a2054fcbf6ae97c758aa956b8dba802"}, - {file = "multidict-6.6.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:452ff5da78d4720d7516a3a2abd804957532dd69296cb77319c193e3ffb87e24"}, - {file = "multidict-6.6.4-cp312-cp312-win32.whl", hash = "sha256:8c2fcb12136530ed19572bbba61b407f655e3953ba669b96a35036a11a485793"}, - {file = "multidict-6.6.4-cp312-cp312-win_amd64.whl", hash = "sha256:047d9425860a8c9544fed1b9584f0c8bcd31bcde9568b047c5e567a1025ecd6e"}, - {file = "multidict-6.6.4-cp312-cp312-win_arm64.whl", hash = "sha256:14754eb72feaa1e8ae528468f24250dd997b8e2188c3d2f593f9eba259e4b364"}, - {file = "multidict-6.6.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f46a6e8597f9bd71b31cc708195d42b634c8527fecbcf93febf1052cacc1f16e"}, - {file = "multidict-6.6.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:22e38b2bc176c5eb9c0a0e379f9d188ae4cd8b28c0f53b52bce7ab0a9e534657"}, - {file = "multidict-6.6.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5df8afd26f162da59e218ac0eefaa01b01b2e6cd606cffa46608f699539246da"}, - {file = "multidict-6.6.4-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:49517449b58d043023720aa58e62b2f74ce9b28f740a0b5d33971149553d72aa"}, - {file = "multidict-6.6.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae9408439537c5afdca05edd128a63f56a62680f4b3c234301055d7a2000220f"}, - {file = "multidict-6.6.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:87a32d20759dc52a9e850fe1061b6e41ab28e2998d44168a8a341b99ded1dba0"}, - {file = "multidict-6.6.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:52e3c8d43cdfff587ceedce9deb25e6ae77daba560b626e97a56ddcad3756879"}, - {file = "multidict-6.6.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ad8850921d3a8d8ff6fbef790e773cecfc260bbfa0566998980d3fa8f520bc4a"}, - {file = "multidict-6.6.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:497a2954adc25c08daff36f795077f63ad33e13f19bfff7736e72c785391534f"}, - {file = "multidict-6.6.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:024ce601f92d780ca1617ad4be5ac15b501cc2414970ffa2bb2bbc2bd5a68fa5"}, - {file = "multidict-6.6.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:a693fc5ed9bdd1c9e898013e0da4dcc640de7963a371c0bd458e50e046bf6438"}, - {file = "multidict-6.6.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:190766dac95aab54cae5b152a56520fd99298f32a1266d66d27fdd1b5ac00f4e"}, - {file = "multidict-6.6.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:34d8f2a5ffdceab9dcd97c7a016deb2308531d5f0fced2bb0c9e1df45b3363d7"}, - {file = "multidict-6.6.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:59e8d40ab1f5a8597abcef00d04845155a5693b5da00d2c93dbe88f2050f2812"}, - {file = "multidict-6.6.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:467fe64138cfac771f0e949b938c2e1ada2b5af22f39692aa9258715e9ea613a"}, - {file = "multidict-6.6.4-cp313-cp313-win32.whl", hash = "sha256:14616a30fe6d0a48d0a48d1a633ab3b8bec4cf293aac65f32ed116f620adfd69"}, - {file = "multidict-6.6.4-cp313-cp313-win_amd64.whl", hash = "sha256:40cd05eaeb39e2bc8939451f033e57feaa2ac99e07dbca8afe2be450a4a3b6cf"}, - {file = "multidict-6.6.4-cp313-cp313-win_arm64.whl", hash = "sha256:f6eb37d511bfae9e13e82cb4d1af36b91150466f24d9b2b8a9785816deb16605"}, - {file = "multidict-6.6.4-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:6c84378acd4f37d1b507dfa0d459b449e2321b3ba5f2338f9b085cf7a7ba95eb"}, - {file = "multidict-6.6.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0e0558693063c75f3d952abf645c78f3c5dfdd825a41d8c4d8156fc0b0da6e7e"}, - {file = "multidict-6.6.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3f8e2384cb83ebd23fd07e9eada8ba64afc4c759cd94817433ab8c81ee4b403f"}, - {file = "multidict-6.6.4-cp313-cp313t-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:f996b87b420995a9174b2a7c1a8daf7db4750be6848b03eb5e639674f7963773"}, - {file = "multidict-6.6.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc356250cffd6e78416cf5b40dc6a74f1edf3be8e834cf8862d9ed5265cf9b0e"}, - {file = "multidict-6.6.4-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:dadf95aa862714ea468a49ad1e09fe00fcc9ec67d122f6596a8d40caf6cec7d0"}, - {file = "multidict-6.6.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7dd57515bebffd8ebd714d101d4c434063322e4fe24042e90ced41f18b6d3395"}, - {file = "multidict-6.6.4-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:967af5f238ebc2eb1da4e77af5492219fbd9b4b812347da39a7b5f5c72c0fa45"}, - {file = "multidict-6.6.4-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2a4c6875c37aae9794308ec43e3530e4aa0d36579ce38d89979bbf89582002bb"}, - {file = "multidict-6.6.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:7f683a551e92bdb7fac545b9c6f9fa2aebdeefa61d607510b3533286fcab67f5"}, - {file = "multidict-6.6.4-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:3ba5aaf600edaf2a868a391779f7a85d93bed147854925f34edd24cc70a3e141"}, - {file = "multidict-6.6.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:580b643b7fd2c295d83cad90d78419081f53fd532d1f1eb67ceb7060f61cff0d"}, - {file = "multidict-6.6.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:37b7187197da6af3ee0b044dbc9625afd0c885f2800815b228a0e70f9a7f473d"}, - {file = "multidict-6.6.4-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e1b93790ed0bc26feb72e2f08299691ceb6da5e9e14a0d13cc74f1869af327a0"}, - {file = "multidict-6.6.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a506a77ddee1efcca81ecbeae27ade3e09cdf21a8ae854d766c2bb4f14053f92"}, - {file = "multidict-6.6.4-cp313-cp313t-win32.whl", hash = "sha256:f93b2b2279883d1d0a9e1bd01f312d6fc315c5e4c1f09e112e4736e2f650bc4e"}, - {file = "multidict-6.6.4-cp313-cp313t-win_amd64.whl", hash = "sha256:6d46a180acdf6e87cc41dc15d8f5c2986e1e8739dc25dbb7dac826731ef381a4"}, - {file = "multidict-6.6.4-cp313-cp313t-win_arm64.whl", hash = "sha256:756989334015e3335d087a27331659820d53ba432befdef6a718398b0a8493ad"}, - {file = "multidict-6.6.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:af7618b591bae552b40dbb6f93f5518328a949dac626ee75927bba1ecdeea9f4"}, - {file = "multidict-6.6.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b6819f83aef06f560cb15482d619d0e623ce9bf155115150a85ab11b8342a665"}, - {file = "multidict-6.6.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4d09384e75788861e046330308e7af54dd306aaf20eb760eb1d0de26b2bea2cb"}, - {file = "multidict-6.6.4-cp39-cp39-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:a59c63061f1a07b861c004e53869eb1211ffd1a4acbca330e3322efa6dd02978"}, - {file = "multidict-6.6.4-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:350f6b0fe1ced61e778037fdc7613f4051c8baf64b1ee19371b42a3acdb016a0"}, - {file = "multidict-6.6.4-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0c5cbac6b55ad69cb6aa17ee9343dfbba903118fd530348c330211dc7aa756d1"}, - {file = "multidict-6.6.4-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:630f70c32b8066ddfd920350bc236225814ad94dfa493fe1910ee17fe4365cbb"}, - {file = "multidict-6.6.4-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f8d4916a81697faec6cb724a273bd5457e4c6c43d82b29f9dc02c5542fd21fc9"}, - {file = "multidict-6.6.4-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e42332cf8276bb7645d310cdecca93a16920256a5b01bebf747365f86a1675b"}, - {file = "multidict-6.6.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:f3be27440f7644ab9a13a6fc86f09cdd90b347c3c5e30c6d6d860de822d7cb53"}, - {file = "multidict-6.6.4-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:21f216669109e02ef3e2415ede07f4f8987f00de8cdfa0cc0b3440d42534f9f0"}, - {file = "multidict-6.6.4-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:d9890d68c45d1aeac5178ded1d1cccf3bc8d7accf1f976f79bf63099fb16e4bd"}, - {file = "multidict-6.6.4-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:edfdcae97cdc5d1a89477c436b61f472c4d40971774ac4729c613b4b133163cb"}, - {file = "multidict-6.6.4-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:0b2e886624be5773e69cf32bcb8534aecdeb38943520b240fed3d5596a430f2f"}, - {file = "multidict-6.6.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:be5bf4b3224948032a845d12ab0f69f208293742df96dc14c4ff9b09e508fc17"}, - {file = "multidict-6.6.4-cp39-cp39-win32.whl", hash = "sha256:10a68a9191f284fe9d501fef4efe93226e74df92ce7a24e301371293bd4918ae"}, - {file = "multidict-6.6.4-cp39-cp39-win_amd64.whl", hash = "sha256:ee25f82f53262f9ac93bd7e58e47ea1bdcc3393cef815847e397cba17e284210"}, - {file = "multidict-6.6.4-cp39-cp39-win_arm64.whl", hash = "sha256:f9867e55590e0855bcec60d4f9a092b69476db64573c9fe17e92b0c50614c16a"}, - {file = "multidict-6.6.4-py3-none-any.whl", hash = "sha256:27d8f8e125c07cb954e54d75d04905a9bba8a439c1d84aca94949d4d03d8601c"}, - {file = "multidict-6.6.4.tar.gz", hash = "sha256:d2d4e4787672911b48350df02ed3fa3fffdc2f2e8ca06dd6afdf34189b76a9dd"}, -] - -[[package]] -name = "mypy" -version = "1.18.2" -description = "Optional static typing for Python" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "mypy-1.18.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c1eab0cf6294dafe397c261a75f96dc2c31bffe3b944faa24db5def4e2b0f77c"}, - {file = "mypy-1.18.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7a780ca61fc239e4865968ebc5240bb3bf610ef59ac398de9a7421b54e4a207e"}, - {file = "mypy-1.18.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:448acd386266989ef11662ce3c8011fd2a7b632e0ec7d61a98edd8e27472225b"}, - {file = "mypy-1.18.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f9e171c465ad3901dc652643ee4bffa8e9fef4d7d0eece23b428908c77a76a66"}, - {file = "mypy-1.18.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:592ec214750bc00741af1f80cbf96b5013d81486b7bb24cb052382c19e40b428"}, - {file = "mypy-1.18.2-cp310-cp310-win_amd64.whl", hash = "sha256:7fb95f97199ea11769ebe3638c29b550b5221e997c63b14ef93d2e971606ebed"}, - {file = "mypy-1.18.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:807d9315ab9d464125aa9fcf6d84fde6e1dc67da0b6f80e7405506b8ac72bc7f"}, - {file = "mypy-1.18.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:776bb00de1778caf4db739c6e83919c1d85a448f71979b6a0edd774ea8399341"}, - {file = "mypy-1.18.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1379451880512ffce14505493bd9fe469e0697543717298242574882cf8cdb8d"}, - {file = "mypy-1.18.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1331eb7fd110d60c24999893320967594ff84c38ac6d19e0a76c5fd809a84c86"}, - {file = "mypy-1.18.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3ca30b50a51e7ba93b00422e486cbb124f1c56a535e20eff7b2d6ab72b3b2e37"}, - {file = "mypy-1.18.2-cp311-cp311-win_amd64.whl", hash = "sha256:664dc726e67fa54e14536f6e1224bcfce1d9e5ac02426d2326e2bb4e081d1ce8"}, - {file = "mypy-1.18.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:33eca32dd124b29400c31d7cf784e795b050ace0e1f91b8dc035672725617e34"}, - {file = "mypy-1.18.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a3c47adf30d65e89b2dcd2fa32f3aeb5e94ca970d2c15fcb25e297871c8e4764"}, - {file = "mypy-1.18.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d6c838e831a062f5f29d11c9057c6009f60cb294fea33a98422688181fe2893"}, - {file = "mypy-1.18.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01199871b6110a2ce984bde85acd481232d17413868c9807e95c1b0739a58914"}, - {file = "mypy-1.18.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a2afc0fa0b0e91b4599ddfe0f91e2c26c2b5a5ab263737e998d6817874c5f7c8"}, - {file = "mypy-1.18.2-cp312-cp312-win_amd64.whl", hash = "sha256:d8068d0afe682c7c4897c0f7ce84ea77f6de953262b12d07038f4d296d547074"}, - {file = "mypy-1.18.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:07b8b0f580ca6d289e69209ec9d3911b4a26e5abfde32228a288eb79df129fcc"}, - {file = "mypy-1.18.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ed4482847168439651d3feee5833ccedbf6657e964572706a2adb1f7fa4dfe2e"}, - {file = "mypy-1.18.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3ad2afadd1e9fea5cf99a45a822346971ede8685cc581ed9cd4d42eaf940986"}, - {file = "mypy-1.18.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a431a6f1ef14cf8c144c6b14793a23ec4eae3db28277c358136e79d7d062f62d"}, - {file = "mypy-1.18.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7ab28cc197f1dd77a67e1c6f35cd1f8e8b73ed2217e4fc005f9e6a504e46e7ba"}, - {file = "mypy-1.18.2-cp313-cp313-win_amd64.whl", hash = "sha256:0e2785a84b34a72ba55fb5daf079a1003a34c05b22238da94fcae2bbe46f3544"}, - {file = "mypy-1.18.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:62f0e1e988ad41c2a110edde6c398383a889d95b36b3e60bcf155f5164c4fdce"}, - {file = "mypy-1.18.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8795a039bab805ff0c1dfdb8cd3344642c2b99b8e439d057aba30850b8d3423d"}, - {file = "mypy-1.18.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6ca1e64b24a700ab5ce10133f7ccd956a04715463d30498e64ea8715236f9c9c"}, - {file = "mypy-1.18.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d924eef3795cc89fecf6bedc6ed32b33ac13e8321344f6ddbf8ee89f706c05cb"}, - {file = "mypy-1.18.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20c02215a080e3a2be3aa50506c67242df1c151eaba0dcbc1e4e557922a26075"}, - {file = "mypy-1.18.2-cp314-cp314-win_amd64.whl", hash = "sha256:749b5f83198f1ca64345603118a6f01a4e99ad4bf9d103ddc5a3200cc4614adf"}, - {file = "mypy-1.18.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:25a9c8fb67b00599f839cf472713f54249a62efd53a54b565eb61956a7e3296b"}, - {file = "mypy-1.18.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c2b9c7e284ee20e7598d6f42e13ca40b4928e6957ed6813d1ab6348aa3f47133"}, - {file = "mypy-1.18.2-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d6985ed057513e344e43a26cc1cd815c7a94602fb6a3130a34798625bc2f07b6"}, - {file = "mypy-1.18.2-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22f27105f1525ec024b5c630c0b9f36d5c1cc4d447d61fe51ff4bd60633f47ac"}, - {file = "mypy-1.18.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:030c52d0ea8144e721e49b1f68391e39553d7451f0c3f8a7565b59e19fcb608b"}, - {file = "mypy-1.18.2-cp39-cp39-win_amd64.whl", hash = "sha256:aa5e07ac1a60a253445797e42b8b2963c9675563a94f11291ab40718b016a7a0"}, - {file = "mypy-1.18.2-py3-none-any.whl", hash = "sha256:22a1748707dd62b58d2ae53562ffc4d7f8bcc727e8ac7cbc69c053ddc874d47e"}, - {file = "mypy-1.18.2.tar.gz", hash = "sha256:06a398102a5f203d7477b2923dda3634c36727fa5c237d8f859ef90c42a9924b"}, -] - -[package.dependencies] -mypy_extensions = ">=1.0.0" -pathspec = ">=0.9.0" -typing_extensions = ">=4.6.0" - -[package.extras] -dmypy = ["psutil (>=4.0)"] -faster-cache = ["orjson"] -install-types = ["pip"] -mypyc = ["setuptools (>=50)"] -reports = ["lxml"] - -[[package]] -name = "mypy-extensions" -version = "1.1.0" -description = "Type system extensions for programs checked with the mypy type checker." -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505"}, - {file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"}, -] - -[[package]] -name = "nodeenv" -version = "1.9.1" -description = "Node.js virtual environment builder" -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" -groups = ["dev"] -files = [ - {file = "nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9"}, - {file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"}, -] - -[[package]] -name = "numpy" -version = "2.3.2" -description = "Fundamental package for array computing in Python" -optional = false -python-versions = ">=3.11" -groups = ["main", "dev"] -files = [ - {file = "numpy-2.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:852ae5bed3478b92f093e30f785c98e0cb62fa0a939ed057c31716e18a7a22b9"}, - {file = "numpy-2.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7a0e27186e781a69959d0230dd9909b5e26024f8da10683bd6344baea1885168"}, - {file = "numpy-2.3.2-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:f0a1a8476ad77a228e41619af2fa9505cf69df928e9aaa165746584ea17fed2b"}, - {file = "numpy-2.3.2-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:cbc95b3813920145032412f7e33d12080f11dc776262df1712e1638207dde9e8"}, - {file = "numpy-2.3.2-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f75018be4980a7324edc5930fe39aa391d5734531b1926968605416ff58c332d"}, - {file = "numpy-2.3.2-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:20b8200721840f5621b7bd03f8dcd78de33ec522fc40dc2641aa09537df010c3"}, - {file = "numpy-2.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1f91e5c028504660d606340a084db4b216567ded1056ea2b4be4f9d10b67197f"}, - {file = "numpy-2.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:fb1752a3bb9a3ad2d6b090b88a9a0ae1cd6f004ef95f75825e2f382c183b2097"}, - {file = "numpy-2.3.2-cp311-cp311-win32.whl", hash = "sha256:4ae6863868aaee2f57503c7a5052b3a2807cf7a3914475e637a0ecd366ced220"}, - {file = "numpy-2.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:240259d6564f1c65424bcd10f435145a7644a65a6811cfc3201c4a429ba79170"}, - {file = "numpy-2.3.2-cp311-cp311-win_arm64.whl", hash = "sha256:4209f874d45f921bde2cff1ffcd8a3695f545ad2ffbef6d3d3c6768162efab89"}, - {file = "numpy-2.3.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bc3186bea41fae9d8e90c2b4fb5f0a1f5a690682da79b92574d63f56b529080b"}, - {file = "numpy-2.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2f4f0215edb189048a3c03bd5b19345bdfa7b45a7a6f72ae5945d2a28272727f"}, - {file = "numpy-2.3.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:8b1224a734cd509f70816455c3cffe13a4f599b1bf7130f913ba0e2c0b2006c0"}, - {file = "numpy-2.3.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:3dcf02866b977a38ba3ec10215220609ab9667378a9e2150615673f3ffd6c73b"}, - {file = "numpy-2.3.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:572d5512df5470f50ada8d1972c5f1082d9a0b7aa5944db8084077570cf98370"}, - {file = "numpy-2.3.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8145dd6d10df13c559d1e4314df29695613575183fa2e2d11fac4c208c8a1f73"}, - {file = "numpy-2.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:103ea7063fa624af04a791c39f97070bf93b96d7af7eb23530cd087dc8dbe9dc"}, - {file = "numpy-2.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc927d7f289d14f5e037be917539620603294454130b6de200091e23d27dc9be"}, - {file = "numpy-2.3.2-cp312-cp312-win32.whl", hash = "sha256:d95f59afe7f808c103be692175008bab926b59309ade3e6d25009e9a171f7036"}, - {file = "numpy-2.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:9e196ade2400c0c737d93465327d1ae7c06c7cb8a1756121ebf54b06ca183c7f"}, - {file = "numpy-2.3.2-cp312-cp312-win_arm64.whl", hash = "sha256:ee807923782faaf60d0d7331f5e86da7d5e3079e28b291973c545476c2b00d07"}, - {file = "numpy-2.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c8d9727f5316a256425892b043736d63e89ed15bbfe6556c5ff4d9d4448ff3b3"}, - {file = "numpy-2.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:efc81393f25f14d11c9d161e46e6ee348637c0a1e8a54bf9dedc472a3fae993b"}, - {file = "numpy-2.3.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:dd937f088a2df683cbb79dda9a772b62a3e5a8a7e76690612c2737f38c6ef1b6"}, - {file = "numpy-2.3.2-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:11e58218c0c46c80509186e460d79fbdc9ca1eb8d8aee39d8f2dc768eb781089"}, - {file = "numpy-2.3.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5ad4ebcb683a1f99f4f392cc522ee20a18b2bb12a2c1c42c3d48d5a1adc9d3d2"}, - {file = "numpy-2.3.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:938065908d1d869c7d75d8ec45f735a034771c6ea07088867f713d1cd3bbbe4f"}, - {file = "numpy-2.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:66459dccc65d8ec98cc7df61307b64bf9e08101f9598755d42d8ae65d9a7a6ee"}, - {file = "numpy-2.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a7af9ed2aa9ec5950daf05bb11abc4076a108bd3c7db9aa7251d5f107079b6a6"}, - {file = "numpy-2.3.2-cp313-cp313-win32.whl", hash = "sha256:906a30249315f9c8e17b085cc5f87d3f369b35fedd0051d4a84686967bdbbd0b"}, - {file = "numpy-2.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:c63d95dc9d67b676e9108fe0d2182987ccb0f11933c1e8959f42fa0da8d4fa56"}, - {file = "numpy-2.3.2-cp313-cp313-win_arm64.whl", hash = "sha256:b05a89f2fb84d21235f93de47129dd4f11c16f64c87c33f5e284e6a3a54e43f2"}, - {file = "numpy-2.3.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4e6ecfeddfa83b02318f4d84acf15fbdbf9ded18e46989a15a8b6995dfbf85ab"}, - {file = "numpy-2.3.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:508b0eada3eded10a3b55725b40806a4b855961040180028f52580c4729916a2"}, - {file = "numpy-2.3.2-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:754d6755d9a7588bdc6ac47dc4ee97867271b17cee39cb87aef079574366db0a"}, - {file = "numpy-2.3.2-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:a9f66e7d2b2d7712410d3bc5684149040ef5f19856f20277cd17ea83e5006286"}, - {file = "numpy-2.3.2-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de6ea4e5a65d5a90c7d286ddff2b87f3f4ad61faa3db8dabe936b34c2275b6f8"}, - {file = "numpy-2.3.2-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3ef07ec8cbc8fc9e369c8dcd52019510c12da4de81367d8b20bc692aa07573a"}, - {file = "numpy-2.3.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:27c9f90e7481275c7800dc9c24b7cc40ace3fdb970ae4d21eaff983a32f70c91"}, - {file = "numpy-2.3.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:07b62978075b67eee4065b166d000d457c82a1efe726cce608b9db9dd66a73a5"}, - {file = "numpy-2.3.2-cp313-cp313t-win32.whl", hash = "sha256:c771cfac34a4f2c0de8e8c97312d07d64fd8f8ed45bc9f5726a7e947270152b5"}, - {file = "numpy-2.3.2-cp313-cp313t-win_amd64.whl", hash = "sha256:72dbebb2dcc8305c431b2836bcc66af967df91be793d63a24e3d9b741374c450"}, - {file = "numpy-2.3.2-cp313-cp313t-win_arm64.whl", hash = "sha256:72c6df2267e926a6d5286b0a6d556ebe49eae261062059317837fda12ddf0c1a"}, - {file = "numpy-2.3.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:448a66d052d0cf14ce9865d159bfc403282c9bc7bb2a31b03cc18b651eca8b1a"}, - {file = "numpy-2.3.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:546aaf78e81b4081b2eba1d105c3b34064783027a06b3ab20b6eba21fb64132b"}, - {file = "numpy-2.3.2-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:87c930d52f45df092f7578889711a0768094debf73cfcde105e2d66954358125"}, - {file = "numpy-2.3.2-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:8dc082ea901a62edb8f59713c6a7e28a85daddcb67454c839de57656478f5b19"}, - {file = "numpy-2.3.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:af58de8745f7fa9ca1c0c7c943616c6fe28e75d0c81f5c295810e3c83b5be92f"}, - {file = "numpy-2.3.2-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed5527c4cf10f16c6d0b6bee1f89958bccb0ad2522c8cadc2efd318bcd545f5"}, - {file = "numpy-2.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:095737ed986e00393ec18ec0b21b47c22889ae4b0cd2d5e88342e08b01141f58"}, - {file = "numpy-2.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5e40e80299607f597e1a8a247ff8d71d79c5b52baa11cc1cce30aa92d2da6e0"}, - {file = "numpy-2.3.2-cp314-cp314-win32.whl", hash = "sha256:7d6e390423cc1f76e1b8108c9b6889d20a7a1f59d9a60cac4a050fa734d6c1e2"}, - {file = "numpy-2.3.2-cp314-cp314-win_amd64.whl", hash = "sha256:b9d0878b21e3918d76d2209c924ebb272340da1fb51abc00f986c258cd5e957b"}, - {file = "numpy-2.3.2-cp314-cp314-win_arm64.whl", hash = "sha256:2738534837c6a1d0c39340a190177d7d66fdf432894f469728da901f8f6dc910"}, - {file = "numpy-2.3.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:4d002ecf7c9b53240be3bb69d80f86ddbd34078bae04d87be81c1f58466f264e"}, - {file = "numpy-2.3.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:293b2192c6bcce487dbc6326de5853787f870aeb6c43f8f9c6496db5b1781e45"}, - {file = "numpy-2.3.2-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:0a4f2021a6da53a0d580d6ef5db29947025ae8b35b3250141805ea9a32bbe86b"}, - {file = "numpy-2.3.2-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:9c144440db4bf3bb6372d2c3e49834cc0ff7bb4c24975ab33e01199e645416f2"}, - {file = "numpy-2.3.2-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f92d6c2a8535dc4fe4419562294ff957f83a16ebdec66df0805e473ffaad8bd0"}, - {file = "numpy-2.3.2-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cefc2219baa48e468e3db7e706305fcd0c095534a192a08f31e98d83a7d45fb0"}, - {file = "numpy-2.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:76c3e9501ceb50b2ff3824c3589d5d1ab4ac857b0ee3f8f49629d0de55ecf7c2"}, - {file = "numpy-2.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:122bf5ed9a0221b3419672493878ba4967121514b1d7d4656a7580cd11dddcbf"}, - {file = "numpy-2.3.2-cp314-cp314t-win32.whl", hash = "sha256:6f1ae3dcb840edccc45af496f312528c15b1f79ac318169d094e85e4bb35fdf1"}, - {file = "numpy-2.3.2-cp314-cp314t-win_amd64.whl", hash = "sha256:087ffc25890d89a43536f75c5fe8770922008758e8eeeef61733957041ed2f9b"}, - {file = "numpy-2.3.2-cp314-cp314t-win_arm64.whl", hash = "sha256:092aeb3449833ea9c0bf0089d70c29ae480685dd2377ec9cdbbb620257f84631"}, - {file = "numpy-2.3.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:14a91ebac98813a49bc6aa1a0dfc09513dcec1d97eaf31ca21a87221a1cdcb15"}, - {file = "numpy-2.3.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:71669b5daae692189540cffc4c439468d35a3f84f0c88b078ecd94337f6cb0ec"}, - {file = "numpy-2.3.2-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:69779198d9caee6e547adb933941ed7520f896fd9656834c300bdf4dd8642712"}, - {file = "numpy-2.3.2-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:2c3271cc4097beb5a60f010bcc1cc204b300bb3eafb4399376418a83a1c6373c"}, - {file = "numpy-2.3.2-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8446acd11fe3dc1830568c941d44449fd5cb83068e5c70bd5a470d323d448296"}, - {file = "numpy-2.3.2-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aa098a5ab53fa407fded5870865c6275a5cd4101cfdef8d6fafc48286a96e981"}, - {file = "numpy-2.3.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6936aff90dda378c09bea075af0d9c675fe3a977a9d2402f95a87f440f59f619"}, - {file = "numpy-2.3.2.tar.gz", hash = "sha256:e0486a11ec30cdecb53f184d496d1c6a20786c81e55e41640270130056f8ee48"}, -] - -[[package]] -name = "orjson" -version = "3.11.3" -description = "Fast, correct Python JSON library supporting dataclasses, datetimes, and numpy" -optional = false -python-versions = ">=3.9" -groups = ["main", "dev"] -files = [ - {file = "orjson-3.11.3-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:29cb1f1b008d936803e2da3d7cba726fc47232c45df531b29edf0b232dd737e7"}, - {file = "orjson-3.11.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:97dceed87ed9139884a55db8722428e27bd8452817fbf1869c58b49fecab1120"}, - {file = "orjson-3.11.3-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:58533f9e8266cb0ac298e259ed7b4d42ed3fa0b78ce76860626164de49e0d467"}, - {file = "orjson-3.11.3-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0c212cfdd90512fe722fa9bd620de4d46cda691415be86b2e02243242ae81873"}, - {file = "orjson-3.11.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5ff835b5d3e67d9207343effb03760c00335f8b5285bfceefd4dc967b0e48f6a"}, - {file = "orjson-3.11.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f5aa4682912a450c2db89cbd92d356fef47e115dffba07992555542f344d301b"}, - {file = "orjson-3.11.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d7d18dd34ea2e860553a579df02041845dee0af8985dff7f8661306f95504ddf"}, - {file = "orjson-3.11.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d8b11701bc43be92ea42bd454910437b355dfb63696c06fe953ffb40b5f763b4"}, - {file = "orjson-3.11.3-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:90368277087d4af32d38bd55f9da2ff466d25325bf6167c8f382d8ee40cb2bbc"}, - {file = "orjson-3.11.3-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:fd7ff459fb393358d3a155d25b275c60b07a2c83dcd7ea962b1923f5a1134569"}, - {file = "orjson-3.11.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f8d902867b699bcd09c176a280b1acdab57f924489033e53d0afe79817da37e6"}, - {file = "orjson-3.11.3-cp310-cp310-win32.whl", hash = "sha256:bb93562146120bb51e6b154962d3dadc678ed0fce96513fa6bc06599bb6f6edc"}, - {file = "orjson-3.11.3-cp310-cp310-win_amd64.whl", hash = "sha256:976c6f1975032cc327161c65d4194c549f2589d88b105a5e3499429a54479770"}, - {file = "orjson-3.11.3-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9d2ae0cc6aeb669633e0124531f342a17d8e97ea999e42f12a5ad4adaa304c5f"}, - {file = "orjson-3.11.3-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:ba21dbb2493e9c653eaffdc38819b004b7b1b246fb77bfc93dc016fe664eac91"}, - {file = "orjson-3.11.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:00f1a271e56d511d1569937c0447d7dce5a99a33ea0dec76673706360a051904"}, - {file = "orjson-3.11.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b67e71e47caa6680d1b6f075a396d04fa6ca8ca09aafb428731da9b3ea32a5a6"}, - {file = "orjson-3.11.3-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d7d012ebddffcce8c85734a6d9e5f08180cd3857c5f5a3ac70185b43775d043d"}, - {file = "orjson-3.11.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dd759f75d6b8d1b62012b7f5ef9461d03c804f94d539a5515b454ba3a6588038"}, - {file = "orjson-3.11.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6890ace0809627b0dff19cfad92d69d0fa3f089d3e359a2a532507bb6ba34efb"}, - {file = "orjson-3.11.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9d4a5e041ae435b815e568537755773d05dac031fee6a57b4ba70897a44d9d2"}, - {file = "orjson-3.11.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2d68bf97a771836687107abfca089743885fb664b90138d8761cce61d5625d55"}, - {file = "orjson-3.11.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:bfc27516ec46f4520b18ef645864cee168d2a027dbf32c5537cb1f3e3c22dac1"}, - {file = "orjson-3.11.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:f66b001332a017d7945e177e282a40b6997056394e3ed7ddb41fb1813b83e824"}, - {file = "orjson-3.11.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:212e67806525d2561efbfe9e799633b17eb668b8964abed6b5319b2f1cfbae1f"}, - {file = "orjson-3.11.3-cp311-cp311-win32.whl", hash = "sha256:6e8e0c3b85575a32f2ffa59de455f85ce002b8bdc0662d6b9c2ed6d80ab5d204"}, - {file = "orjson-3.11.3-cp311-cp311-win_amd64.whl", hash = "sha256:6be2f1b5d3dc99a5ce5ce162fc741c22ba9f3443d3dd586e6a1211b7bc87bc7b"}, - {file = "orjson-3.11.3-cp311-cp311-win_arm64.whl", hash = "sha256:fafb1a99d740523d964b15c8db4eabbfc86ff29f84898262bf6e3e4c9e97e43e"}, - {file = "orjson-3.11.3-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:8c752089db84333e36d754c4baf19c0e1437012242048439c7e80eb0e6426e3b"}, - {file = "orjson-3.11.3-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:9b8761b6cf04a856eb544acdd82fc594b978f12ac3602d6374a7edb9d86fd2c2"}, - {file = "orjson-3.11.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b13974dc8ac6ba22feaa867fc19135a3e01a134b4f7c9c28162fed4d615008a"}, - {file = "orjson-3.11.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f83abab5bacb76d9c821fd5c07728ff224ed0e52d7a71b7b3de822f3df04e15c"}, - {file = "orjson-3.11.3-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e6fbaf48a744b94091a56c62897b27c31ee2da93d826aa5b207131a1e13d4064"}, - {file = "orjson-3.11.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bc779b4f4bba2847d0d2940081a7b6f7b5877e05408ffbb74fa1faf4a136c424"}, - {file = "orjson-3.11.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd4b909ce4c50faa2192da6bb684d9848d4510b736b0611b6ab4020ea6fd2d23"}, - {file = "orjson-3.11.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:524b765ad888dc5518bbce12c77c2e83dee1ed6b0992c1790cc5fb49bb4b6667"}, - {file = "orjson-3.11.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:84fd82870b97ae3cdcea9d8746e592b6d40e1e4d4527835fc520c588d2ded04f"}, - {file = "orjson-3.11.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:fbecb9709111be913ae6879b07bafd4b0785b44c1eb5cac8ac76da048b3885a1"}, - {file = "orjson-3.11.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:9dba358d55aee552bd868de348f4736ca5a4086d9a62e2bfbbeeb5629fe8b0cc"}, - {file = "orjson-3.11.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:eabcf2e84f1d7105f84580e03012270c7e97ecb1fb1618bda395061b2a84a049"}, - {file = "orjson-3.11.3-cp312-cp312-win32.whl", hash = "sha256:3782d2c60b8116772aea8d9b7905221437fdf53e7277282e8d8b07c220f96cca"}, - {file = "orjson-3.11.3-cp312-cp312-win_amd64.whl", hash = "sha256:79b44319268af2eaa3e315b92298de9a0067ade6e6003ddaef72f8e0bedb94f1"}, - {file = "orjson-3.11.3-cp312-cp312-win_arm64.whl", hash = "sha256:0e92a4e83341ef79d835ca21b8bd13e27c859e4e9e4d7b63defc6e58462a3710"}, - {file = "orjson-3.11.3-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:af40c6612fd2a4b00de648aa26d18186cd1322330bd3a3cc52f87c699e995810"}, - {file = "orjson-3.11.3-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:9f1587f26c235894c09e8b5b7636a38091a9e6e7fe4531937534749c04face43"}, - {file = "orjson-3.11.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:61dcdad16da5bb486d7227a37a2e789c429397793a6955227cedbd7252eb5a27"}, - {file = "orjson-3.11.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:11c6d71478e2cbea0a709e8a06365fa63da81da6498a53e4c4f065881d21ae8f"}, - {file = "orjson-3.11.3-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ff94112e0098470b665cb0ed06efb187154b63649403b8d5e9aedeb482b4548c"}, - {file = "orjson-3.11.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ae8b756575aaa2a855a75192f356bbda11a89169830e1439cfb1a3e1a6dde7be"}, - {file = "orjson-3.11.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c9416cc19a349c167ef76135b2fe40d03cea93680428efee8771f3e9fb66079d"}, - {file = "orjson-3.11.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b822caf5b9752bc6f246eb08124c3d12bf2175b66ab74bac2ef3bbf9221ce1b2"}, - {file = "orjson-3.11.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:414f71e3bdd5573893bf5ecdf35c32b213ed20aa15536fe2f588f946c318824f"}, - {file = "orjson-3.11.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:828e3149ad8815dc14468f36ab2a4b819237c155ee1370341b91ea4c8672d2ee"}, - {file = "orjson-3.11.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ac9e05f25627ffc714c21f8dfe3a579445a5c392a9c8ae7ba1d0e9fb5333f56e"}, - {file = "orjson-3.11.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e44fbe4000bd321d9f3b648ae46e0196d21577cf66ae684a96ff90b1f7c93633"}, - {file = "orjson-3.11.3-cp313-cp313-win32.whl", hash = "sha256:2039b7847ba3eec1f5886e75e6763a16e18c68a63efc4b029ddf994821e2e66b"}, - {file = "orjson-3.11.3-cp313-cp313-win_amd64.whl", hash = "sha256:29be5ac4164aa8bdcba5fa0700a3c9c316b411d8ed9d39ef8a882541bd452fae"}, - {file = "orjson-3.11.3-cp313-cp313-win_arm64.whl", hash = "sha256:18bd1435cb1f2857ceb59cfb7de6f92593ef7b831ccd1b9bfb28ca530e539dce"}, - {file = "orjson-3.11.3-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:cf4b81227ec86935568c7edd78352a92e97af8da7bd70bdfdaa0d2e0011a1ab4"}, - {file = "orjson-3.11.3-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:bc8bc85b81b6ac9fc4dae393a8c159b817f4c2c9dee5d12b773bddb3b95fc07e"}, - {file = "orjson-3.11.3-cp314-cp314-manylinux_2_34_aarch64.whl", hash = "sha256:88dcfc514cfd1b0de038443c7b3e6a9797ffb1b3674ef1fd14f701a13397f82d"}, - {file = "orjson-3.11.3-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:d61cd543d69715d5fc0a690c7c6f8dcc307bc23abef9738957981885f5f38229"}, - {file = "orjson-3.11.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2b7b153ed90ababadbef5c3eb39549f9476890d339cf47af563aea7e07db2451"}, - {file = "orjson-3.11.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:7909ae2460f5f494fecbcd10613beafe40381fd0316e35d6acb5f3a05bfda167"}, - {file = "orjson-3.11.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:2030c01cbf77bc67bee7eef1e7e31ecf28649353987775e3583062c752da0077"}, - {file = "orjson-3.11.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a0169ebd1cbd94b26c7a7ad282cf5c2744fce054133f959e02eb5265deae1872"}, - {file = "orjson-3.11.3-cp314-cp314-win32.whl", hash = "sha256:0c6d7328c200c349e3a4c6d8c83e0a5ad029bdc2d417f234152bf34842d0fc8d"}, - {file = "orjson-3.11.3-cp314-cp314-win_amd64.whl", hash = "sha256:317bbe2c069bbc757b1a2e4105b64aacd3bc78279b66a6b9e51e846e4809f804"}, - {file = "orjson-3.11.3-cp314-cp314-win_arm64.whl", hash = "sha256:e8f6a7a27d7b7bec81bd5924163e9af03d49bbb63013f107b48eb5d16db711bc"}, - {file = "orjson-3.11.3-cp39-cp39-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:56afaf1e9b02302ba636151cfc49929c1bb66b98794291afd0e5f20fecaf757c"}, - {file = "orjson-3.11.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:913f629adef31d2d350d41c051ce7e33cf0fd06a5d1cb28d49b1899b23b903aa"}, - {file = "orjson-3.11.3-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e0a23b41f8f98b4e61150a03f83e4f0d566880fe53519d445a962929a4d21045"}, - {file = "orjson-3.11.3-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3d721fee37380a44f9d9ce6c701b3960239f4fb3d5ceea7f31cbd43882edaa2f"}, - {file = "orjson-3.11.3-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73b92a5b69f31b1a58c0c7e31080aeaec49c6e01b9522e71ff38d08f15aa56de"}, - {file = "orjson-3.11.3-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d2489b241c19582b3f1430cc5d732caefc1aaf378d97e7fb95b9e56bed11725f"}, - {file = "orjson-3.11.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c5189a5dab8b0312eadaf9d58d3049b6a52c454256493a557405e77a3d67ab7f"}, - {file = "orjson-3.11.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:9d8787bdfbb65a85ea76d0e96a3b1bed7bf0fbcb16d40408dc1172ad784a49d2"}, - {file = "orjson-3.11.3-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:8e531abd745f51f8035e207e75e049553a86823d189a51809c078412cefb399a"}, - {file = "orjson-3.11.3-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:8ab962931015f170b97a3dd7bd933399c1bae8ed8ad0fb2a7151a5654b6941c7"}, - {file = "orjson-3.11.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:124d5ba71fee9c9902c4a7baa9425e663f7f0aecf73d31d54fe3dd357d62c1a7"}, - {file = "orjson-3.11.3-cp39-cp39-win32.whl", hash = "sha256:22724d80ee5a815a44fc76274bb7ba2e7464f5564aacb6ecddaa9970a83e3225"}, - {file = "orjson-3.11.3-cp39-cp39-win_amd64.whl", hash = "sha256:215c595c792a87d4407cb72dd5e0f6ee8e694ceeb7f9102b533c5a9bf2a916bb"}, - {file = "orjson-3.11.3.tar.gz", hash = "sha256:1c0603b1d2ffcd43a411d64797a19556ef76958aef1c182f22dc30860152a98a"}, -] - -[[package]] -name = "packaging" -version = "25.0" -description = "Core utilities for Python packages" -optional = false -python-versions = ">=3.8" -groups = ["main", "dev"] -files = [ - {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, - {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, -] - -[[package]] -name = "paho-mqtt" -version = "2.1.0" -description = "MQTT version 5.0/3.1.1 client class" -optional = false -python-versions = ">=3.7" -groups = ["dev"] -files = [ - {file = "paho_mqtt-2.1.0-py3-none-any.whl", hash = "sha256:6db9ba9b34ed5bc6b6e3812718c7e06e2fd7444540df2455d2c51bd58808feee"}, - {file = "paho_mqtt-2.1.0.tar.gz", hash = "sha256:12d6e7511d4137555a3f6ea167ae846af2c7357b10bc6fa4f7c3968fc1723834"}, -] - -[package.extras] -proxy = ["pysocks"] - -[[package]] -name = "pathspec" -version = "0.12.1" -description = "Utility library for gitignore style pattern matching of file paths." -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, - {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, -] - -[[package]] -name = "pillow" -version = "11.3.0" -description = "Python Imaging Library (Fork)" -optional = false -python-versions = ">=3.9" -groups = ["main", "dev"] -files = [ - {file = "pillow-11.3.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:1b9c17fd4ace828b3003dfd1e30bff24863e0eb59b535e8f80194d9cc7ecf860"}, - {file = "pillow-11.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:65dc69160114cdd0ca0f35cb434633c75e8e7fad4cf855177a05bf38678f73ad"}, - {file = "pillow-11.3.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7107195ddc914f656c7fc8e4a5e1c25f32e9236ea3ea860f257b0436011fddd0"}, - {file = "pillow-11.3.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc3e831b563b3114baac7ec2ee86819eb03caa1a2cef0b481a5675b59c4fe23b"}, - {file = "pillow-11.3.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f1f182ebd2303acf8c380a54f615ec883322593320a9b00438eb842c1f37ae50"}, - {file = "pillow-11.3.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4445fa62e15936a028672fd48c4c11a66d641d2c05726c7ec1f8ba6a572036ae"}, - {file = "pillow-11.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:71f511f6b3b91dd543282477be45a033e4845a40278fa8dcdbfdb07109bf18f9"}, - {file = "pillow-11.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:040a5b691b0713e1f6cbe222e0f4f74cd233421e105850ae3b3c0ceda520f42e"}, - {file = "pillow-11.3.0-cp310-cp310-win32.whl", hash = "sha256:89bd777bc6624fe4115e9fac3352c79ed60f3bb18651420635f26e643e3dd1f6"}, - {file = "pillow-11.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:19d2ff547c75b8e3ff46f4d9ef969a06c30ab2d4263a9e287733aa8b2429ce8f"}, - {file = "pillow-11.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:819931d25e57b513242859ce1876c58c59dc31587847bf74cfe06b2e0cb22d2f"}, - {file = "pillow-11.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:1cd110edf822773368b396281a2293aeb91c90a2db00d78ea43e7e861631b722"}, - {file = "pillow-11.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9c412fddd1b77a75aa904615ebaa6001f169b26fd467b4be93aded278266b288"}, - {file = "pillow-11.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1aa4de119a0ecac0a34a9c8bde33f34022e2e8f99104e47a3ca392fd60e37d"}, - {file = "pillow-11.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:91da1d88226663594e3f6b4b8c3c8d85bd504117d043740a8e0ec449087cc494"}, - {file = "pillow-11.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:643f189248837533073c405ec2f0bb250ba54598cf80e8c1e043381a60632f58"}, - {file = "pillow-11.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:106064daa23a745510dabce1d84f29137a37224831d88eb4ce94bb187b1d7e5f"}, - {file = "pillow-11.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd8ff254faf15591e724dc7c4ddb6bf4793efcbe13802a4ae3e863cd300b493e"}, - {file = "pillow-11.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:932c754c2d51ad2b2271fd01c3d121daaa35e27efae2a616f77bf164bc0b3e94"}, - {file = "pillow-11.3.0-cp311-cp311-win32.whl", hash = "sha256:b4b8f3efc8d530a1544e5962bd6b403d5f7fe8b9e08227c6b255f98ad82b4ba0"}, - {file = "pillow-11.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:1a992e86b0dd7aeb1f053cd506508c0999d710a8f07b4c791c63843fc6a807ac"}, - {file = "pillow-11.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:30807c931ff7c095620fe04448e2c2fc673fcbb1ffe2a7da3fb39613489b1ddd"}, - {file = "pillow-11.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fdae223722da47b024b867c1ea0be64e0df702c5e0a60e27daad39bf960dd1e4"}, - {file = "pillow-11.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:921bd305b10e82b4d1f5e802b6850677f965d8394203d182f078873851dada69"}, - {file = "pillow-11.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb76541cba2f958032d79d143b98a3a6b3ea87f0959bbe256c0b5e416599fd5d"}, - {file = "pillow-11.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67172f2944ebba3d4a7b54f2e95c786a3a50c21b88456329314caaa28cda70f6"}, - {file = "pillow-11.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f07ed9f56a3b9b5f49d3661dc9607484e85c67e27f3e8be2c7d28ca032fec7"}, - {file = "pillow-11.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:676b2815362456b5b3216b4fd5bd89d362100dc6f4945154ff172e206a22c024"}, - {file = "pillow-11.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3e184b2f26ff146363dd07bde8b711833d7b0202e27d13540bfe2e35a323a809"}, - {file = "pillow-11.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6be31e3fc9a621e071bc17bb7de63b85cbe0bfae91bb0363c893cbe67247780d"}, - {file = "pillow-11.3.0-cp312-cp312-win32.whl", hash = "sha256:7b161756381f0918e05e7cb8a371fff367e807770f8fe92ecb20d905d0e1c149"}, - {file = "pillow-11.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a6444696fce635783440b7f7a9fc24b3ad10a9ea3f0ab66c5905be1c19ccf17d"}, - {file = "pillow-11.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:2aceea54f957dd4448264f9bf40875da0415c83eb85f55069d89c0ed436e3542"}, - {file = "pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:1c627742b539bba4309df89171356fcb3cc5a9178355b2727d1b74a6cf155fbd"}, - {file = "pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:30b7c02f3899d10f13d7a48163c8969e4e653f8b43416d23d13d1bbfdc93b9f8"}, - {file = "pillow-11.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7859a4cc7c9295f5838015d8cc0a9c215b77e43d07a25e460f35cf516df8626f"}, - {file = "pillow-11.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec1ee50470b0d050984394423d96325b744d55c701a439d2bd66089bff963d3c"}, - {file = "pillow-11.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7db51d222548ccfd274e4572fdbf3e810a5e66b00608862f947b163e613b67dd"}, - {file = "pillow-11.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2d6fcc902a24ac74495df63faad1884282239265c6839a0a6416d33faedfae7e"}, - {file = "pillow-11.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0f5d8f4a08090c6d6d578351a2b91acf519a54986c055af27e7a93feae6d3f1"}, - {file = "pillow-11.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c37d8ba9411d6003bba9e518db0db0c58a680ab9fe5179f040b0463644bc9805"}, - {file = "pillow-11.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13f87d581e71d9189ab21fe0efb5a23e9f28552d5be6979e84001d3b8505abe8"}, - {file = "pillow-11.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:023f6d2d11784a465f09fd09a34b150ea4672e85fb3d05931d89f373ab14abb2"}, - {file = "pillow-11.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:45dfc51ac5975b938e9809451c51734124e73b04d0f0ac621649821a63852e7b"}, - {file = "pillow-11.3.0-cp313-cp313-win32.whl", hash = "sha256:a4d336baed65d50d37b88ca5b60c0fa9d81e3a87d4a7930d3880d1624d5b31f3"}, - {file = "pillow-11.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bce5c4fd0921f99d2e858dc4d4d64193407e1b99478bc5cacecba2311abde51"}, - {file = "pillow-11.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:1904e1264881f682f02b7f8167935cce37bc97db457f8e7849dc3a6a52b99580"}, - {file = "pillow-11.3.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4c834a3921375c48ee6b9624061076bc0a32a60b5532b322cc0ea64e639dd50e"}, - {file = "pillow-11.3.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5e05688ccef30ea69b9317a9ead994b93975104a677a36a8ed8106be9260aa6d"}, - {file = "pillow-11.3.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1019b04af07fc0163e2810167918cb5add8d74674b6267616021ab558dc98ced"}, - {file = "pillow-11.3.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f944255db153ebb2b19c51fe85dd99ef0ce494123f21b9db4877ffdfc5590c7c"}, - {file = "pillow-11.3.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f85acb69adf2aaee8b7da124efebbdb959a104db34d3a2cb0f3793dbae422a8"}, - {file = "pillow-11.3.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05f6ecbeff5005399bb48d198f098a9b4b6bdf27b8487c7f38ca16eeb070cd59"}, - {file = "pillow-11.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a7bc6e6fd0395bc052f16b1a8670859964dbd7003bd0af2ff08342eb6e442cfe"}, - {file = "pillow-11.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:83e1b0161c9d148125083a35c1c5a89db5b7054834fd4387499e06552035236c"}, - {file = "pillow-11.3.0-cp313-cp313t-win32.whl", hash = "sha256:2a3117c06b8fb646639dce83694f2f9eac405472713fcb1ae887469c0d4f6788"}, - {file = "pillow-11.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:857844335c95bea93fb39e0fa2726b4d9d758850b34075a7e3ff4f4fa3aa3b31"}, - {file = "pillow-11.3.0-cp313-cp313t-win_arm64.whl", hash = "sha256:8797edc41f3e8536ae4b10897ee2f637235c94f27404cac7297f7b607dd0716e"}, - {file = "pillow-11.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d9da3df5f9ea2a89b81bb6087177fb1f4d1c7146d583a3fe5c672c0d94e55e12"}, - {file = "pillow-11.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0b275ff9b04df7b640c59ec5a3cb113eefd3795a8df80bac69646ef699c6981a"}, - {file = "pillow-11.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0743841cabd3dba6a83f38a92672cccbd69af56e3e91777b0ee7f4dba4385632"}, - {file = "pillow-11.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2465a69cf967b8b49ee1b96d76718cd98c4e925414ead59fdf75cf0fd07df673"}, - {file = "pillow-11.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41742638139424703b4d01665b807c6468e23e699e8e90cffefe291c5832b027"}, - {file = "pillow-11.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93efb0b4de7e340d99057415c749175e24c8864302369e05914682ba642e5d77"}, - {file = "pillow-11.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7966e38dcd0fa11ca390aed7c6f20454443581d758242023cf36fcb319b1a874"}, - {file = "pillow-11.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:98a9afa7b9007c67ed84c57c9e0ad86a6000da96eaa638e4f8abe5b65ff83f0a"}, - {file = "pillow-11.3.0-cp314-cp314-win32.whl", hash = "sha256:02a723e6bf909e7cea0dac1b0e0310be9d7650cd66222a5f1c571455c0a45214"}, - {file = "pillow-11.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:a418486160228f64dd9e9efcd132679b7a02a5f22c982c78b6fc7dab3fefb635"}, - {file = "pillow-11.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:155658efb5e044669c08896c0c44231c5e9abcaadbc5cd3648df2f7c0b96b9a6"}, - {file = "pillow-11.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:59a03cdf019efbfeeed910bf79c7c93255c3d54bc45898ac2a4140071b02b4ae"}, - {file = "pillow-11.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f8a5827f84d973d8636e9dc5764af4f0cf2318d26744b3d902931701b0d46653"}, - {file = "pillow-11.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ee92f2fd10f4adc4b43d07ec5e779932b4eb3dbfbc34790ada5a6669bc095aa6"}, - {file = "pillow-11.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c96d333dcf42d01f47b37e0979b6bd73ec91eae18614864622d9b87bbd5bbf36"}, - {file = "pillow-11.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c96f993ab8c98460cd0c001447bff6194403e8b1d7e149ade5f00594918128b"}, - {file = "pillow-11.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:41342b64afeba938edb034d122b2dda5db2139b9a4af999729ba8818e0056477"}, - {file = "pillow-11.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:068d9c39a2d1b358eb9f245ce7ab1b5c3246c7c8c7d9ba58cfa5b43146c06e50"}, - {file = "pillow-11.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a1bc6ba083b145187f648b667e05a2534ecc4b9f2784c2cbe3089e44868f2b9b"}, - {file = "pillow-11.3.0-cp314-cp314t-win32.whl", hash = "sha256:118ca10c0d60b06d006be10a501fd6bbdfef559251ed31b794668ed569c87e12"}, - {file = "pillow-11.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8924748b688aa210d79883357d102cd64690e56b923a186f35a82cbc10f997db"}, - {file = "pillow-11.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:79ea0d14d3ebad43ec77ad5272e6ff9bba5b679ef73375ea760261207fa8e0aa"}, - {file = "pillow-11.3.0-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:48d254f8a4c776de343051023eb61ffe818299eeac478da55227d96e241de53f"}, - {file = "pillow-11.3.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:7aee118e30a4cf54fdd873bd3a29de51e29105ab11f9aad8c32123f58c8f8081"}, - {file = "pillow-11.3.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:23cff760a9049c502721bdb743a7cb3e03365fafcdfc2ef9784610714166e5a4"}, - {file = "pillow-11.3.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6359a3bc43f57d5b375d1ad54a0074318a0844d11b76abccf478c37c986d3cfc"}, - {file = "pillow-11.3.0-cp39-cp39-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:092c80c76635f5ecb10f3f83d76716165c96f5229addbd1ec2bdbbda7d496e06"}, - {file = "pillow-11.3.0-cp39-cp39-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cadc9e0ea0a2431124cde7e1697106471fc4c1da01530e679b2391c37d3fbb3a"}, - {file = "pillow-11.3.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:6a418691000f2a418c9135a7cf0d797c1bb7d9a485e61fe8e7722845b95ef978"}, - {file = "pillow-11.3.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:97afb3a00b65cc0804d1c7abddbf090a81eaac02768af58cbdcaaa0a931e0b6d"}, - {file = "pillow-11.3.0-cp39-cp39-win32.whl", hash = "sha256:ea944117a7974ae78059fcc1800e5d3295172bb97035c0c1d9345fca1419da71"}, - {file = "pillow-11.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:e5c5858ad8ec655450a7c7df532e9842cf8df7cc349df7225c60d5d348c8aada"}, - {file = "pillow-11.3.0-cp39-cp39-win_arm64.whl", hash = "sha256:6abdbfd3aea42be05702a8dd98832329c167ee84400a1d1f61ab11437f1717eb"}, - {file = "pillow-11.3.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:3cee80663f29e3843b68199b9d6f4f54bd1d4a6b59bdd91bceefc51238bcb967"}, - {file = "pillow-11.3.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:b5f56c3f344f2ccaf0dd875d3e180f631dc60a51b314295a3e681fe8cf851fbe"}, - {file = "pillow-11.3.0-pp310-pypy310_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e67d793d180c9df62f1f40aee3accca4829d3794c95098887edc18af4b8b780c"}, - {file = "pillow-11.3.0-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d000f46e2917c705e9fb93a3606ee4a819d1e3aa7a9b442f6444f07e77cf5e25"}, - {file = "pillow-11.3.0-pp310-pypy310_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:527b37216b6ac3a12d7838dc3bd75208ec57c1c6d11ef01902266a5a0c14fc27"}, - {file = "pillow-11.3.0-pp310-pypy310_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:be5463ac478b623b9dd3937afd7fb7ab3d79dd290a28e2b6df292dc75063eb8a"}, - {file = "pillow-11.3.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:8dc70ca24c110503e16918a658b869019126ecfe03109b754c402daff12b3d9f"}, - {file = "pillow-11.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7c8ec7a017ad1bd562f93dbd8505763e688d388cde6e4a010ae1486916e713e6"}, - {file = "pillow-11.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9ab6ae226de48019caa8074894544af5b53a117ccb9d3b3dcb2871464c829438"}, - {file = "pillow-11.3.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fe27fb049cdcca11f11a7bfda64043c37b30e6b91f10cb5bab275806c32f6ab3"}, - {file = "pillow-11.3.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:465b9e8844e3c3519a983d58b80be3f668e2a7a5db97f2784e7079fbc9f9822c"}, - {file = "pillow-11.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5418b53c0d59b3824d05e029669efa023bbef0f3e92e75ec8428f3799487f361"}, - {file = "pillow-11.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:504b6f59505f08ae014f724b6207ff6222662aab5cc9542577fb084ed0676ac7"}, - {file = "pillow-11.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c84d689db21a1c397d001aa08241044aa2069e7587b398c8cc63020390b1c1b8"}, - {file = "pillow-11.3.0.tar.gz", hash = "sha256:3828ee7586cd0b2091b6209e5ad53e20d0649bbe87164a459d0676e035e8f523"}, -] - -[package.extras] -docs = ["furo", "olefile", "sphinx (>=8.2)", "sphinx-autobuild", "sphinx-copybutton", "sphinx-inline-tabs", "sphinxext-opengraph"] -fpx = ["olefile"] -mic = ["olefile"] -test-arrow = ["pyarrow"] -tests = ["check-manifest", "coverage (>=7.4.2)", "defusedxml", "markdown2", "olefile", "packaging", "pyroma", "pytest", "pytest-cov", "pytest-timeout", "pytest-xdist", "trove-classifiers (>=2024.10.12)"] -typing = ["typing-extensions ; python_version < \"3.10\""] -xmp = ["defusedxml"] - -[[package]] -name = "pip" -version = "25.2" -description = "The PyPA recommended tool for installing Python packages." -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "pip-25.2-py3-none-any.whl", hash = "sha256:6d67a2b4e7f14d8b31b8b52648866fa717f45a1eb70e83002f4331d07e953717"}, - {file = "pip-25.2.tar.gz", hash = "sha256:578283f006390f85bb6282dffb876454593d637f5d1be494b5202ce4877e71f2"}, -] - -[[package]] -name = "pipdeptree" -version = "2.26.1" -description = "Command line utility to show dependency tree of packages." -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "pipdeptree-2.26.1-py3-none-any.whl", hash = "sha256:3849d62a2ed641256afac3058c4f9b85ac4a47e9d8c991ee17a8f3d230c5cffb"}, - {file = "pipdeptree-2.26.1.tar.gz", hash = "sha256:92a8f37ab79235dacb46af107e691a1309ca4a429315ba2a1df97d1cd56e27ac"}, -] - -[package.dependencies] -packaging = ">=24.1" -pip = ">=24.2" - -[package.extras] -graphviz = ["graphviz (>=0.20.3)"] -test = ["covdefaults (>=2.3)", "diff-cover (>=9.1.1)", "pytest (>=8.3.2)", "pytest-console-scripts (>=1.4.1)", "pytest-cov (>=5)", "pytest-mock (>=3.14)", "virtualenv (>=20.26.4,<21)"] - -[[package]] -name = "platformdirs" -version = "4.4.0" -description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "platformdirs-4.4.0-py3-none-any.whl", hash = "sha256:abd01743f24e5287cd7a5db3752faf1a2d65353f38ec26d98e25a6db65958c85"}, - {file = "platformdirs-4.4.0.tar.gz", hash = "sha256:ca753cf4d81dc309bc67b0ea38fd15dc97bc30ce419a7f58d13eb3bf14c4febf"}, -] - -[package.extras] -docs = ["furo (>=2024.8.6)", "proselint (>=0.14)", "sphinx (>=8.1.3)", "sphinx-autodoc-typehints (>=3)"] -test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.3.4)", "pytest-cov (>=6)", "pytest-mock (>=3.14)"] -type = ["mypy (>=1.14.1)"] - -[[package]] -name = "pluggy" -version = "1.6.0" -description = "plugin and hook calling mechanisms for python" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, - {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, -] - -[package.extras] -dev = ["pre-commit", "tox"] -testing = ["coverage", "pytest", "pytest-benchmark"] - -[[package]] -name = "pre-commit" -version = "4.3.0" -description = "A framework for managing and maintaining multi-language pre-commit hooks." -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "pre_commit-4.3.0-py2.py3-none-any.whl", hash = "sha256:2b0747ad7e6e967169136edffee14c16e148a778a54e4f967921aa1ebf2308d8"}, - {file = "pre_commit-4.3.0.tar.gz", hash = "sha256:499fe450cc9d42e9d58e606262795ecb64dd05438943c62b66f6a8673da30b16"}, -] - -[package.dependencies] -cfgv = ">=2.0.0" -identify = ">=1.0.0" -nodeenv = ">=0.11.1" -pyyaml = ">=5.1" -virtualenv = ">=20.10.0" - -[[package]] -name = "prettier" -version = "0.0.7" -description = "Properly pprint of nested objects" -optional = false -python-versions = "*" -groups = ["dev"] -files = [ - {file = "prettier-0.0.7-py3-none-any.whl", hash = "sha256:20e76791de41cafe481328dd49552303f29ca192151cee1b120c26f66cae9bfc"}, - {file = "prettier-0.0.7.tar.gz", hash = "sha256:6c34b8cd09fd9c8956c05d6395ea3f575e0122dce494ba57685c07065abed427"}, -] - -[[package]] -name = "propcache" -version = "0.3.2" -description = "Accelerated property cache" -optional = false -python-versions = ">=3.9" -groups = ["main", "dev"] -files = [ - {file = "propcache-0.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:22d9962a358aedbb7a2e36187ff273adeaab9743373a272976d2e348d08c7770"}, - {file = "propcache-0.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0d0fda578d1dc3f77b6b5a5dce3b9ad69a8250a891760a548df850a5e8da87f3"}, - {file = "propcache-0.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:3def3da3ac3ce41562d85db655d18ebac740cb3fa4367f11a52b3da9d03a5cc3"}, - {file = "propcache-0.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9bec58347a5a6cebf239daba9bda37dffec5b8d2ce004d9fe4edef3d2815137e"}, - {file = "propcache-0.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:55ffda449a507e9fbd4aca1a7d9aa6753b07d6166140e5a18d2ac9bc49eac220"}, - {file = "propcache-0.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:64a67fb39229a8a8491dd42f864e5e263155e729c2e7ff723d6e25f596b1e8cb"}, - {file = "propcache-0.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9da1cf97b92b51253d5b68cf5a2b9e0dafca095e36b7f2da335e27dc6172a614"}, - {file = "propcache-0.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5f559e127134b07425134b4065be45b166183fdcb433cb6c24c8e4149056ad50"}, - {file = "propcache-0.3.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:aff2e4e06435d61f11a428360a932138d0ec288b0a31dd9bd78d200bd4a2b339"}, - {file = "propcache-0.3.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:4927842833830942a5d0a56e6f4839bc484785b8e1ce8d287359794818633ba0"}, - {file = "propcache-0.3.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:6107ddd08b02654a30fb8ad7a132021759d750a82578b94cd55ee2772b6ebea2"}, - {file = "propcache-0.3.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:70bd8b9cd6b519e12859c99f3fc9a93f375ebd22a50296c3a295028bea73b9e7"}, - {file = "propcache-0.3.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:2183111651d710d3097338dd1893fcf09c9f54e27ff1a8795495a16a469cc90b"}, - {file = "propcache-0.3.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:fb075ad271405dcad8e2a7ffc9a750a3bf70e533bd86e89f0603e607b93aa64c"}, - {file = "propcache-0.3.2-cp310-cp310-win32.whl", hash = "sha256:404d70768080d3d3bdb41d0771037da19d8340d50b08e104ca0e7f9ce55fce70"}, - {file = "propcache-0.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:7435d766f978b4ede777002e6b3b6641dd229cd1da8d3d3106a45770365f9ad9"}, - {file = "propcache-0.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0b8d2f607bd8f80ddc04088bc2a037fdd17884a6fcadc47a96e334d72f3717be"}, - {file = "propcache-0.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:06766d8f34733416e2e34f46fea488ad5d60726bb9481d3cddf89a6fa2d9603f"}, - {file = "propcache-0.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a2dc1f4a1df4fecf4e6f68013575ff4af84ef6f478fe5344317a65d38a8e6dc9"}, - {file = "propcache-0.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be29c4f4810c5789cf10ddf6af80b041c724e629fa51e308a7a0fb19ed1ef7bf"}, - {file = "propcache-0.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:59d61f6970ecbd8ff2e9360304d5c8876a6abd4530cb752c06586849ac8a9dc9"}, - {file = "propcache-0.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:62180e0b8dbb6b004baec00a7983e4cc52f5ada9cd11f48c3528d8cfa7b96a66"}, - {file = "propcache-0.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c144ca294a204c470f18cf4c9d78887810d04a3e2fbb30eea903575a779159df"}, - {file = "propcache-0.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c5c2a784234c28854878d68978265617aa6dc0780e53d44b4d67f3651a17a9a2"}, - {file = "propcache-0.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5745bc7acdafa978ca1642891b82c19238eadc78ba2aaa293c6863b304e552d7"}, - {file = "propcache-0.3.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:c0075bf773d66fa8c9d41f66cc132ecc75e5bb9dd7cce3cfd14adc5ca184cb95"}, - {file = "propcache-0.3.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5f57aa0847730daceff0497f417c9de353c575d8da3579162cc74ac294c5369e"}, - {file = "propcache-0.3.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:eef914c014bf72d18efb55619447e0aecd5fb7c2e3fa7441e2e5d6099bddff7e"}, - {file = "propcache-0.3.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2a4092e8549031e82facf3decdbc0883755d5bbcc62d3aea9d9e185549936dcf"}, - {file = "propcache-0.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:85871b050f174bc0bfb437efbdb68aaf860611953ed12418e4361bc9c392749e"}, - {file = "propcache-0.3.2-cp311-cp311-win32.whl", hash = "sha256:36c8d9b673ec57900c3554264e630d45980fd302458e4ac801802a7fd2ef7897"}, - {file = "propcache-0.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:e53af8cb6a781b02d2ea079b5b853ba9430fcbe18a8e3ce647d5982a3ff69f39"}, - {file = "propcache-0.3.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:8de106b6c84506b31c27168582cd3cb3000a6412c16df14a8628e5871ff83c10"}, - {file = "propcache-0.3.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:28710b0d3975117239c76600ea351934ac7b5ff56e60953474342608dbbb6154"}, - {file = "propcache-0.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce26862344bdf836650ed2487c3d724b00fbfec4233a1013f597b78c1cb73615"}, - {file = "propcache-0.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bca54bd347a253af2cf4544bbec232ab982f4868de0dd684246b67a51bc6b1db"}, - {file = "propcache-0.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:55780d5e9a2ddc59711d727226bb1ba83a22dd32f64ee15594b9392b1f544eb1"}, - {file = "propcache-0.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:035e631be25d6975ed87ab23153db6a73426a48db688070d925aa27e996fe93c"}, - {file = "propcache-0.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ee6f22b6eaa39297c751d0e80c0d3a454f112f5c6481214fcf4c092074cecd67"}, - {file = "propcache-0.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7ca3aee1aa955438c4dba34fc20a9f390e4c79967257d830f137bd5a8a32ed3b"}, - {file = "propcache-0.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7a4f30862869fa2b68380d677cc1c5fcf1e0f2b9ea0cf665812895c75d0ca3b8"}, - {file = "propcache-0.3.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b77ec3c257d7816d9f3700013639db7491a434644c906a2578a11daf13176251"}, - {file = "propcache-0.3.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cab90ac9d3f14b2d5050928483d3d3b8fb6b4018893fc75710e6aa361ecb2474"}, - {file = "propcache-0.3.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:0b504d29f3c47cf6b9e936c1852246c83d450e8e063d50562115a6be6d3a2535"}, - {file = "propcache-0.3.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:ce2ac2675a6aa41ddb2a0c9cbff53780a617ac3d43e620f8fd77ba1c84dcfc06"}, - {file = "propcache-0.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:62b4239611205294cc433845b914131b2a1f03500ff3c1ed093ed216b82621e1"}, - {file = "propcache-0.3.2-cp312-cp312-win32.whl", hash = "sha256:df4a81b9b53449ebc90cc4deefb052c1dd934ba85012aa912c7ea7b7e38b60c1"}, - {file = "propcache-0.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:7046e79b989d7fe457bb755844019e10f693752d169076138abf17f31380800c"}, - {file = "propcache-0.3.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ca592ed634a73ca002967458187109265e980422116c0a107cf93d81f95af945"}, - {file = "propcache-0.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9ecb0aad4020e275652ba3975740f241bd12a61f1a784df044cf7477a02bc252"}, - {file = "propcache-0.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7f08f1cc28bd2eade7a8a3d2954ccc673bb02062e3e7da09bc75d843386b342f"}, - {file = "propcache-0.3.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1a342c834734edb4be5ecb1e9fb48cb64b1e2320fccbd8c54bf8da8f2a84c33"}, - {file = "propcache-0.3.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8a544caaae1ac73f1fecfae70ded3e93728831affebd017d53449e3ac052ac1e"}, - {file = "propcache-0.3.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:310d11aa44635298397db47a3ebce7db99a4cc4b9bbdfcf6c98a60c8d5261cf1"}, - {file = "propcache-0.3.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4c1396592321ac83157ac03a2023aa6cc4a3cc3cfdecb71090054c09e5a7cce3"}, - {file = "propcache-0.3.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8cabf5b5902272565e78197edb682017d21cf3b550ba0460ee473753f28d23c1"}, - {file = "propcache-0.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0a2f2235ac46a7aa25bdeb03a9e7060f6ecbd213b1f9101c43b3090ffb971ef6"}, - {file = "propcache-0.3.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:92b69e12e34869a6970fd2f3da91669899994b47c98f5d430b781c26f1d9f387"}, - {file = "propcache-0.3.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:54e02207c79968ebbdffc169591009f4474dde3b4679e16634d34c9363ff56b4"}, - {file = "propcache-0.3.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4adfb44cb588001f68c5466579d3f1157ca07f7504fc91ec87862e2b8e556b88"}, - {file = "propcache-0.3.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fd3e6019dc1261cd0291ee8919dd91fbab7b169bb76aeef6c716833a3f65d206"}, - {file = "propcache-0.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4c181cad81158d71c41a2bce88edce078458e2dd5ffee7eddd6b05da85079f43"}, - {file = "propcache-0.3.2-cp313-cp313-win32.whl", hash = "sha256:8a08154613f2249519e549de2330cf8e2071c2887309a7b07fb56098f5170a02"}, - {file = "propcache-0.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:e41671f1594fc4ab0a6dec1351864713cb3a279910ae8b58f884a88a0a632c05"}, - {file = "propcache-0.3.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:9a3cf035bbaf035f109987d9d55dc90e4b0e36e04bbbb95af3055ef17194057b"}, - {file = "propcache-0.3.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:156c03d07dc1323d8dacaa221fbe028c5c70d16709cdd63502778e6c3ccca1b0"}, - {file = "propcache-0.3.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:74413c0ba02ba86f55cf60d18daab219f7e531620c15f1e23d95563f505efe7e"}, - {file = "propcache-0.3.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f066b437bb3fa39c58ff97ab2ca351db465157d68ed0440abecb21715eb24b28"}, - {file = "propcache-0.3.2-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f1304b085c83067914721e7e9d9917d41ad87696bf70f0bc7dee450e9c71ad0a"}, - {file = "propcache-0.3.2-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ab50cef01b372763a13333b4e54021bdcb291fc9a8e2ccb9c2df98be51bcde6c"}, - {file = "propcache-0.3.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fad3b2a085ec259ad2c2842666b2a0a49dea8463579c606426128925af1ed725"}, - {file = "propcache-0.3.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:261fa020c1c14deafd54c76b014956e2f86991af198c51139faf41c4d5e83892"}, - {file = "propcache-0.3.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:46d7f8aa79c927e5f987ee3a80205c987717d3659f035c85cf0c3680526bdb44"}, - {file = "propcache-0.3.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:6d8f3f0eebf73e3c0ff0e7853f68be638b4043c65a70517bb575eff54edd8dbe"}, - {file = "propcache-0.3.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:03c89c1b14a5452cf15403e291c0ccd7751d5b9736ecb2c5bab977ad6c5bcd81"}, - {file = "propcache-0.3.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:0cc17efde71e12bbaad086d679ce575268d70bc123a5a71ea7ad76f70ba30bba"}, - {file = "propcache-0.3.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:acdf05d00696bc0447e278bb53cb04ca72354e562cf88ea6f9107df8e7fd9770"}, - {file = "propcache-0.3.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4445542398bd0b5d32df908031cb1b30d43ac848e20470a878b770ec2dcc6330"}, - {file = "propcache-0.3.2-cp313-cp313t-win32.whl", hash = "sha256:f86e5d7cd03afb3a1db8e9f9f6eff15794e79e791350ac48a8c924e6f439f394"}, - {file = "propcache-0.3.2-cp313-cp313t-win_amd64.whl", hash = "sha256:9704bedf6e7cbe3c65eca4379a9b53ee6a83749f047808cbb5044d40d7d72198"}, - {file = "propcache-0.3.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:a7fad897f14d92086d6b03fdd2eb844777b0c4d7ec5e3bac0fbae2ab0602bbe5"}, - {file = "propcache-0.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1f43837d4ca000243fd7fd6301947d7cb93360d03cd08369969450cc6b2ce3b4"}, - {file = "propcache-0.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:261df2e9474a5949c46e962065d88eb9b96ce0f2bd30e9d3136bcde84befd8f2"}, - {file = "propcache-0.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e514326b79e51f0a177daab1052bc164d9d9e54133797a3a58d24c9c87a3fe6d"}, - {file = "propcache-0.3.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d4a996adb6904f85894570301939afeee65f072b4fd265ed7e569e8d9058e4ec"}, - {file = "propcache-0.3.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:76cace5d6b2a54e55b137669b30f31aa15977eeed390c7cbfb1dafa8dfe9a701"}, - {file = "propcache-0.3.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:31248e44b81d59d6addbb182c4720f90b44e1efdc19f58112a3c3a1615fb47ef"}, - {file = "propcache-0.3.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:abb7fa19dbf88d3857363e0493b999b8011eea856b846305d8c0512dfdf8fbb1"}, - {file = "propcache-0.3.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:d81ac3ae39d38588ad0549e321e6f773a4e7cc68e7751524a22885d5bbadf886"}, - {file = "propcache-0.3.2-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:cc2782eb0f7a16462285b6f8394bbbd0e1ee5f928034e941ffc444012224171b"}, - {file = "propcache-0.3.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:db429c19a6c7e8a1c320e6a13c99799450f411b02251fb1b75e6217cf4a14fcb"}, - {file = "propcache-0.3.2-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:21d8759141a9e00a681d35a1f160892a36fb6caa715ba0b832f7747da48fb6ea"}, - {file = "propcache-0.3.2-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:2ca6d378f09adb13837614ad2754fa8afaee330254f404299611bce41a8438cb"}, - {file = "propcache-0.3.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:34a624af06c048946709f4278b4176470073deda88d91342665d95f7c6270fbe"}, - {file = "propcache-0.3.2-cp39-cp39-win32.whl", hash = "sha256:4ba3fef1c30f306b1c274ce0b8baaa2c3cdd91f645c48f06394068f37d3837a1"}, - {file = "propcache-0.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:7a2368eed65fc69a7a7a40b27f22e85e7627b74216f0846b04ba5c116e191ec9"}, - {file = "propcache-0.3.2-py3-none-any.whl", hash = "sha256:98f1ec44fb675f5052cccc8e609c46ed23a35a1cfd18545ad4e29002d858a43f"}, - {file = "propcache-0.3.2.tar.gz", hash = "sha256:20d7d62e4e7ef05f221e0db2856b979540686342e7dd9973b815599c7057e168"}, -] - -[[package]] -name = "psutil" -version = "7.1.0" -description = "Cross-platform lib for process and system monitoring." -optional = false -python-versions = ">=3.6" -groups = ["main", "dev"] -files = [ - {file = "psutil-7.1.0-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:76168cef4397494250e9f4e73eb3752b146de1dd950040b29186d0cce1d5ca13"}, - {file = "psutil-7.1.0-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:5d007560c8c372efdff9e4579c2846d71de737e4605f611437255e81efcca2c5"}, - {file = "psutil-7.1.0-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:22e4454970b32472ce7deaa45d045b34d3648ce478e26a04c7e858a0a6e75ff3"}, - {file = "psutil-7.1.0-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c70e113920d51e89f212dd7be06219a9b88014e63a4cec69b684c327bc474e3"}, - {file = "psutil-7.1.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7d4a113425c037300de3ac8b331637293da9be9713855c4fc9d2d97436d7259d"}, - {file = "psutil-7.1.0-cp37-abi3-win32.whl", hash = "sha256:09ad740870c8d219ed8daae0ad3b726d3bf9a028a198e7f3080f6a1888b99bca"}, - {file = "psutil-7.1.0-cp37-abi3-win_amd64.whl", hash = "sha256:57f5e987c36d3146c0dd2528cd42151cf96cd359b9d67cfff836995cc5df9a3d"}, - {file = "psutil-7.1.0-cp37-abi3-win_arm64.whl", hash = "sha256:6937cb68133e7c97b6cc9649a570c9a18ba0efebed46d8c5dae4c07fa1b67a07"}, - {file = "psutil-7.1.0.tar.gz", hash = "sha256:655708b3c069387c8b77b072fc429a57d0e214221d01c0a772df7dfedcb3bcd2"}, -] - -[package.extras] -dev = ["abi3audit", "black", "check-manifest", "coverage", "packaging", "pylint", "pyperf", "pypinfo", "pyreadline ; os_name == \"nt\"", "pytest", "pytest-cov", "pytest-instafail", "pytest-subtests", "pytest-xdist", "pywin32 ; os_name == \"nt\" and platform_python_implementation != \"PyPy\"", "requests", "rstcheck", "ruff", "setuptools", "sphinx", "sphinx_rtd_theme", "toml-sort", "twine", "virtualenv", "vulture", "wheel", "wheel ; os_name == \"nt\" and platform_python_implementation != \"PyPy\"", "wmi ; os_name == \"nt\" and platform_python_implementation != \"PyPy\""] -test = ["pytest", "pytest-instafail", "pytest-subtests", "pytest-xdist", "pywin32 ; os_name == \"nt\" and platform_python_implementation != \"PyPy\"", "setuptools", "wheel ; os_name == \"nt\" and platform_python_implementation != \"PyPy\"", "wmi ; os_name == \"nt\" and platform_python_implementation != \"PyPy\""] - -[[package]] -name = "psutil-home-assistant" -version = "0.0.1" -description = "Wrapper for psutil to allow it to be used several times in the same process." -optional = false -python-versions = ">=3.8" -groups = ["main", "dev"] -files = [ - {file = "psutil-home-assistant-0.0.1.tar.gz", hash = "sha256:ebe4f3a98d76d93a3140da2823e9ef59ca50a59761fdc453b30b4407c4c1bdb8"}, - {file = "psutil_home_assistant-0.0.1-py3-none-any.whl", hash = "sha256:35a782e93e23db845fc4a57b05df9c52c2d5c24f5b233bd63b01bae4efae3c41"}, -] - -[package.dependencies] -psutil = "*" - -[[package]] -name = "pycares" -version = "4.11.0" -description = "Python interface for c-ares" -optional = false -python-versions = ">=3.9" -groups = ["main", "dev"] -files = [ - {file = "pycares-4.11.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:87dab618fe116f1936f8461df5970fcf0befeba7531a36b0a86321332ff9c20b"}, - {file = "pycares-4.11.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:3db6b6439e378115572fa317053f3ee6eecb39097baafe9292320ff1a9df73e3"}, - {file = "pycares-4.11.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:742fbaa44b418237dbd6bf8cdab205c98b3edb334436a972ad341b0ea296fb47"}, - {file = "pycares-4.11.0-cp310-cp310-manylinux_2_28_ppc64le.whl", hash = "sha256:d2a3526dbf6cb01b355e8867079c9356a8df48706b4b099ac0bf59d4656e610d"}, - {file = "pycares-4.11.0-cp310-cp310-manylinux_2_28_s390x.whl", hash = "sha256:3d5300a598ad48bbf169fba1f2b2e4cf7ab229e7c1a48d8c1166f9ccf1755cb3"}, - {file = "pycares-4.11.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:066f3caa07c85e1a094aebd9e7a7bb3f3b2d97cff2276665693dd5c0cc81cf84"}, - {file = "pycares-4.11.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dcd4a7761fdfb5aaac88adad0a734dd065c038f5982a8c4b0dd28efa0bd9cc7c"}, - {file = "pycares-4.11.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:83a7401d7520fa14b00d85d68bcca47a0676c69996e8515d53733972286f9739"}, - {file = "pycares-4.11.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:66c310773abe42479302abf064832f4a37c8d7f788f4d5ee0d43cbad35cf5ff4"}, - {file = "pycares-4.11.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:95bc81f83fadb67f7f87914f216a0e141555ee17fd7f56e25aa0cc165e99e53b"}, - {file = "pycares-4.11.0-cp310-cp310-win32.whl", hash = "sha256:1dbbf0cfb39be63598b4cdc2522960627bf2f523e49c4349fb64b0499902ec7c"}, - {file = "pycares-4.11.0-cp310-cp310-win_amd64.whl", hash = "sha256:dde02314eefb85dce3cfdd747e8b44c69a94d442c0d7221b7de151ee4c93f0f5"}, - {file = "pycares-4.11.0-cp310-cp310-win_arm64.whl", hash = "sha256:9518514e3e85646bac798d94d34bf5b8741ee0cb580512e8450ce884f526b7cf"}, - {file = "pycares-4.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c2971af3a4094280f7c24293ff4d361689c175c1ebcbea6b3c1560eaff7cb240"}, - {file = "pycares-4.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5d69e2034160e1219665decb8140e439afc7a7afcfd4adff08eb0f6142405c3e"}, - {file = "pycares-4.11.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:3bd81ad69f607803f531ff5cfa1262391fa06e78488c13495cee0f70d02e0287"}, - {file = "pycares-4.11.0-cp311-cp311-manylinux_2_28_ppc64le.whl", hash = "sha256:0aed0974eab3131d832e7e84a73ddb0dddbc57393cd8c0788d68a759a78c4a7b"}, - {file = "pycares-4.11.0-cp311-cp311-manylinux_2_28_s390x.whl", hash = "sha256:30d197180af626bb56f17e1fa54640838d7d12ed0f74665a3014f7155435b199"}, - {file = "pycares-4.11.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:cb711a66246561f1cae51244deef700eef75481a70d99611fd3c8ab5bd69ab49"}, - {file = "pycares-4.11.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7aba9a312a620052133437f2363aae90ae4695ee61cb2ee07cbb9951d4c69ddd"}, - {file = "pycares-4.11.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c2af7a9d3afb63da31df1456d38b91555a6c147710a116d5cc70ab1e9f457a4f"}, - {file = "pycares-4.11.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d5fe089be67bc5927f0c0bd60c082c79f22cf299635ee3ddd370ae2a6e8b4ae0"}, - {file = "pycares-4.11.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:35ff1ec260372c97ed688efd5b3c6e5481f2274dea08f6c4ea864c195a9673c6"}, - {file = "pycares-4.11.0-cp311-cp311-win32.whl", hash = "sha256:ff3d25883b7865ea34c00084dd22a7be7c58fd3131db6b25c35eafae84398f9d"}, - {file = "pycares-4.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:f4695153333607e63068580f2979b377b641a03bc36e02813659ffbea2b76fe2"}, - {file = "pycares-4.11.0-cp311-cp311-win_arm64.whl", hash = "sha256:dc54a21586c096df73f06f9bdf594e8d86d7be84e5d4266358ce81c04c3cc88c"}, - {file = "pycares-4.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b93d624560ba52287873bacff70b42c99943821ecbc810b959b0953560f53c36"}, - {file = "pycares-4.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:775d99966e28c8abd9910ddef2de0f1e173afc5a11cea9f184613c747373ab80"}, - {file = "pycares-4.11.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:84fde689557361764f052850a2d68916050adbfd9321f6105aca1d8f1a9bd49b"}, - {file = "pycares-4.11.0-cp312-cp312-manylinux_2_28_ppc64le.whl", hash = "sha256:30ceed06f3bf5eff865a34d21562c25a7f3dad0ed336b9dd415330e03a6c50c4"}, - {file = "pycares-4.11.0-cp312-cp312-manylinux_2_28_s390x.whl", hash = "sha256:97d971b3a88a803bb95ff8a40ea4d68da59319eb8b59e924e318e2560af8c16d"}, - {file = "pycares-4.11.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:2d5cac829da91ade70ce1af97dad448c6cd4778b48facbce1b015e16ced93642"}, - {file = "pycares-4.11.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ee1ea367835eb441d246164c09d1f9703197af4425fc6865cefcde9e2ca81f85"}, - {file = "pycares-4.11.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:3139ec1f4450a4b253386035c5ecd2722582ae3320a456df5021ffe3f174260a"}, - {file = "pycares-4.11.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:5d70324ca1d82c6c4b00aa678347f7560d1ef2ce1d181978903459a97751543a"}, - {file = "pycares-4.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e2f8d9cfe0eb3a2997fde5df99b1aaea5a46dabfcfcac97b2d05f027c2cd5e28"}, - {file = "pycares-4.11.0-cp312-cp312-win32.whl", hash = "sha256:1571a7055c03a95d5270c914034eac7f8bfa1b432fc1de53d871b821752191a4"}, - {file = "pycares-4.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:7570e0b50db619b2ee370461c462617225dc3a3f63f975c6f117e2f0c94f82ca"}, - {file = "pycares-4.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:f199702740f3b766ed8c70efb885538be76cb48cd0cb596b948626f0b825e07a"}, - {file = "pycares-4.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2c296ab94d1974f8d2f76c499755a9ce31ffd4986e8898ef19b90e32525f7d84"}, - {file = "pycares-4.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e0fcd3a8bac57a0987d9b09953ba0f8703eb9dca7c77f7051d8c2ed001185be8"}, - {file = "pycares-4.11.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:bac55842047567ddae177fb8189b89a60633ac956d5d37260f7f71b517fd8b87"}, - {file = "pycares-4.11.0-cp313-cp313-manylinux_2_28_ppc64le.whl", hash = "sha256:4da2e805ed8c789b9444ef4053f6ef8040cd13b0c1ca6d3c4fe6f9369c458cb4"}, - {file = "pycares-4.11.0-cp313-cp313-manylinux_2_28_s390x.whl", hash = "sha256:ea785d1f232b42b325578f0c8a2fa348192e182cc84a1e862896076a4a2ba2a7"}, - {file = "pycares-4.11.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:aa160dc9e785212c49c12bb891e242c949758b99542946cc8e2098ef391f93b0"}, - {file = "pycares-4.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7830709c23bbc43fbaefbb3dde57bdd295dc86732504b9d2e65044df8fd5e9fb"}, - {file = "pycares-4.11.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3ef1ab7abbd238bb2dbbe871c3ea39f5a7fc63547c015820c1e24d0d494a1689"}, - {file = "pycares-4.11.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:a4060d8556c908660512d42df1f4a874e4e91b81f79e3a9090afedc7690ea5ba"}, - {file = "pycares-4.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a98fac4a3d4f780817016b6f00a8a2c2f41df5d25dfa8e5b1aa0d783645a6566"}, - {file = "pycares-4.11.0-cp313-cp313-win32.whl", hash = "sha256:faa8321bc2a366189dcf87b3823e030edf5ac97a6b9a7fc99f1926c4bf8ef28e"}, - {file = "pycares-4.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:6f74b1d944a50fa12c5006fd10b45e1a45da0c5d15570919ce48be88e428264c"}, - {file = "pycares-4.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:4b6f7581793d8bb3014028b8397f6f80b99db8842da58f4409839c29b16397ad"}, - {file = "pycares-4.11.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:df0a17f4e677d57bca3624752bbb515316522ad1ce0de07ed9d920e6c4ee5d35"}, - {file = "pycares-4.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3b44e54cad31d3c3be5e8149ac36bc1c163ec86e0664293402f6f846fb22ad00"}, - {file = "pycares-4.11.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:80752133442dc7e6dd9410cec227c49f69283c038c316a8585cca05ec32c2766"}, - {file = "pycares-4.11.0-cp314-cp314-manylinux_2_28_ppc64le.whl", hash = "sha256:84b0b402dd333403fdce0e204aef1ef834d839c439c0c1aa143dc7d1237bb197"}, - {file = "pycares-4.11.0-cp314-cp314-manylinux_2_28_s390x.whl", hash = "sha256:c0eec184df42fc82e43197e073f9cc8f93b25ad2f11f230c64c2dc1c80dbc078"}, - {file = "pycares-4.11.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:ee751409322ff10709ee867d5aea1dc8431eec7f34835f0f67afd016178da134"}, - {file = "pycares-4.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1732db81e348bfce19c9bf9448ba660aea03042eeeea282824da1604a5bd4dcf"}, - {file = "pycares-4.11.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:702d21823996f139874aba5aa9bb786d69e93bde6e3915b99832eb4e335d31ae"}, - {file = "pycares-4.11.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:218619b912cef7c64a339ab0e231daea10c994a05699740714dff8c428b9694a"}, - {file = "pycares-4.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:719f7ddff024fdacde97b926b4b26d0cc25901d5ef68bb994a581c420069936d"}, - {file = "pycares-4.11.0-cp314-cp314-win32.whl", hash = "sha256:d552fb2cb513ce910d1dc22dbba6420758a991a356f3cd1b7ec73a9e31f94d01"}, - {file = "pycares-4.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:23d50a0842e8dbdddf870a7218a7ab5053b68892706b3a391ecb3d657424d266"}, - {file = "pycares-4.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:836725754c32363d2c5d15b931b3ebd46b20185c02e850672cb6c5f0452c1e80"}, - {file = "pycares-4.11.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c9d839b5700542b27c1a0d359cbfad6496341e7c819c7fea63db9588857065ed"}, - {file = "pycares-4.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:31b85ad00422b38f426e5733a71dfb7ee7eb65a99ea328c508d4f552b1760dc8"}, - {file = "pycares-4.11.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:cdac992206756b024b371760c55719eb5cd9d6b2cb25a8d5a04ae1b0ff426232"}, - {file = "pycares-4.11.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:ffb22cee640bc12ee0e654eba74ecfb59e2e0aebc5bccc3cc7ef92f487008af7"}, - {file = "pycares-4.11.0-cp314-cp314t-manylinux_2_28_s390x.whl", hash = "sha256:00538826d2eaf4a0e4becb0753b0ac8d652334603c445c9566c9eb273657eb4c"}, - {file = "pycares-4.11.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:29daa36548c04cdcd1a78ae187a4b7b003f0b357a2f4f1f98f9863373eedc759"}, - {file = "pycares-4.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:cf306f3951740d7bed36149a6d8d656a7d5432dd4bbc6af3bb6554361fc87401"}, - {file = "pycares-4.11.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:386da2581db4ea2832629e275c061103b0be32f9391c5dfaea7f6040951950ad"}, - {file = "pycares-4.11.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:45d3254a694459fdb0640ef08724ca9d4b4f6ff6d7161c9b526d7d2e2111379e"}, - {file = "pycares-4.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eddf5e520bb88b23b04ac1f28f5e9a7c77c718b8b4af3a4a7a2cc4a600f34502"}, - {file = "pycares-4.11.0-cp314-cp314t-win32.whl", hash = "sha256:8a75a406432ce39ce0ca41edff7486df6c970eb0fe5cfbe292f195a6b8654461"}, - {file = "pycares-4.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:3784b80d797bcc2ff2bf3d4b27f46d8516fe1707ff3b82c2580dc977537387f9"}, - {file = "pycares-4.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:afc6503adf8b35c21183b9387be64ca6810644ef54c9ef6c99d1d5635c01601b"}, - {file = "pycares-4.11.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5e1ab899bb0763dea5d6569300aab3a205572e6e2d0ef1a33b8cf2b86d1312a4"}, - {file = "pycares-4.11.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:9d0c543bdeefa4794582ef48f3c59e5e7a43d672a4bfad9cbbd531e897911690"}, - {file = "pycares-4.11.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:5344d52efa37df74728505a81dd52c15df639adffd166f7ddca7a6318ecdb605"}, - {file = "pycares-4.11.0-cp39-cp39-manylinux_2_28_ppc64le.whl", hash = "sha256:b50ca218a3e2e23cbda395fd002d030385202fbb8182aa87e11bea0a568bd0b8"}, - {file = "pycares-4.11.0-cp39-cp39-manylinux_2_28_s390x.whl", hash = "sha256:30feeab492ac609f38a0d30fab3dc1789bd19c48f725b2955bcaaef516e32a21"}, - {file = "pycares-4.11.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:6195208b16cce1a7b121727710a6f78e8403878c1017ab5a3f92158b048cec34"}, - {file = "pycares-4.11.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:77bf82dc0beb81262bf1c7f546e1c1fde4992e5c8a2343b867ca201b85f9e1aa"}, - {file = "pycares-4.11.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:aca981fc00c8af8d5b9254ea5c2f276df8ece089b081af1ef4856fbcfc7c698a"}, - {file = "pycares-4.11.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:96e07d5a8b733d753e37d1f7138e7321d2316bb3f0f663ab4e3d500fabc82807"}, - {file = "pycares-4.11.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:9a00408105901ede92e318eecb46d0e661d7d093d0a9b1224c71b5dd94f79e83"}, - {file = "pycares-4.11.0-cp39-cp39-win32.whl", hash = "sha256:910ce19a549f493fb55cfd1d7d70960706a03de6bfc896c1429fc5d6216df77e"}, - {file = "pycares-4.11.0-cp39-cp39-win_amd64.whl", hash = "sha256:6f751f5a0e4913b2787f237c2c69c11a53f599269012feaa9fb86d7cef3aec26"}, - {file = "pycares-4.11.0-cp39-cp39-win_arm64.whl", hash = "sha256:f6c602c5e3615abbf43dbdf3c6c64c65e76e5aa23cb74e18466b55d4a2095468"}, - {file = "pycares-4.11.0.tar.gz", hash = "sha256:c863d9003ca0ce7df26429007859afd2a621d3276ed9fef154a9123db9252557"}, -] - -[package.dependencies] -cffi = {version = ">=1.5.0", markers = "python_version < \"3.14\""} - -[package.extras] -idna = ["idna (>=2.1)"] - -[[package]] -name = "pycognito" -version = "2024.5.1" -description = "Python class to integrate Boto3's Cognito client so it is easy to login users. With SRP support." -optional = false -python-versions = ">=3.8" -groups = ["main", "dev"] -files = [ - {file = "pycognito-2024.5.1-py3-none-any.whl", hash = "sha256:c821895dc62b7aea410fdccae4f96d8be7cab374182339f50a03de0fcb93f9ea"}, - {file = "pycognito-2024.5.1.tar.gz", hash = "sha256:e211c66698c2c3dc8680e95107c2b4a922f504c3f7c179c27b8ee1ab0fc23ae4"}, -] - -[package.dependencies] -boto3 = ">=1.10.49" -envs = ">=1.3" -pyjwt = {version = ">=2.8.0", extras = ["crypto"]} -requests = ">=2.22.0" - -[[package]] -name = "pycparser" -version = "2.23" -description = "C parser in Python" -optional = false -python-versions = ">=3.8" -groups = ["main", "dev"] -markers = "implementation_name != \"PyPy\"" -files = [ - {file = "pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934"}, - {file = "pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2"}, -] - -[[package]] -name = "pydantic" -version = "2.11.7" -description = "Data validation using Python type hints" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "pydantic-2.11.7-py3-none-any.whl", hash = "sha256:dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b"}, - {file = "pydantic-2.11.7.tar.gz", hash = "sha256:d989c3c6cb79469287b1569f7447a17848c998458d49ebe294e975b9baf0f0db"}, -] - -[package.dependencies] -annotated-types = ">=0.6.0" -pydantic-core = "2.33.2" -typing-extensions = ">=4.12.2" -typing-inspection = ">=0.4.0" - -[package.extras] -email = ["email-validator (>=2.0.0)"] -timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows\""] - -[[package]] -name = "pydantic-core" -version = "2.33.2" -description = "Core functionality for Pydantic validation and serialization" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "pydantic_core-2.33.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2b3d326aaef0c0399d9afffeb6367d5e26ddc24d351dbc9c636840ac355dc5d8"}, - {file = "pydantic_core-2.33.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e5b2671f05ba48b94cb90ce55d8bdcaaedb8ba00cc5359f6810fc918713983d"}, - {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0069c9acc3f3981b9ff4cdfaf088e98d83440a4c7ea1bc07460af3d4dc22e72d"}, - {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d53b22f2032c42eaaf025f7c40c2e3b94568ae077a606f006d206a463bc69572"}, - {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0405262705a123b7ce9f0b92f123334d67b70fd1f20a9372b907ce1080c7ba02"}, - {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4b25d91e288e2c4e0662b8038a28c6a07eaac3e196cfc4ff69de4ea3db992a1b"}, - {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6bdfe4b3789761f3bcb4b1ddf33355a71079858958e3a552f16d5af19768fef2"}, - {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:efec8db3266b76ef9607c2c4c419bdb06bf335ae433b80816089ea7585816f6a"}, - {file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:031c57d67ca86902726e0fae2214ce6770bbe2f710dc33063187a68744a5ecac"}, - {file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:f8de619080e944347f5f20de29a975c2d815d9ddd8be9b9b7268e2e3ef68605a"}, - {file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:73662edf539e72a9440129f231ed3757faab89630d291b784ca99237fb94db2b"}, - {file = "pydantic_core-2.33.2-cp310-cp310-win32.whl", hash = "sha256:0a39979dcbb70998b0e505fb1556a1d550a0781463ce84ebf915ba293ccb7e22"}, - {file = "pydantic_core-2.33.2-cp310-cp310-win_amd64.whl", hash = "sha256:b0379a2b24882fef529ec3b4987cb5d003b9cda32256024e6fe1586ac45fc640"}, - {file = "pydantic_core-2.33.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4c5b0a576fb381edd6d27f0a85915c6daf2f8138dc5c267a57c08a62900758c7"}, - {file = "pydantic_core-2.33.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e799c050df38a639db758c617ec771fd8fb7a5f8eaaa4b27b101f266b216a246"}, - {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc46a01bf8d62f227d5ecee74178ffc448ff4e5197c756331f71efcc66dc980f"}, - {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a144d4f717285c6d9234a66778059f33a89096dfb9b39117663fd8413d582dcc"}, - {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73cf6373c21bc80b2e0dc88444f41ae60b2f070ed02095754eb5a01df12256de"}, - {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3dc625f4aa79713512d1976fe9f0bc99f706a9dee21dfd1810b4bbbf228d0e8a"}, - {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:881b21b5549499972441da4758d662aeea93f1923f953e9cbaff14b8b9565aef"}, - {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bdc25f3681f7b78572699569514036afe3c243bc3059d3942624e936ec93450e"}, - {file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:fe5b32187cbc0c862ee201ad66c30cf218e5ed468ec8dc1cf49dec66e160cc4d"}, - {file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:bc7aee6f634a6f4a95676fcb5d6559a2c2a390330098dba5e5a5f28a2e4ada30"}, - {file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:235f45e5dbcccf6bd99f9f472858849f73d11120d76ea8707115415f8e5ebebf"}, - {file = "pydantic_core-2.33.2-cp311-cp311-win32.whl", hash = "sha256:6368900c2d3ef09b69cb0b913f9f8263b03786e5b2a387706c5afb66800efd51"}, - {file = "pydantic_core-2.33.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e063337ef9e9820c77acc768546325ebe04ee38b08703244c1309cccc4f1bab"}, - {file = "pydantic_core-2.33.2-cp311-cp311-win_arm64.whl", hash = "sha256:6b99022f1d19bc32a4c2a0d544fc9a76e3be90f0b3f4af413f87d38749300e65"}, - {file = "pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc"}, - {file = "pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7"}, - {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025"}, - {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011"}, - {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f"}, - {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88"}, - {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1"}, - {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b"}, - {file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1"}, - {file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6"}, - {file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea"}, - {file = "pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290"}, - {file = "pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2"}, - {file = "pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab"}, - {file = "pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f"}, - {file = "pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6"}, - {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef"}, - {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a"}, - {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916"}, - {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a"}, - {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d"}, - {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56"}, - {file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5"}, - {file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e"}, - {file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162"}, - {file = "pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849"}, - {file = "pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9"}, - {file = "pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9"}, - {file = "pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac"}, - {file = "pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5"}, - {file = "pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9"}, - {file = "pydantic_core-2.33.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:a2b911a5b90e0374d03813674bf0a5fbbb7741570dcd4b4e85a2e48d17def29d"}, - {file = "pydantic_core-2.33.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6fa6dfc3e4d1f734a34710f391ae822e0a8eb8559a85c6979e14e65ee6ba2954"}, - {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c54c939ee22dc8e2d545da79fc5381f1c020d6d3141d3bd747eab59164dc89fb"}, - {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:53a57d2ed685940a504248187d5685e49eb5eef0f696853647bf37c418c538f7"}, - {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09fb9dd6571aacd023fe6aaca316bd01cf60ab27240d7eb39ebd66a3a15293b4"}, - {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0e6116757f7959a712db11f3e9c0a99ade00a5bbedae83cb801985aa154f071b"}, - {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d55ab81c57b8ff8548c3e4947f119551253f4e3787a7bbc0b6b3ca47498a9d3"}, - {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c20c462aa4434b33a2661701b861604913f912254e441ab8d78d30485736115a"}, - {file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:44857c3227d3fb5e753d5fe4a3420d6376fa594b07b621e220cd93703fe21782"}, - {file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:eb9b459ca4df0e5c87deb59d37377461a538852765293f9e6ee834f0435a93b9"}, - {file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:9fcd347d2cc5c23b06de6d3b7b8275be558a0c90549495c699e379a80bf8379e"}, - {file = "pydantic_core-2.33.2-cp39-cp39-win32.whl", hash = "sha256:83aa99b1285bc8f038941ddf598501a86f1536789740991d7d8756e34f1e74d9"}, - {file = "pydantic_core-2.33.2-cp39-cp39-win_amd64.whl", hash = "sha256:f481959862f57f29601ccced557cc2e817bce7533ab8e01a797a48b49c9692b3"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5c4aa4e82353f65e548c476b37e64189783aa5384903bfea4f41580f255fddfa"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d946c8bf0d5c24bf4fe333af284c59a19358aa3ec18cb3dc4370080da1e8ad29"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87b31b6846e361ef83fedb187bb5b4372d0da3f7e28d85415efa92d6125d6e6d"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa9d91b338f2df0508606f7009fde642391425189bba6d8c653afd80fd6bb64e"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2058a32994f1fde4ca0480ab9d1e75a0e8c87c22b53a3ae66554f9af78f2fe8c"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:0e03262ab796d986f978f79c943fc5f620381be7287148b8010b4097f79a39ec"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:1a8695a8d00c73e50bff9dfda4d540b7dee29ff9b8053e38380426a85ef10052"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:fa754d1850735a0b0e03bcffd9d4b4343eb417e47196e4485d9cca326073a42c"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:a11c8d26a50bfab49002947d3d237abe4d9e4b5bdc8846a63537b6488e197808"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:dd14041875d09cc0f9308e37a6f8b65f5585cf2598a53aa0123df8b129d481f8"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d87c561733f66531dced0da6e864f44ebf89a8fba55f31407b00c2f7f9449593"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f82865531efd18d6e07a04a17331af02cb7a651583c418df8266f17a63c6612"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bfb5112df54209d820d7bf9317c7a6c9025ea52e49f46b6a2060104bba37de7"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:64632ff9d614e5eecfb495796ad51b0ed98c453e447a76bcbeeb69615079fc7e"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:f889f7a40498cc077332c7ab6b4608d296d852182211787d4f3ee377aaae66e8"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:de4b83bb311557e439b9e186f733f6c645b9417c84e2eb8203f3f820a4b988bf"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:82f68293f055f51b51ea42fafc74b6aad03e70e191799430b90c13d643059ebb"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:87acbfcf8e90ca885206e98359d7dca4bcbb35abdc0ff66672a293e1d7a19101"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:7f92c15cd1e97d4b12acd1cc9004fa092578acfa57b67ad5e43a197175d01a64"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d3f26877a748dc4251cfcfda9dfb5f13fcb034f5308388066bcfe9031b63ae7d"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dac89aea9af8cd672fa7b510e7b8c33b0bba9a43186680550ccf23020f32d535"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:970919794d126ba8645f3837ab6046fb4e72bbc057b3709144066204c19a455d"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:3eb3fe62804e8f859c49ed20a8451342de53ed764150cb14ca71357c765dc2a6"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:3abcd9392a36025e3bd55f9bd38d908bd17962cc49bc6da8e7e96285336e2bca"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:3a1c81334778f9e3af2f8aeb7a960736e5cab1dfebfb26aabca09afd2906c039"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2807668ba86cb38c6817ad9bc66215ab8584d1d304030ce4f0887336f28a5e27"}, - {file = "pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc"}, -] - -[package.dependencies] -typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" - -[[package]] -name = "pygments" -version = "2.19.2" -description = "Pygments is a syntax highlighting package written in Python." -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"}, - {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"}, -] - -[package.extras] -windows-terminal = ["colorama (>=0.4.6)"] - -[[package]] -name = "pyjwt" -version = "2.10.1" -description = "JSON Web Token implementation in Python" -optional = false -python-versions = ">=3.9" -groups = ["main", "dev"] -files = [ - {file = "PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb"}, - {file = "pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953"}, -] - -[package.dependencies] -cryptography = {version = ">=3.4.0", optional = true, markers = "extra == \"crypto\""} - -[package.extras] -crypto = ["cryptography (>=3.4.0)"] -dev = ["coverage[toml] (==5.0.4)", "cryptography (>=3.4.0)", "pre-commit", "pytest (>=6.0.0,<7.0.0)", "sphinx", "sphinx-rtd-theme", "zope.interface"] -docs = ["sphinx", "sphinx-rtd-theme", "zope.interface"] -tests = ["coverage[toml] (==5.0.4)", "pytest (>=6.0.0,<7.0.0)"] - -[[package]] -name = "pylint" -version = "3.3.8" -description = "python code static checker" -optional = false -python-versions = ">=3.9.0" -groups = ["dev"] -files = [ - {file = "pylint-3.3.8-py3-none-any.whl", hash = "sha256:7ef94aa692a600e82fabdd17102b73fc226758218c97473c7ad67bd4cb905d83"}, - {file = "pylint-3.3.8.tar.gz", hash = "sha256:26698de19941363037e2937d3db9ed94fb3303fdadf7d98847875345a8bb6b05"}, -] - -[package.dependencies] -astroid = ">=3.3.8,<=3.4.0.dev0" -colorama = {version = ">=0.4.5", markers = "sys_platform == \"win32\""} -dill = {version = ">=0.3.7", markers = "python_version >= \"3.12\""} -isort = ">=4.2.5,<5.13 || >5.13,<7" -mccabe = ">=0.6,<0.8" -platformdirs = ">=2.2" -tomlkit = ">=0.10.1" - -[package.extras] -spelling = ["pyenchant (>=3.2,<4.0)"] -testutils = ["gitpython (>3)"] - -[[package]] -name = "pylint-per-file-ignores" -version = "1.4.0" -description = "A pylint plugin to ignore error codes per file." -optional = false -python-versions = "<4.0.0,>=3.8.1" -groups = ["dev"] -files = [ - {file = "pylint_per_file_ignores-1.4.0-py3-none-any.whl", hash = "sha256:0cd82d22551738b4e63a0aa1dab2a1fc4016e8f27f1429159616483711e122fd"}, - {file = "pylint_per_file_ignores-1.4.0.tar.gz", hash = "sha256:c0de7b3d0169571aefaa1ac3a82a265641b8825b54a0b6f5ef27c3b76b988609"}, -] - -[[package]] -name = "pyobjc-core" -version = "11.1" -description = "Python<->ObjC Interoperability Module" -optional = false -python-versions = ">=3.8" -groups = ["main", "dev"] -markers = "platform_system == \"Darwin\"" -files = [ - {file = "pyobjc_core-11.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4c7536f3e94de0a3eae6bb382d75f1219280aa867cdf37beef39d9e7d580173c"}, - {file = "pyobjc_core-11.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ec36680b5c14e2f73d432b03ba7c1457dc6ca70fa59fd7daea1073f2b4157d33"}, - {file = "pyobjc_core-11.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:765b97dea6b87ec4612b3212258024d8496ea23517c95a1c5f0735f96b7fd529"}, - {file = "pyobjc_core-11.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:18986f83998fbd5d3f56d8a8428b2f3e0754fd15cef3ef786ca0d29619024f2c"}, - {file = "pyobjc_core-11.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:8849e78cfe6595c4911fbba29683decfb0bf57a350aed8a43316976ba6f659d2"}, - {file = "pyobjc_core-11.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8cb9ed17a8d84a312a6e8b665dd22393d48336ea1d8277e7ad20c19a38edf731"}, - {file = "pyobjc_core-11.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:f2455683e807f8541f0d83fbba0f5d9a46128ab0d5cc83ea208f0bec759b7f96"}, - {file = "pyobjc_core-11.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:4a99e6558b48b8e47c092051e7b3be05df1c8d0617b62f6fa6a316c01902d157"}, - {file = "pyobjc_core-11.1.tar.gz", hash = "sha256:b63d4d90c5df7e762f34739b39cc55bc63dbcf9fb2fb3f2671e528488c7a87fe"}, -] - -[[package]] -name = "pyobjc-framework-cocoa" -version = "11.1" -description = "Wrappers for the Cocoa frameworks on macOS" -optional = false -python-versions = ">=3.9" -groups = ["main", "dev"] -markers = "platform_system == \"Darwin\"" -files = [ - {file = "pyobjc_framework_cocoa-11.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b27a5bdb3ab6cdeb998443ff3fce194ffae5f518c6a079b832dbafc4426937f9"}, - {file = "pyobjc_framework_cocoa-11.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7b9a9b8ba07f5bf84866399e3de2aa311ed1c34d5d2788a995bdbe82cc36cfa0"}, - {file = "pyobjc_framework_cocoa-11.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:806de56f06dfba8f301a244cce289d54877c36b4b19818e3b53150eb7c2424d0"}, - {file = "pyobjc_framework_cocoa-11.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:54e93e1d9b0fc41c032582a6f0834befe1d418d73893968f3f450281b11603da"}, - {file = "pyobjc_framework_cocoa-11.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:fd5245ee1997d93e78b72703be1289d75d88ff6490af94462b564892e9266350"}, - {file = "pyobjc_framework_cocoa-11.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:aede53a1afc5433e1e7d66568cc52acceeb171b0a6005407a42e8e82580b4fc0"}, - {file = "pyobjc_framework_cocoa-11.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:1b5de4e1757bb65689d6dc1f8d8717de9ec8587eb0c4831c134f13aba29f9b71"}, - {file = "pyobjc_framework_cocoa-11.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:bbee71eeb93b1b31ffbac8560b59a0524a8a4b90846a260d2c4f2188f3d4c721"}, - {file = "pyobjc_framework_cocoa-11.1.tar.gz", hash = "sha256:87df76b9b73e7ca699a828ff112564b59251bb9bbe72e610e670a4dc9940d038"}, -] - -[package.dependencies] -pyobjc-core = ">=11.1" - -[[package]] -name = "pyobjc-framework-corebluetooth" -version = "11.1" -description = "Wrappers for the framework CoreBluetooth on macOS" -optional = false -python-versions = ">=3.9" -groups = ["main", "dev"] -markers = "platform_system == \"Darwin\"" -files = [ - {file = "pyobjc_framework_corebluetooth-11.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ab509994503a5f0ec0f446a7ccc9f9a672d5a427d40dba4563dd00e8e17dfb06"}, - {file = "pyobjc_framework_corebluetooth-11.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:433b8593eb1ea8b6262b243ec903e1de4434b768ce103ebe15aac249b890cc2a"}, - {file = "pyobjc_framework_corebluetooth-11.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:36bef95a822c68b72f505cf909913affd61a15b56eeaeafea7302d35a82f4f05"}, - {file = "pyobjc_framework_corebluetooth-11.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:992404b03033ecf637e9174caed70cb22fd1be2a98c16faa699217678e62a5c7"}, - {file = "pyobjc_framework_corebluetooth-11.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:ebb8648f5e33d98446eb1d6c4654ba4fcc15d62bfcb47fa3bbd5596f6ecdb37c"}, - {file = "pyobjc_framework_corebluetooth-11.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:e84cbf52006a93d937b90421ada0bc4a146d6d348eb40ae10d5bd2256cc92206"}, - {file = "pyobjc_framework_corebluetooth-11.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:4da1106265d7efd3f726bacdf13ba9528cc380fb534b5af38b22a397e6908291"}, - {file = "pyobjc_framework_corebluetooth-11.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:e9fa3781fea20a31b3bb809deaeeab3bdc7b86602a1fd829f0e86db11d7aa577"}, - {file = "pyobjc_framework_corebluetooth-11.1.tar.gz", hash = "sha256:1deba46e3fcaf5e1c314f4bbafb77d9fe49ec248c493ad00d8aff2df212d6190"}, -] - -[package.dependencies] -pyobjc-core = ">=11.1" -pyobjc-framework-Cocoa = ">=11.1" - -[[package]] -name = "pyobjc-framework-libdispatch" -version = "11.1" -description = "Wrappers for libdispatch on macOS" -optional = false -python-versions = ">=3.9" -groups = ["main", "dev"] -markers = "platform_system == \"Darwin\"" -files = [ - {file = "pyobjc_framework_libdispatch-11.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:9c598c073a541b5956b5457b94bd33b9ce19ef8d867235439a0fad22d6beab49"}, - {file = "pyobjc_framework_libdispatch-11.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:2ddca472c2cbc6bb192e05b8b501d528ce49333abe7ef0eef28df3133a8e18b7"}, - {file = "pyobjc_framework_libdispatch-11.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:dc9a7b8c2e8a63789b7cf69563bb7247bde15353208ef1353fff0af61b281684"}, - {file = "pyobjc_framework_libdispatch-11.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c4e219849f5426745eb429f3aee58342a59f81e3144b37aa20e81dacc6177de1"}, - {file = "pyobjc_framework_libdispatch-11.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a9357736cb47b4a789f59f8fab9b0d10b0a9c84f9876367c398718d3de085888"}, - {file = "pyobjc_framework_libdispatch-11.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:cd08f32ea7724906ef504a0fd40a32e2a0be4d64b9239530a31767ca9ccfc921"}, - {file = "pyobjc_framework_libdispatch-11.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:5d9985b0e050cae72bf2c6a1cc8180ff4fa3a812cd63b2dc59e09c6f7f6263a1"}, - {file = "pyobjc_framework_libdispatch-11.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:cfe515f4c3ea66c13fce4a527230027517b8b779b40bbcb220ff7cdf3ad20bc4"}, - {file = "pyobjc_framework_libdispatch-11.1.tar.gz", hash = "sha256:11a704e50a0b7dbfb01552b7d686473ffa63b5254100fdb271a1fe368dd08e87"}, -] - -[package.dependencies] -pyobjc-core = ">=11.1" -pyobjc-framework-Cocoa = ">=11.1" - -[[package]] -name = "pyopenssl" -version = "25.1.0" -description = "Python wrapper module around the OpenSSL library" -optional = false -python-versions = ">=3.7" -groups = ["main", "dev"] -files = [ - {file = "pyopenssl-25.1.0-py3-none-any.whl", hash = "sha256:2b11f239acc47ac2e5aca04fd7fa829800aeee22a2eb30d744572a157bd8a1ab"}, - {file = "pyopenssl-25.1.0.tar.gz", hash = "sha256:8d031884482e0c67ee92bf9a4d8cceb08d92aba7136432ffb0703c5280fc205b"}, -] - -[package.dependencies] -cryptography = ">=41.0.5,<46" - -[package.extras] -docs = ["sphinx (!=5.2.0,!=5.2.0.post0,!=7.2.5)", "sphinx_rtd_theme"] -test = ["pretend", "pytest (>=3.0.1)", "pytest-rerunfailures"] - -[[package]] -name = "pyrfc3339" -version = "2.1.0" -description = "Generate and parse RFC 3339 timestamps" -optional = false -python-versions = ">=3.9" -groups = ["main", "dev"] -files = [ - {file = "pyrfc3339-2.1.0-py3-none-any.whl", hash = "sha256:560f3f972e339f579513fe1396974352fd575ef27caff160a38b312252fcddf3"}, - {file = "pyrfc3339-2.1.0.tar.gz", hash = "sha256:c569a9714faf115cdb20b51e830e798c1f4de8dabb07f6ff25d221b5d09d8d7f"}, -] - -[[package]] -name = "pyric" -version = "0.1.6.3" -description = "Python Wireless Library" -optional = false -python-versions = "*" -groups = ["main", "dev"] -files = [ - {file = "PyRIC-0.1.6.3.tar.gz", hash = "sha256:b539b01cafebd2406c00097f94525ea0f8ecd1dd92f7731f43eac0ef16c2ccc9"}, -] - -[[package]] -name = "pyright" -version = "1.1.405" -description = "Command line wrapper for pyright" -optional = false -python-versions = ">=3.7" -groups = ["dev"] -files = [ - {file = "pyright-1.1.405-py3-none-any.whl", hash = "sha256:a2cb13700b5508ce8e5d4546034cb7ea4aedb60215c6c33f56cec7f53996035a"}, - {file = "pyright-1.1.405.tar.gz", hash = "sha256:5c2a30e1037af27eb463a1cc0b9f6d65fec48478ccf092c1ac28385a15c55763"}, -] - -[package.dependencies] -nodeenv = ">=1.6.0" -typing-extensions = ">=4.1" - -[package.extras] -all = ["nodejs-wheel-binaries", "twine (>=3.4.1)"] -dev = ["twine (>=3.4.1)"] -nodejs = ["nodejs-wheel-binaries"] - -[[package]] -name = "pytest" -version = "8.4.1" -description = "pytest: simple powerful testing with Python" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "pytest-8.4.1-py3-none-any.whl", hash = "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7"}, - {file = "pytest-8.4.1.tar.gz", hash = "sha256:7c67fd69174877359ed9371ec3af8a3d2b04741818c51e5e99cc1742251fa93c"}, -] - -[package.dependencies] -colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} -iniconfig = ">=1" -packaging = ">=20" -pluggy = ">=1.5,<2" -pygments = ">=2.7.2" - -[package.extras] -dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] - -[[package]] -name = "pytest-aiohttp" -version = "1.1.0" -description = "Pytest plugin for aiohttp support" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "pytest_aiohttp-1.1.0-py3-none-any.whl", hash = "sha256:f39a11693a0dce08dd6c542d241e199dd8047a6e6596b2bcfa60d373f143456d"}, - {file = "pytest_aiohttp-1.1.0.tar.gz", hash = "sha256:147de8cb164f3fc9d7196967f109ab3c0b93ea3463ab50631e56438eab7b5adc"}, -] - -[package.dependencies] -aiohttp = ">=3.11.0b0" -pytest = ">=6.1.0" -pytest-asyncio = ">=0.17.2" - -[package.extras] -testing = ["coverage (==6.2)", "mypy (==1.12.1)"] - -[[package]] -name = "pytest-asyncio" -version = "1.1.0" -description = "Pytest support for asyncio" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "pytest_asyncio-1.1.0-py3-none-any.whl", hash = "sha256:5fe2d69607b0bd75c656d1211f969cadba035030156745ee09e7d71740e58ecf"}, - {file = "pytest_asyncio-1.1.0.tar.gz", hash = "sha256:796aa822981e01b68c12e4827b8697108f7205020f24b5793b3c41555dab68ea"}, -] - -[package.dependencies] -pytest = ">=8.2,<9" - -[package.extras] -docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1)"] -testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] - -[[package]] -name = "pytest-cov" -version = "6.2.1" -description = "Pytest plugin for measuring coverage." -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "pytest_cov-6.2.1-py3-none-any.whl", hash = "sha256:f5bc4c23f42f1cdd23c70b1dab1bbaef4fc505ba950d53e0081d0730dd7e86d5"}, - {file = "pytest_cov-6.2.1.tar.gz", hash = "sha256:25cc6cc0a5358204b8108ecedc51a9b57b34cc6b8c967cc2c01a4e00d8a67da2"}, -] - -[package.dependencies] -coverage = {version = ">=7.5", extras = ["toml"]} -pluggy = ">=1.2" -pytest = ">=6.2.5" - -[package.extras] -testing = ["fields", "hunter", "process-tests", "pytest-xdist", "virtualenv"] - -[[package]] -name = "pytest-freezer" -version = "0.4.9" -description = "Pytest plugin providing a fixture interface for spulec/freezegun" -optional = false -python-versions = ">=3.6" -groups = ["dev"] -files = [ - {file = "pytest_freezer-0.4.9-py3-none-any.whl", hash = "sha256:8b6c50523b7d4aec4590b52bfa5ff766d772ce506e2bf4846c88041ea9ccae59"}, - {file = "pytest_freezer-0.4.9.tar.gz", hash = "sha256:21bf16bc9cc46bf98f94382c4b5c3c389be7056ff0be33029111ae11b3f1c82a"}, -] - -[package.dependencies] -freezegun = ">=1.1" -pytest = ">=3.6" - -[[package]] -name = "pytest-github-actions-annotate-failures" -version = "0.3.0" -description = "pytest plugin to annotate failed tests with a workflow command for GitHub Actions" -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "pytest_github_actions_annotate_failures-0.3.0-py3-none-any.whl", hash = "sha256:41ea558ba10c332c0bfc053daeee0c85187507b2034e990f21e4f7e5fef044cf"}, - {file = "pytest_github_actions_annotate_failures-0.3.0.tar.gz", hash = "sha256:d4c3177c98046c3900a7f8ddebb22ea54b9f6822201b5d3ab8fcdea51e010db7"}, -] - -[package.dependencies] -pytest = ">=6.0.0" - -[[package]] -name = "pytest-homeassistant-custom-component" -version = "0.13.280" -description = "Experimental package to automatically extract test plugins for Home Assistant custom components" -optional = false -python-versions = ">=3.13" -groups = ["dev"] -files = [ - {file = "pytest_homeassistant_custom_component-0.13.280-py3-none-any.whl", hash = "sha256:7b16812e04f0a2fababd0d13bff6ae2993814108d92faf2b28d59e6601bc0e94"}, - {file = "pytest_homeassistant_custom_component-0.13.280.tar.gz", hash = "sha256:a6d28b841b9f30b345ac9b8bc3bdc11411e769102f14c7d99921c65ee290b1e9"}, -] - -[package.dependencies] -coverage = "7.10.0" -freezegun = "1.5.2" -go2rtc-client = "0.2.1" -homeassistant = "2025.9.3" -license-expression = "30.4.3" -mock-open = "1.4.0" -numpy = "2.3.2" -paho-mqtt = "2.1.0" -pipdeptree = "2.26.1" -pydantic = "2.11.7" -pylint-per-file-ignores = "1.4.0" -pytest = "8.4.1" -pytest-aiohttp = "1.1.0" -pytest-asyncio = "1.1.0" -pytest-cov = "6.2.1" -pytest-freezer = "0.4.9" -pytest-github-actions-annotate-failures = "0.3.0" -pytest-picked = "0.5.1" -pytest-socket = "0.7.0" -pytest-sugar = "1.0.0" -pytest-timeout = "2.4.0" -pytest-unordered = "0.7.0" -pytest-xdist = "3.8.0" -requests-mock = "1.12.1" -respx = "0.22.0" -sqlalchemy = "2.0.41" -syrupy = "4.9.1" -tqdm = "4.67.1" - -[[package]] -name = "pytest-picked" -version = "0.5.1" -description = "Run the tests related to the changed files" -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "pytest_picked-0.5.1-py3-none-any.whl", hash = "sha256:af65c4763b51dc095ae4bc5073a962406902422ad9629c26d8b01122b677d998"}, - {file = "pytest_picked-0.5.1.tar.gz", hash = "sha256:6634c4356a560a5dc3dba35471865e6eb06bbd356b56b69c540593e9d5620ded"}, -] - -[package.dependencies] -pytest = ">=3.7.0" - -[[package]] -name = "pytest-socket" -version = "0.7.0" -description = "Pytest Plugin to disable socket calls during tests" -optional = false -python-versions = ">=3.8,<4.0" -groups = ["dev"] -files = [ - {file = "pytest_socket-0.7.0-py3-none-any.whl", hash = "sha256:7e0f4642177d55d317bbd58fc68c6bd9048d6eadb2d46a89307fa9221336ce45"}, - {file = "pytest_socket-0.7.0.tar.gz", hash = "sha256:71ab048cbbcb085c15a4423b73b619a8b35d6a307f46f78ea46be51b1b7e11b3"}, -] - -[package.dependencies] -pytest = ">=6.2.5" - -[[package]] -name = "pytest-sugar" -version = "1.0.0" -description = "pytest-sugar is a plugin for pytest that changes the default look and feel of pytest (e.g. progressbar, show tests that fail instantly)." -optional = false -python-versions = "*" -groups = ["dev"] -files = [ - {file = "pytest-sugar-1.0.0.tar.gz", hash = "sha256:6422e83258f5b0c04ce7c632176c7732cab5fdb909cb39cca5c9139f81276c0a"}, - {file = "pytest_sugar-1.0.0-py3-none-any.whl", hash = "sha256:70ebcd8fc5795dc457ff8b69d266a4e2e8a74ae0c3edc749381c64b5246c8dfd"}, -] - -[package.dependencies] -packaging = ">=21.3" -pytest = ">=6.2.0" -termcolor = ">=2.1.0" - -[package.extras] -dev = ["black", "flake8", "pre-commit"] - -[[package]] -name = "pytest-timeout" -version = "2.4.0" -description = "pytest plugin to abort hanging tests" -optional = false -python-versions = ">=3.7" -groups = ["dev"] -files = [ - {file = "pytest_timeout-2.4.0-py3-none-any.whl", hash = "sha256:c42667e5cdadb151aeb5b26d114aff6bdf5a907f176a007a30b940d3d865b5c2"}, - {file = "pytest_timeout-2.4.0.tar.gz", hash = "sha256:7e68e90b01f9eff71332b25001f85c75495fc4e3a836701876183c4bcfd0540a"}, -] - -[package.dependencies] -pytest = ">=7.0.0" - -[[package]] -name = "pytest-unordered" -version = "0.7.0" -description = "Test equality of unordered collections in pytest" -optional = false -python-versions = "*" -groups = ["dev"] -files = [ - {file = "pytest_unordered-0.7.0-py3-none-any.whl", hash = "sha256:486b26d24a2d3b879a275c3d16d14eda1bd9c32aafddbb17b98ac755daba7584"}, - {file = "pytest_unordered-0.7.0.tar.gz", hash = "sha256:0f953a438db00a9f6f99a0f4727f2d75e72dd93319b3d548a97ec9db4903a44f"}, -] - -[package.dependencies] -pytest = ">=7.0.0" - -[[package]] -name = "pytest-xdist" -version = "3.8.0" -description = "pytest xdist plugin for distributed testing, most importantly across multiple CPUs" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88"}, - {file = "pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1"}, -] - -[package.dependencies] -execnet = ">=2.1" -pytest = ">=7.0.0" - -[package.extras] -psutil = ["psutil (>=3.0)"] -setproctitle = ["setproctitle"] -testing = ["filelock"] - -[[package]] -name = "python-dateutil" -version = "2.9.0.post0" -description = "Extensions to the standard Python datetime module" -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" -groups = ["main", "dev"] -files = [ - {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, - {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, -] - -[package.dependencies] -six = ">=1.5" - -[[package]] -name = "python-direnv" -version = "0.2.2" -description = "Loads environment variables from a direnv .envrc file." -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "python_direnv-0.2.2-py3-none-any.whl", hash = "sha256:a617d14f093f13dd9a858e88c2914bdb16edee992b5148efd8c23c10ca1b50d9"}, - {file = "python_direnv-0.2.2.tar.gz", hash = "sha256:0fe2fb834c901d675edcacc688689cfcf55cf06d9cf27dc7d3768a6c38c35f00"}, -] - -[[package]] -name = "python-slugify" -version = "8.0.4" -description = "A Python slugify application that also handles Unicode" -optional = false -python-versions = ">=3.7" -groups = ["main", "dev"] -files = [ - {file = "python-slugify-8.0.4.tar.gz", hash = "sha256:59202371d1d05b54a9e7720c5e038f928f45daaffe41dd10822f3907b937c856"}, - {file = "python_slugify-8.0.4-py2.py3-none-any.whl", hash = "sha256:276540b79961052b66b7d116620b36518847f52d5fd9e3a70164fc8c50faa6b8"}, -] - -[package.dependencies] -text-unidecode = ">=1.3" - -[package.extras] -unidecode = ["Unidecode (>=1.1.1)"] - -[[package]] -name = "pytz" -version = "2025.2" -description = "World timezone definitions, modern and historical" -optional = false -python-versions = "*" -groups = ["main", "dev"] -files = [ - {file = "pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00"}, - {file = "pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3"}, -] - -[[package]] -name = "pyyaml" -version = "6.0.2" -description = "YAML parser and emitter for Python" -optional = false -python-versions = ">=3.8" -groups = ["main", "dev"] -files = [ - {file = "PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086"}, - {file = "PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf"}, - {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237"}, - {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b"}, - {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed"}, - {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180"}, - {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68"}, - {file = "PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99"}, - {file = "PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e"}, - {file = "PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774"}, - {file = "PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee"}, - {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c"}, - {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317"}, - {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85"}, - {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4"}, - {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e"}, - {file = "PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5"}, - {file = "PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44"}, - {file = "PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab"}, - {file = "PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725"}, - {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5"}, - {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425"}, - {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476"}, - {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48"}, - {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b"}, - {file = "PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4"}, - {file = "PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8"}, - {file = "PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba"}, - {file = "PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1"}, - {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133"}, - {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484"}, - {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5"}, - {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc"}, - {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652"}, - {file = "PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183"}, - {file = "PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563"}, - {file = "PyYAML-6.0.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:24471b829b3bf607e04e88d79542a9d48bb037c2267d7927a874e6c205ca7e9a"}, - {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7fded462629cfa4b685c5416b949ebad6cec74af5e2d42905d41e257e0869f5"}, - {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d84a1718ee396f54f3a086ea0a66d8e552b2ab2017ef8b420e92edbc841c352d"}, - {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9056c1ecd25795207ad294bcf39f2db3d845767be0ea6e6a34d856f006006083"}, - {file = "PyYAML-6.0.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:82d09873e40955485746739bcb8b4586983670466c23382c19cffecbf1fd8706"}, - {file = "PyYAML-6.0.2-cp38-cp38-win32.whl", hash = "sha256:43fa96a3ca0d6b1812e01ced1044a003533c47f6ee8aca31724f78e93ccc089a"}, - {file = "PyYAML-6.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:01179a4a8559ab5de078078f37e5c1a30d76bb88519906844fd7bdea1b7729ff"}, - {file = "PyYAML-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d"}, - {file = "PyYAML-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f"}, - {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290"}, - {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12"}, - {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19"}, - {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e"}, - {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725"}, - {file = "PyYAML-6.0.2-cp39-cp39-win32.whl", hash = "sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631"}, - {file = "PyYAML-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8"}, - {file = "pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e"}, -] - -[[package]] -name = "radon" -version = "6.0.1" -description = "Code Metrics in Python" -optional = false -python-versions = "*" -groups = ["dev"] -files = [ - {file = "radon-6.0.1-py2.py3-none-any.whl", hash = "sha256:632cc032364a6f8bb1010a2f6a12d0f14bc7e5ede76585ef29dc0cecf4cd8859"}, - {file = "radon-6.0.1.tar.gz", hash = "sha256:d1ac0053943a893878940fedc8b19ace70386fc9c9bf0a09229a44125ebf45b5"}, -] - -[package.dependencies] -colorama = {version = ">=0.4.1", markers = "python_version > \"3.4\""} -mando = ">=0.6,<0.8" - -[package.extras] -toml = ["tomli (>=2.0.1)"] - -[[package]] -name = "regex" -version = "2024.11.6" -description = "Alternative regular expression module, to replace re." -optional = false -python-versions = ">=3.8" -groups = ["main", "dev"] -files = [ - {file = "regex-2024.11.6-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ff590880083d60acc0433f9c3f713c51f7ac6ebb9adf889c79a261ecf541aa91"}, - {file = "regex-2024.11.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:658f90550f38270639e83ce492f27d2c8d2cd63805c65a13a14d36ca126753f0"}, - {file = "regex-2024.11.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:164d8b7b3b4bcb2068b97428060b2a53be050085ef94eca7f240e7947f1b080e"}, - {file = "regex-2024.11.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d3660c82f209655a06b587d55e723f0b813d3a7db2e32e5e7dc64ac2a9e86fde"}, - {file = "regex-2024.11.6-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d22326fcdef5e08c154280b71163ced384b428343ae16a5ab2b3354aed12436e"}, - {file = "regex-2024.11.6-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f1ac758ef6aebfc8943560194e9fd0fa18bcb34d89fd8bd2af18183afd8da3a2"}, - {file = "regex-2024.11.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:997d6a487ff00807ba810e0f8332c18b4eb8d29463cfb7c820dc4b6e7562d0cf"}, - {file = "regex-2024.11.6-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:02a02d2bb04fec86ad61f3ea7f49c015a0681bf76abb9857f945d26159d2968c"}, - {file = "regex-2024.11.6-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f02f93b92358ee3f78660e43b4b0091229260c5d5c408d17d60bf26b6c900e86"}, - {file = "regex-2024.11.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:06eb1be98df10e81ebaded73fcd51989dcf534e3c753466e4b60c4697a003b67"}, - {file = "regex-2024.11.6-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:040df6fe1a5504eb0f04f048e6d09cd7c7110fef851d7c567a6b6e09942feb7d"}, - {file = "regex-2024.11.6-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:fdabbfc59f2c6edba2a6622c647b716e34e8e3867e0ab975412c5c2f79b82da2"}, - {file = "regex-2024.11.6-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:8447d2d39b5abe381419319f942de20b7ecd60ce86f16a23b0698f22e1b70008"}, - {file = "regex-2024.11.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:da8f5fc57d1933de22a9e23eec290a0d8a5927a5370d24bda9a6abe50683fe62"}, - {file = "regex-2024.11.6-cp310-cp310-win32.whl", hash = "sha256:b489578720afb782f6ccf2840920f3a32e31ba28a4b162e13900c3e6bd3f930e"}, - {file = "regex-2024.11.6-cp310-cp310-win_amd64.whl", hash = "sha256:5071b2093e793357c9d8b2929dfc13ac5f0a6c650559503bb81189d0a3814519"}, - {file = "regex-2024.11.6-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5478c6962ad548b54a591778e93cd7c456a7a29f8eca9c49e4f9a806dcc5d638"}, - {file = "regex-2024.11.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2c89a8cc122b25ce6945f0423dc1352cb9593c68abd19223eebbd4e56612c5b7"}, - {file = "regex-2024.11.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:94d87b689cdd831934fa3ce16cc15cd65748e6d689f5d2b8f4f4df2065c9fa20"}, - {file = "regex-2024.11.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1062b39a0a2b75a9c694f7a08e7183a80c63c0d62b301418ffd9c35f55aaa114"}, - {file = "regex-2024.11.6-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:167ed4852351d8a750da48712c3930b031f6efdaa0f22fa1933716bfcd6bf4a3"}, - {file = "regex-2024.11.6-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d548dafee61f06ebdb584080621f3e0c23fff312f0de1afc776e2a2ba99a74f"}, - {file = "regex-2024.11.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2a19f302cd1ce5dd01a9099aaa19cae6173306d1302a43b627f62e21cf18ac0"}, - {file = "regex-2024.11.6-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bec9931dfb61ddd8ef2ebc05646293812cb6b16b60cf7c9511a832b6f1854b55"}, - {file = "regex-2024.11.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9714398225f299aa85267fd222f7142fcb5c769e73d7733344efc46f2ef5cf89"}, - {file = "regex-2024.11.6-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:202eb32e89f60fc147a41e55cb086db2a3f8cb82f9a9a88440dcfc5d37faae8d"}, - {file = "regex-2024.11.6-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:4181b814e56078e9b00427ca358ec44333765f5ca1b45597ec7446d3a1ef6e34"}, - {file = "regex-2024.11.6-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:068376da5a7e4da51968ce4c122a7cd31afaaec4fccc7856c92f63876e57b51d"}, - {file = "regex-2024.11.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ac10f2c4184420d881a3475fb2c6f4d95d53a8d50209a2500723d831036f7c45"}, - {file = "regex-2024.11.6-cp311-cp311-win32.whl", hash = "sha256:c36f9b6f5f8649bb251a5f3f66564438977b7ef8386a52460ae77e6070d309d9"}, - {file = "regex-2024.11.6-cp311-cp311-win_amd64.whl", hash = "sha256:02e28184be537f0e75c1f9b2f8847dc51e08e6e171c6bde130b2687e0c33cf60"}, - {file = "regex-2024.11.6-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:52fb28f528778f184f870b7cf8f225f5eef0a8f6e3778529bdd40c7b3920796a"}, - {file = "regex-2024.11.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fdd6028445d2460f33136c55eeb1f601ab06d74cb3347132e1c24250187500d9"}, - {file = "regex-2024.11.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:805e6b60c54bf766b251e94526ebad60b7de0c70f70a4e6210ee2891acb70bf2"}, - {file = "regex-2024.11.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b85c2530be953a890eaffde05485238f07029600e8f098cdf1848d414a8b45e4"}, - {file = "regex-2024.11.6-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bb26437975da7dc36b7efad18aa9dd4ea569d2357ae6b783bf1118dabd9ea577"}, - {file = "regex-2024.11.6-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:abfa5080c374a76a251ba60683242bc17eeb2c9818d0d30117b4486be10c59d3"}, - {file = "regex-2024.11.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b7fa6606c2881c1db9479b0eaa11ed5dfa11c8d60a474ff0e095099f39d98e"}, - {file = "regex-2024.11.6-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0c32f75920cf99fe6b6c539c399a4a128452eaf1af27f39bce8909c9a3fd8cbe"}, - {file = "regex-2024.11.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:982e6d21414e78e1f51cf595d7f321dcd14de1f2881c5dc6a6e23bbbbd68435e"}, - {file = "regex-2024.11.6-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a7c2155f790e2fb448faed6dd241386719802296ec588a8b9051c1f5c481bc29"}, - {file = "regex-2024.11.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:149f5008d286636e48cd0b1dd65018548944e495b0265b45e1bffecce1ef7f39"}, - {file = "regex-2024.11.6-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:e5364a4502efca094731680e80009632ad6624084aff9a23ce8c8c6820de3e51"}, - {file = "regex-2024.11.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0a86e7eeca091c09e021db8eb72d54751e527fa47b8d5787caf96d9831bd02ad"}, - {file = "regex-2024.11.6-cp312-cp312-win32.whl", hash = "sha256:32f9a4c643baad4efa81d549c2aadefaeba12249b2adc5af541759237eee1c54"}, - {file = "regex-2024.11.6-cp312-cp312-win_amd64.whl", hash = "sha256:a93c194e2df18f7d264092dc8539b8ffb86b45b899ab976aa15d48214138e81b"}, - {file = "regex-2024.11.6-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a6ba92c0bcdf96cbf43a12c717eae4bc98325ca3730f6b130ffa2e3c3c723d84"}, - {file = "regex-2024.11.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:525eab0b789891ac3be914d36893bdf972d483fe66551f79d3e27146191a37d4"}, - {file = "regex-2024.11.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:086a27a0b4ca227941700e0b31425e7a28ef1ae8e5e05a33826e17e47fbfdba0"}, - {file = "regex-2024.11.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bde01f35767c4a7899b7eb6e823b125a64de314a8ee9791367c9a34d56af18d0"}, - {file = "regex-2024.11.6-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b583904576650166b3d920d2bcce13971f6f9e9a396c673187f49811b2769dc7"}, - {file = "regex-2024.11.6-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1c4de13f06a0d54fa0d5ab1b7138bfa0d883220965a29616e3ea61b35d5f5fc7"}, - {file = "regex-2024.11.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3cde6e9f2580eb1665965ce9bf17ff4952f34f5b126beb509fee8f4e994f143c"}, - {file = "regex-2024.11.6-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0d7f453dca13f40a02b79636a339c5b62b670141e63efd511d3f8f73fba162b3"}, - {file = "regex-2024.11.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:59dfe1ed21aea057a65c6b586afd2a945de04fc7db3de0a6e3ed5397ad491b07"}, - {file = "regex-2024.11.6-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b97c1e0bd37c5cd7902e65f410779d39eeda155800b65fc4d04cc432efa9bc6e"}, - {file = "regex-2024.11.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f9d1e379028e0fc2ae3654bac3cbbef81bf3fd571272a42d56c24007979bafb6"}, - {file = "regex-2024.11.6-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:13291b39131e2d002a7940fb176e120bec5145f3aeb7621be6534e46251912c4"}, - {file = "regex-2024.11.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f51f88c126370dcec4908576c5a627220da6c09d0bff31cfa89f2523843316d"}, - {file = "regex-2024.11.6-cp313-cp313-win32.whl", hash = "sha256:63b13cfd72e9601125027202cad74995ab26921d8cd935c25f09c630436348ff"}, - {file = "regex-2024.11.6-cp313-cp313-win_amd64.whl", hash = "sha256:2b3361af3198667e99927da8b84c1b010752fa4b1115ee30beaa332cabc3ef1a"}, - {file = "regex-2024.11.6-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:3a51ccc315653ba012774efca4f23d1d2a8a8f278a6072e29c7147eee7da446b"}, - {file = "regex-2024.11.6-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:ad182d02e40de7459b73155deb8996bbd8e96852267879396fb274e8700190e3"}, - {file = "regex-2024.11.6-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:ba9b72e5643641b7d41fa1f6d5abda2c9a263ae835b917348fc3c928182ad467"}, - {file = "regex-2024.11.6-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40291b1b89ca6ad8d3f2b82782cc33807f1406cf68c8d440861da6304d8ffbbd"}, - {file = "regex-2024.11.6-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cdf58d0e516ee426a48f7b2c03a332a4114420716d55769ff7108c37a09951bf"}, - {file = "regex-2024.11.6-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a36fdf2af13c2b14738f6e973aba563623cb77d753bbbd8d414d18bfaa3105dd"}, - {file = "regex-2024.11.6-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d1cee317bfc014c2419a76bcc87f071405e3966da434e03e13beb45f8aced1a6"}, - {file = "regex-2024.11.6-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:50153825ee016b91549962f970d6a4442fa106832e14c918acd1c8e479916c4f"}, - {file = "regex-2024.11.6-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:ea1bfda2f7162605f6e8178223576856b3d791109f15ea99a9f95c16a7636fb5"}, - {file = "regex-2024.11.6-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:df951c5f4a1b1910f1a99ff42c473ff60f8225baa1cdd3539fe2819d9543e9df"}, - {file = "regex-2024.11.6-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:072623554418a9911446278f16ecb398fb3b540147a7828c06e2011fa531e773"}, - {file = "regex-2024.11.6-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:f654882311409afb1d780b940234208a252322c24a93b442ca714d119e68086c"}, - {file = "regex-2024.11.6-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:89d75e7293d2b3e674db7d4d9b1bee7f8f3d1609428e293771d1a962617150cc"}, - {file = "regex-2024.11.6-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:f65557897fc977a44ab205ea871b690adaef6b9da6afda4790a2484b04293a5f"}, - {file = "regex-2024.11.6-cp38-cp38-win32.whl", hash = "sha256:6f44ec28b1f858c98d3036ad5d7d0bfc568bdd7a74f9c24e25f41ef1ebfd81a4"}, - {file = "regex-2024.11.6-cp38-cp38-win_amd64.whl", hash = "sha256:bb8f74f2f10dbf13a0be8de623ba4f9491faf58c24064f32b65679b021ed0001"}, - {file = "regex-2024.11.6-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:5704e174f8ccab2026bd2f1ab6c510345ae8eac818b613d7d73e785f1310f839"}, - {file = "regex-2024.11.6-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:220902c3c5cc6af55d4fe19ead504de80eb91f786dc102fbd74894b1551f095e"}, - {file = "regex-2024.11.6-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:5e7e351589da0850c125f1600a4c4ba3c722efefe16b297de54300f08d734fbf"}, - {file = "regex-2024.11.6-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5056b185ca113c88e18223183aa1a50e66507769c9640a6ff75859619d73957b"}, - {file = "regex-2024.11.6-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2e34b51b650b23ed3354b5a07aab37034d9f923db2a40519139af34f485f77d0"}, - {file = "regex-2024.11.6-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5670bce7b200273eee1840ef307bfa07cda90b38ae56e9a6ebcc9f50da9c469b"}, - {file = "regex-2024.11.6-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:08986dce1339bc932923e7d1232ce9881499a0e02925f7402fb7c982515419ef"}, - {file = "regex-2024.11.6-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:93c0b12d3d3bc25af4ebbf38f9ee780a487e8bf6954c115b9f015822d3bb8e48"}, - {file = "regex-2024.11.6-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:764e71f22ab3b305e7f4c21f1a97e1526a25ebdd22513e251cf376760213da13"}, - {file = "regex-2024.11.6-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:f056bf21105c2515c32372bbc057f43eb02aae2fda61052e2f7622c801f0b4e2"}, - {file = "regex-2024.11.6-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:69ab78f848845569401469da20df3e081e6b5a11cb086de3eed1d48f5ed57c95"}, - {file = "regex-2024.11.6-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:86fddba590aad9208e2fa8b43b4c098bb0ec74f15718bb6a704e3c63e2cef3e9"}, - {file = "regex-2024.11.6-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:684d7a212682996d21ca12ef3c17353c021fe9de6049e19ac8481ec35574a70f"}, - {file = "regex-2024.11.6-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a03e02f48cd1abbd9f3b7e3586d97c8f7a9721c436f51a5245b3b9483044480b"}, - {file = "regex-2024.11.6-cp39-cp39-win32.whl", hash = "sha256:41758407fc32d5c3c5de163888068cfee69cb4c2be844e7ac517a52770f9af57"}, - {file = "regex-2024.11.6-cp39-cp39-win_amd64.whl", hash = "sha256:b2837718570f95dd41675328e111345f9b7095d821bac435aac173ac80b19983"}, - {file = "regex-2024.11.6.tar.gz", hash = "sha256:7ab159b063c52a0333c884e4679f8d7a85112ee3078fe3d9004b2dd875585519"}, -] - -[[package]] -name = "requests" -version = "2.32.4" -description = "Python HTTP for Humans." -optional = false -python-versions = ">=3.8" -groups = ["main", "dev"] -files = [ - {file = "requests-2.32.4-py3-none-any.whl", hash = "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c"}, - {file = "requests-2.32.4.tar.gz", hash = "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422"}, -] - -[package.dependencies] -certifi = ">=2017.4.17" -charset_normalizer = ">=2,<4" -idna = ">=2.5,<4" -urllib3 = ">=1.21.1,<3" - -[package.extras] -socks = ["PySocks (>=1.5.6,!=1.5.7)"] -use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] - -[[package]] -name = "requests-mock" -version = "1.12.1" -description = "Mock out responses from the requests package" -optional = false -python-versions = ">=3.5" -groups = ["dev"] -files = [ - {file = "requests-mock-1.12.1.tar.gz", hash = "sha256:e9e12e333b525156e82a3c852f22016b9158220d2f47454de9cae8a77d371401"}, - {file = "requests_mock-1.12.1-py2.py3-none-any.whl", hash = "sha256:b1e37054004cdd5e56c84454cc7df12b25f90f382159087f4b6915aaeef39563"}, -] - -[package.dependencies] -requests = ">=2.22,<3" - -[package.extras] -fixture = ["fixtures"] - -[[package]] -name = "respx" -version = "0.22.0" -description = "A utility for mocking out the Python HTTPX and HTTP Core libraries." -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "respx-0.22.0-py2.py3-none-any.whl", hash = "sha256:631128d4c9aba15e56903fb5f66fb1eff412ce28dd387ca3a81339e52dbd3ad0"}, - {file = "respx-0.22.0.tar.gz", hash = "sha256:3c8924caa2a50bd71aefc07aa812f2466ff489f1848c96e954a5362d17095d91"}, -] - -[package.dependencies] -httpx = ">=0.25.0" - -[[package]] -name = "rich" -version = "14.1.0" -description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" -optional = false -python-versions = ">=3.8.0" -groups = ["dev"] -files = [ - {file = "rich-14.1.0-py3-none-any.whl", hash = "sha256:536f5f1785986d6dbdea3c75205c473f970777b4a0d6c6dd1b696aa05a3fa04f"}, - {file = "rich-14.1.0.tar.gz", hash = "sha256:e497a48b844b0320d45007cdebfeaeed8db2a4f4bcf49f15e455cfc4af11eaa8"}, -] - -[package.dependencies] -markdown-it-py = ">=2.2.0" -pygments = ">=2.13.0,<3.0.0" - -[package.extras] -jupyter = ["ipywidgets (>=7.5.1,<9)"] - -[[package]] -name = "ruff" -version = "0.13.1" -description = "An extremely fast Python linter and code formatter, written in Rust." -optional = false -python-versions = ">=3.7" -groups = ["dev"] -files = [ - {file = "ruff-0.13.1-py3-none-linux_armv6l.whl", hash = "sha256:b2abff595cc3cbfa55e509d89439b5a09a6ee3c252d92020bd2de240836cf45b"}, - {file = "ruff-0.13.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:4ee9f4249bf7f8bb3984c41bfaf6a658162cdb1b22e3103eabc7dd1dc5579334"}, - {file = "ruff-0.13.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:5c5da4af5f6418c07d75e6f3224e08147441f5d1eac2e6ce10dcce5e616a3bae"}, - {file = "ruff-0.13.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:80524f84a01355a59a93cef98d804e2137639823bcee2931f5028e71134a954e"}, - {file = "ruff-0.13.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ff7f5ce8d7988767dd46a148192a14d0f48d1baea733f055d9064875c7d50389"}, - {file = "ruff-0.13.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c55d84715061f8b05469cdc9a446aa6c7294cd4bd55e86a89e572dba14374f8c"}, - {file = "ruff-0.13.1-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:ac57fed932d90fa1624c946dc67a0a3388d65a7edc7d2d8e4ca7bddaa789b3b0"}, - {file = "ruff-0.13.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c366a71d5b4f41f86a008694f7a0d75fe409ec298685ff72dc882f882d532e36"}, - {file = "ruff-0.13.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f4ea9d1b5ad3e7a83ee8ebb1229c33e5fe771e833d6d3dcfca7b77d95b060d38"}, - {file = "ruff-0.13.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b0f70202996055b555d3d74b626406476cc692f37b13bac8828acff058c9966a"}, - {file = "ruff-0.13.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:f8cff7a105dad631085d9505b491db33848007d6b487c3c1979dd8d9b2963783"}, - {file = "ruff-0.13.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:9761e84255443316a258dd7dfbd9bfb59c756e52237ed42494917b2577697c6a"}, - {file = "ruff-0.13.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:3d376a88c3102ef228b102211ef4a6d13df330cb0f5ca56fdac04ccec2a99700"}, - {file = "ruff-0.13.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:cbefd60082b517a82c6ec8836989775ac05f8991715d228b3c1d86ccc7df7dae"}, - {file = "ruff-0.13.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:dd16b9a5a499fe73f3c2ef09a7885cb1d97058614d601809d37c422ed1525317"}, - {file = "ruff-0.13.1-py3-none-win32.whl", hash = "sha256:55e9efa692d7cb18580279f1fbb525146adc401f40735edf0aaeabd93099f9a0"}, - {file = "ruff-0.13.1-py3-none-win_amd64.whl", hash = "sha256:3a3fb595287ee556de947183489f636b9f76a72f0fa9c028bdcabf5bab2cc5e5"}, - {file = "ruff-0.13.1-py3-none-win_arm64.whl", hash = "sha256:c0bae9ffd92d54e03c2bf266f466da0a65e145f298ee5b5846ed435f6a00518a"}, - {file = "ruff-0.13.1.tar.gz", hash = "sha256:88074c3849087f153d4bb22e92243ad4c1b366d7055f98726bc19aa08dc12d51"}, -] - -[[package]] -name = "s3transfer" -version = "0.14.0" -description = "An Amazon S3 Transfer Manager" -optional = false -python-versions = ">=3.9" -groups = ["main", "dev"] -files = [ - {file = "s3transfer-0.14.0-py3-none-any.whl", hash = "sha256:ea3b790c7077558ed1f02a3072fb3cb992bbbd253392f4b6e9e8976941c7d456"}, - {file = "s3transfer-0.14.0.tar.gz", hash = "sha256:eff12264e7c8b4985074ccce27a3b38a485bb7f7422cc8046fee9be4983e4125"}, -] - -[package.dependencies] -botocore = ">=1.37.4,<2.0a.0" - -[package.extras] -crt = ["botocore[crt] (>=1.37.4,<2.0a.0)"] - -[[package]] -name = "securetar" -version = "2025.2.1" -description = "Python module to handle tarfile backups." -optional = false -python-versions = ">=3.10.0" -groups = ["main", "dev"] -files = [ - {file = "securetar-2025.2.1-py3-none-any.whl", hash = "sha256:760ad9d93579d5923f3d0da86e0f185d0f844cf01795a8754539827bb6a1bab4"}, - {file = "securetar-2025.2.1.tar.gz", hash = "sha256:59536a73fe5cecbc1f00b1838c8b1052464a024e2adcf6c9ce1d200d91990fb1"}, -] - -[package.dependencies] -cryptography = "*" - -[[package]] -name = "sentence-stream" -version = "1.2.0" -description = "A small sentence splitter for text streams" -optional = false -python-versions = ">=3.9.0" -groups = ["main", "dev"] -files = [ - {file = "sentence_stream-1.2.0-py3-none-any.whl", hash = "sha256:01874a7e70efc578f891bafd3bbfa84c074fcbbfe29e1f940df969ce59e160a3"}, - {file = "sentence_stream-1.2.0.tar.gz", hash = "sha256:92c7b6aa515d1d2a44693b719c77e3144dd6bbccd405261eee7a065d01191f71"}, -] - -[package.dependencies] -regex = "2024.11.6" - -[package.extras] -dev = ["black (==24.8.0)", "build (==1.2.2)", "flake8 (==7.2.0)", "mypy (==1.14.0)", "pylint (==3.2.7)", "pytest (==8.3.5)", "pytest-asyncio (==1.1.0)", "tox (==4.26.0)"] - -[[package]] -name = "six" -version = "1.17.0" -description = "Python 2 and 3 compatibility utilities" -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" -groups = ["main", "dev"] -files = [ - {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, - {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, -] - -[[package]] -name = "sniffio" -version = "1.3.1" -description = "Sniff out which async library your code is running under" -optional = false -python-versions = ">=3.7" -groups = ["main", "dev"] -files = [ - {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, - {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, -] - -[[package]] -name = "snitun" -version = "0.44.0" -description = "SNI proxy with TCP multiplexer" -optional = false -python-versions = ">=3.12" -groups = ["main", "dev"] -files = [ - {file = "snitun-0.44.0-py3-none-any.whl", hash = "sha256:8c351ed936c9768d68b1dc5a33ad91c1b8d57cad09f29e73e0b19df0e573c08b"}, - {file = "snitun-0.44.0.tar.gz", hash = "sha256:b9f693568ea6a7da6a9fa459597a404c1657bfb9259eb076005a8eb1247df087"}, -] - -[package.dependencies] -aiohttp = ">=3.9.3" -cryptography = ">=2.5" - -[package.extras] -lint = ["ruff (==0.12.4)"] -test = ["covdefaults (==2.3.0)", "pytest (==8.4.1)", "pytest-aiohttp (==1.1.0)", "pytest-codspeed (==3.2.0)", "pytest-cov (==6.2.1)", "pytest-timeout (==2.4.0)"] - -[[package]] -name = "span-panel-api" -version = "1.1.13" -description = "A client library for SPAN Panel API" -optional = false -python-versions = ">=3.10,<4.0" -groups = ["main"] -files = [] -develop = true - -[package.dependencies] -attrs = ">=22.2.0" -click = ">=8.0.0" -httpx = ">=0.20.0,<0.29.0" -numpy = ">=1.21.0" -python-dateutil = ">=2.8.0" -pyyaml = ">=6.0.0" - -[package.source] -type = "directory" -url = "../span-panel-api" - -[[package]] -name = "sqlalchemy" -version = "2.0.41" -description = "Database Abstraction Library" -optional = false -python-versions = ">=3.7" -groups = ["main", "dev"] -files = [ - {file = "SQLAlchemy-2.0.41-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:6854175807af57bdb6425e47adbce7d20a4d79bbfd6f6d6519cd10bb7109a7f8"}, - {file = "SQLAlchemy-2.0.41-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:05132c906066142103b83d9c250b60508af556982a385d96c4eaa9fb9720ac2b"}, - {file = "SQLAlchemy-2.0.41-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8b4af17bda11e907c51d10686eda89049f9ce5669b08fbe71a29747f1e876036"}, - {file = "SQLAlchemy-2.0.41-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:c0b0e5e1b5d9f3586601048dd68f392dc0cc99a59bb5faf18aab057ce00d00b2"}, - {file = "SQLAlchemy-2.0.41-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:0b3dbf1e7e9bc95f4bac5e2fb6d3fb2f083254c3fdd20a1789af965caf2d2348"}, - {file = "SQLAlchemy-2.0.41-cp37-cp37m-win32.whl", hash = "sha256:1e3f196a0c59b0cae9a0cd332eb1a4bda4696e863f4f1cf84ab0347992c548c2"}, - {file = "SQLAlchemy-2.0.41-cp37-cp37m-win_amd64.whl", hash = "sha256:6ab60a5089a8f02009f127806f777fca82581c49e127f08413a66056bd9166dd"}, - {file = "sqlalchemy-2.0.41-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b1f09b6821406ea1f94053f346f28f8215e293344209129a9c0fcc3578598d7b"}, - {file = "sqlalchemy-2.0.41-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1936af879e3db023601196a1684d28e12f19ccf93af01bf3280a3262c4b6b4e5"}, - {file = "sqlalchemy-2.0.41-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2ac41acfc8d965fb0c464eb8f44995770239668956dc4cdf502d1b1ffe0d747"}, - {file = "sqlalchemy-2.0.41-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:81c24e0c0fde47a9723c81d5806569cddef103aebbf79dbc9fcbb617153dea30"}, - {file = "sqlalchemy-2.0.41-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:23a8825495d8b195c4aa9ff1c430c28f2c821e8c5e2d98089228af887e5d7e29"}, - {file = "sqlalchemy-2.0.41-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:60c578c45c949f909a4026b7807044e7e564adf793537fc762b2489d522f3d11"}, - {file = "sqlalchemy-2.0.41-cp310-cp310-win32.whl", hash = "sha256:118c16cd3f1b00c76d69343e38602006c9cfb9998fa4f798606d28d63f23beda"}, - {file = "sqlalchemy-2.0.41-cp310-cp310-win_amd64.whl", hash = "sha256:7492967c3386df69f80cf67efd665c0f667cee67032090fe01d7d74b0e19bb08"}, - {file = "sqlalchemy-2.0.41-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6375cd674fe82d7aa9816d1cb96ec592bac1726c11e0cafbf40eeee9a4516b5f"}, - {file = "sqlalchemy-2.0.41-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9f8c9fdd15a55d9465e590a402f42082705d66b05afc3ffd2d2eb3c6ba919560"}, - {file = "sqlalchemy-2.0.41-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32f9dc8c44acdee06c8fc6440db9eae8b4af8b01e4b1aee7bdd7241c22edff4f"}, - {file = "sqlalchemy-2.0.41-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90c11ceb9a1f482c752a71f203a81858625d8df5746d787a4786bca4ffdf71c6"}, - {file = "sqlalchemy-2.0.41-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:911cc493ebd60de5f285bcae0491a60b4f2a9f0f5c270edd1c4dbaef7a38fc04"}, - {file = "sqlalchemy-2.0.41-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:03968a349db483936c249f4d9cd14ff2c296adfa1290b660ba6516f973139582"}, - {file = "sqlalchemy-2.0.41-cp311-cp311-win32.whl", hash = "sha256:293cd444d82b18da48c9f71cd7005844dbbd06ca19be1ccf6779154439eec0b8"}, - {file = "sqlalchemy-2.0.41-cp311-cp311-win_amd64.whl", hash = "sha256:3d3549fc3e40667ec7199033a4e40a2f669898a00a7b18a931d3efb4c7900504"}, - {file = "sqlalchemy-2.0.41-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:81f413674d85cfd0dfcd6512e10e0f33c19c21860342a4890c3a2b59479929f9"}, - {file = "sqlalchemy-2.0.41-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:598d9ebc1e796431bbd068e41e4de4dc34312b7aa3292571bb3674a0cb415dd1"}, - {file = "sqlalchemy-2.0.41-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a104c5694dfd2d864a6f91b0956eb5d5883234119cb40010115fd45a16da5e70"}, - {file = "sqlalchemy-2.0.41-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6145afea51ff0af7f2564a05fa95eb46f542919e6523729663a5d285ecb3cf5e"}, - {file = "sqlalchemy-2.0.41-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b46fa6eae1cd1c20e6e6f44e19984d438b6b2d8616d21d783d150df714f44078"}, - {file = "sqlalchemy-2.0.41-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41836fe661cc98abfae476e14ba1906220f92c4e528771a8a3ae6a151242d2ae"}, - {file = "sqlalchemy-2.0.41-cp312-cp312-win32.whl", hash = "sha256:a8808d5cf866c781150d36a3c8eb3adccfa41a8105d031bf27e92c251e3969d6"}, - {file = "sqlalchemy-2.0.41-cp312-cp312-win_amd64.whl", hash = "sha256:5b14e97886199c1f52c14629c11d90c11fbb09e9334fa7bb5f6d068d9ced0ce0"}, - {file = "sqlalchemy-2.0.41-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4eeb195cdedaf17aab6b247894ff2734dcead6c08f748e617bfe05bd5a218443"}, - {file = "sqlalchemy-2.0.41-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d4ae769b9c1c7757e4ccce94b0641bc203bbdf43ba7a2413ab2523d8d047d8dc"}, - {file = "sqlalchemy-2.0.41-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a62448526dd9ed3e3beedc93df9bb6b55a436ed1474db31a2af13b313a70a7e1"}, - {file = "sqlalchemy-2.0.41-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc56c9788617b8964ad02e8fcfeed4001c1f8ba91a9e1f31483c0dffb207002a"}, - {file = "sqlalchemy-2.0.41-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c153265408d18de4cc5ded1941dcd8315894572cddd3c58df5d5b5705b3fa28d"}, - {file = "sqlalchemy-2.0.41-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f67766965996e63bb46cfbf2ce5355fc32d9dd3b8ad7e536a920ff9ee422e23"}, - {file = "sqlalchemy-2.0.41-cp313-cp313-win32.whl", hash = "sha256:bfc9064f6658a3d1cadeaa0ba07570b83ce6801a1314985bf98ec9b95d74e15f"}, - {file = "sqlalchemy-2.0.41-cp313-cp313-win_amd64.whl", hash = "sha256:82ca366a844eb551daff9d2e6e7a9e5e76d2612c8564f58db6c19a726869c1df"}, - {file = "sqlalchemy-2.0.41-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:90144d3b0c8b139408da50196c5cad2a6909b51b23df1f0538411cd23ffa45d3"}, - {file = "sqlalchemy-2.0.41-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:023b3ee6169969beea3bb72312e44d8b7c27c75b347942d943cf49397b7edeb5"}, - {file = "sqlalchemy-2.0.41-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:725875a63abf7c399d4548e686debb65cdc2549e1825437096a0af1f7e374814"}, - {file = "sqlalchemy-2.0.41-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:81965cc20848ab06583506ef54e37cf15c83c7e619df2ad16807c03100745dea"}, - {file = "sqlalchemy-2.0.41-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:dd5ec3aa6ae6e4d5b5de9357d2133c07be1aff6405b136dad753a16afb6717dd"}, - {file = "sqlalchemy-2.0.41-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:ff8e80c4c4932c10493ff97028decfdb622de69cae87e0f127a7ebe32b4069c6"}, - {file = "sqlalchemy-2.0.41-cp38-cp38-win32.whl", hash = "sha256:4d44522480e0bf34c3d63167b8cfa7289c1c54264c2950cc5fc26e7850967e45"}, - {file = "sqlalchemy-2.0.41-cp38-cp38-win_amd64.whl", hash = "sha256:81eedafa609917040d39aa9332e25881a8e7a0862495fcdf2023a9667209deda"}, - {file = "sqlalchemy-2.0.41-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9a420a91913092d1e20c86a2f5f1fc85c1a8924dbcaf5e0586df8aceb09c9cc2"}, - {file = "sqlalchemy-2.0.41-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:906e6b0d7d452e9a98e5ab8507c0da791856b2380fdee61b765632bb8698026f"}, - {file = "sqlalchemy-2.0.41-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a373a400f3e9bac95ba2a06372c4fd1412a7cee53c37fc6c05f829bf672b8769"}, - {file = "sqlalchemy-2.0.41-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:087b6b52de812741c27231b5a3586384d60c353fbd0e2f81405a814b5591dc8b"}, - {file = "sqlalchemy-2.0.41-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:34ea30ab3ec98355235972dadc497bb659cc75f8292b760394824fab9cf39826"}, - {file = "sqlalchemy-2.0.41-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:8280856dd7c6a68ab3a164b4a4b1c51f7691f6d04af4d4ca23d6ecf2261b7923"}, - {file = "sqlalchemy-2.0.41-cp39-cp39-win32.whl", hash = "sha256:b50eab9994d64f4a823ff99a0ed28a6903224ddbe7fef56a6dd865eec9243440"}, - {file = "sqlalchemy-2.0.41-cp39-cp39-win_amd64.whl", hash = "sha256:5e22575d169529ac3e0a120cf050ec9daa94b6a9597993d1702884f6954a7d71"}, - {file = "sqlalchemy-2.0.41-py3-none-any.whl", hash = "sha256:57df5dc6fdb5ed1a88a1ed2195fd31927e705cad62dedd86b46972752a80f576"}, - {file = "sqlalchemy-2.0.41.tar.gz", hash = "sha256:edba70118c4be3c2b1f90754d308d0b79c6fe2c0fdc52d8ddf603916f83f4db9"}, -] - -[package.dependencies] -greenlet = {version = ">=1", markers = "python_version < \"3.14\" and (platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\")"} -typing-extensions = ">=4.6.0" - -[package.extras] -aiomysql = ["aiomysql (>=0.2.0)", "greenlet (>=1)"] -aioodbc = ["aioodbc", "greenlet (>=1)"] -aiosqlite = ["aiosqlite", "greenlet (>=1)", "typing_extensions (!=3.10.0.1)"] -asyncio = ["greenlet (>=1)"] -asyncmy = ["asyncmy (>=0.2.3,!=0.2.4,!=0.2.6)", "greenlet (>=1)"] -mariadb-connector = ["mariadb (>=1.0.1,!=1.1.2,!=1.1.5,!=1.1.10)"] -mssql = ["pyodbc"] -mssql-pymssql = ["pymssql"] -mssql-pyodbc = ["pyodbc"] -mypy = ["mypy (>=0.910)"] -mysql = ["mysqlclient (>=1.4.0)"] -mysql-connector = ["mysql-connector-python"] -oracle = ["cx_oracle (>=8)"] -oracle-oracledb = ["oracledb (>=1.0.1)"] -postgresql = ["psycopg2 (>=2.7)"] -postgresql-asyncpg = ["asyncpg", "greenlet (>=1)"] -postgresql-pg8000 = ["pg8000 (>=1.29.1)"] -postgresql-psycopg = ["psycopg (>=3.0.7)"] -postgresql-psycopg2binary = ["psycopg2-binary"] -postgresql-psycopg2cffi = ["psycopg2cffi"] -postgresql-psycopgbinary = ["psycopg[binary] (>=3.0.7)"] -pymysql = ["pymysql"] -sqlcipher = ["sqlcipher3_binary"] - -[[package]] -name = "standard-aifc" -version = "3.13.0" -description = "Standard library aifc redistribution. \"dead battery\"." -optional = false -python-versions = "*" -groups = ["main", "dev"] -files = [ - {file = "standard_aifc-3.13.0-py3-none-any.whl", hash = "sha256:f7ae09cc57de1224a0dd8e3eb8f73830be7c3d0bc485de4c1f82b4a7f645ac66"}, - {file = "standard_aifc-3.13.0.tar.gz", hash = "sha256:64e249c7cb4b3daf2fdba4e95721f811bde8bdfc43ad9f936589b7bb2fae2e43"}, -] - -[package.dependencies] -audioop-lts = {version = "*", markers = "python_version >= \"3.13\""} -standard-chunk = {version = "*", markers = "python_version >= \"3.13\""} - -[[package]] -name = "standard-chunk" -version = "3.13.0" -description = "Standard library chunk redistribution. \"dead battery\"." -optional = false -python-versions = "*" -groups = ["main", "dev"] -files = [ - {file = "standard_chunk-3.13.0-py3-none-any.whl", hash = "sha256:17880a26c285189c644bd5bd8f8ed2bdb795d216e3293e6dbe55bbd848e2982c"}, - {file = "standard_chunk-3.13.0.tar.gz", hash = "sha256:4ac345d37d7e686d2755e01836b8d98eda0d1a3ee90375e597ae43aaf064d654"}, -] - -[[package]] -name = "standard-telnetlib" -version = "3.13.0" -description = "Standard library telnetlib redistribution. \"dead battery\"." -optional = false -python-versions = "*" -groups = ["main", "dev"] -files = [ - {file = "standard_telnetlib-3.13.0-py3-none-any.whl", hash = "sha256:b268060a3220c80c7887f2ad9df91cd81e865f0c5052332b81d80ffda8677691"}, - {file = "standard_telnetlib-3.13.0.tar.gz", hash = "sha256:243333696bf1659a558eb999c23add82c41ffc2f2d04a56fae13b61b536fb173"}, -] - -[[package]] -name = "stevedore" -version = "5.5.0" -description = "Manage dynamic plugins for Python applications" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "stevedore-5.5.0-py3-none-any.whl", hash = "sha256:18363d4d268181e8e8452e71a38cd77630f345b2ef6b4a8d5614dac5ee0d18cf"}, - {file = "stevedore-5.5.0.tar.gz", hash = "sha256:d31496a4f4df9825e1a1e4f1f74d19abb0154aff311c3b376fcc89dae8fccd73"}, -] - -[[package]] -name = "syrupy" -version = "4.9.1" -description = "Pytest Snapshot Test Utility" -optional = false -python-versions = ">=3.8.1" -groups = ["dev"] -files = [ - {file = "syrupy-4.9.1-py3-none-any.whl", hash = "sha256:b94cc12ed0e5e75b448255430af642516842a2374a46936dd2650cfb6dd20eda"}, - {file = "syrupy-4.9.1.tar.gz", hash = "sha256:b7d0fcadad80a7d2f6c4c71917918e8ebe2483e8c703dfc8d49cdbb01081f9a4"}, -] - -[package.dependencies] -pytest = ">=7.0.0,<9.0.0" - -[[package]] -name = "termcolor" -version = "3.1.0" -description = "ANSI color formatting for output in terminal" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "termcolor-3.1.0-py3-none-any.whl", hash = "sha256:591dd26b5c2ce03b9e43f391264626557873ce1d379019786f99b0c2bee140aa"}, - {file = "termcolor-3.1.0.tar.gz", hash = "sha256:6a6dd7fbee581909eeec6a756cff1d7f7c376063b14e4a298dc4980309e55970"}, -] - -[package.extras] -tests = ["pytest", "pytest-cov"] - -[[package]] -name = "text-unidecode" -version = "1.3" -description = "The most basic Text::Unidecode port" -optional = false -python-versions = "*" -groups = ["main", "dev"] -files = [ - {file = "text-unidecode-1.3.tar.gz", hash = "sha256:bad6603bb14d279193107714b288be206cac565dfa49aa5b105294dd5c4aab93"}, - {file = "text_unidecode-1.3-py2.py3-none-any.whl", hash = "sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8"}, -] - -[[package]] -name = "tomlkit" -version = "0.13.3" -description = "Style preserving TOML library" -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "tomlkit-0.13.3-py3-none-any.whl", hash = "sha256:c89c649d79ee40629a9fda55f8ace8c6a1b42deb912b2a8fd8d942ddadb606b0"}, - {file = "tomlkit-0.13.3.tar.gz", hash = "sha256:430cf247ee57df2b94ee3fbe588e71d362a941ebb545dec29b53961d61add2a1"}, -] - -[[package]] -name = "tqdm" -version = "4.67.1" -description = "Fast, Extensible Progress Meter" -optional = false -python-versions = ">=3.7" -groups = ["dev"] -files = [ - {file = "tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2"}, - {file = "tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2"}, -] - -[package.dependencies] -colorama = {version = "*", markers = "platform_system == \"Windows\""} - -[package.extras] -dev = ["nbval", "pytest (>=6)", "pytest-asyncio (>=0.24)", "pytest-cov", "pytest-timeout"] -discord = ["requests"] -notebook = ["ipywidgets (>=6)"] -slack = ["slack-sdk"] -telegram = ["requests"] - -[[package]] -name = "types-pyyaml" -version = "6.0.12.20250915" -description = "Typing stubs for PyYAML" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "types_pyyaml-6.0.12.20250915-py3-none-any.whl", hash = "sha256:e7d4d9e064e89a3b3cae120b4990cd370874d2bf12fa5f46c97018dd5d3c9ab6"}, - {file = "types_pyyaml-6.0.12.20250915.tar.gz", hash = "sha256:0f8b54a528c303f0e6f7165687dd33fafa81c807fcac23f632b63aa624ced1d3"}, -] - -[[package]] -name = "types-requests" -version = "2.32.4.20250913" -description = "Typing stubs for requests" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "types_requests-2.32.4.20250913-py3-none-any.whl", hash = "sha256:78c9c1fffebbe0fa487a418e0fa5252017e9c60d1a2da394077f1780f655d7e1"}, - {file = "types_requests-2.32.4.20250913.tar.gz", hash = "sha256:abd6d4f9ce3a9383f269775a9835a4c24e5cd6b9f647d64f88aa4613c33def5d"}, -] - -[package.dependencies] -urllib3 = ">=2" - -[[package]] -name = "typing-extensions" -version = "4.15.0" -description = "Backported and Experimental Type Hints for Python 3.9+" -optional = false -python-versions = ">=3.9" -groups = ["main", "dev"] -files = [ - {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, - {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, -] - -[[package]] -name = "typing-inspection" -version = "0.4.1" -description = "Runtime typing introspection tools" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51"}, - {file = "typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28"}, -] - -[package.dependencies] -typing-extensions = ">=4.12.0" - -[[package]] -name = "tzdata" -version = "2025.2" -description = "Provider of IANA time zone data" -optional = false -python-versions = ">=2" -groups = ["main", "dev"] -files = [ - {file = "tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8"}, - {file = "tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9"}, -] - -[[package]] -name = "uart-devices" -version = "0.1.1" -description = "UART Devices for Linux" -optional = false -python-versions = "<4.0,>=3.9" -groups = ["main", "dev"] -files = [ - {file = "uart_devices-0.1.1-py3-none-any.whl", hash = "sha256:55bc8cce66465e90b298f0910e5c496bc7be021341c5455954cf61c6253dc123"}, - {file = "uart_devices-0.1.1.tar.gz", hash = "sha256:3a52c4ae0f5f7400ebe1ae5f6e2a2d40cc0b7f18a50e895236535c4e53c6ed34"}, -] - -[[package]] -name = "ulid-transform" -version = "1.4.0" -description = "Create and transform ULIDs" -optional = false -python-versions = "<4.0,>=3.11" -groups = ["main", "dev"] -files = [ - {file = "ulid_transform-1.4.0-cp313-cp313-manylinux_2_36_x86_64.whl", hash = "sha256:9b1429ca7403696b290e4e97ffadbf8ed0b7470a97ad7e273372c3deae5bfb2f"}, - {file = "ulid_transform-1.4.0.tar.gz", hash = "sha256:5914a3c4277b0d25ebb67f47bfee2167ac858d970249ea275221fb3e5d91c9a0"}, -] - -[[package]] -name = "urllib3" -version = "2.5.0" -description = "HTTP library with thread-safe connection pooling, file post, and more." -optional = false -python-versions = ">=3.9" -groups = ["main", "dev"] -files = [ - {file = "urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc"}, - {file = "urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760"}, -] - -[package.extras] -brotli = ["brotli (>=1.0.9) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\""] -h2 = ["h2 (>=4,<5)"] -socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] -zstd = ["zstandard (>=0.18.0)"] - -[[package]] -name = "usb-devices" -version = "0.4.5" -description = "Tools for mapping, describing, and resetting USB devices" -optional = false -python-versions = ">=3.9,<4.0" -groups = ["main", "dev"] -files = [ - {file = "usb_devices-0.4.5-py3-none-any.whl", hash = "sha256:8a415219ef1395e25aa0bddcad484c88edf9673acdeae8a07223ca7222a01dcf"}, - {file = "usb_devices-0.4.5.tar.gz", hash = "sha256:9b5c7606df2bc791c6c45b7f76244a0cbed83cb6fa4c68791a143c03345e195d"}, -] - -[[package]] -name = "uv" -version = "0.8.9" -description = "An extremely fast Python package and project manager, written in Rust." -optional = false -python-versions = ">=3.8" -groups = ["main", "dev"] -files = [ - {file = "uv-0.8.9-py3-none-linux_armv6l.whl", hash = "sha256:4633c693c79c57a77c52608cbca8a6bb17801bfa223326fbc5c5142654c23cc3"}, - {file = "uv-0.8.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:1cdc11cbc81824e51ebb1bac35745a79048557e869ef9da458e99f1c3a96c7f9"}, - {file = "uv-0.8.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7b20ee83e3bf294e0b1347d0b27c56ea1a4fa7eeff4361fbf1f39587d4273059"}, - {file = "uv-0.8.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:3418315e624f60a1c4ed37987b35d5ff0d03961d380e7e7946a3378499d5d779"}, - {file = "uv-0.8.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7efe01b3ed9816e07e6cd4e088472a558a1d2946177f31002b4c42cd55cb4604"}, - {file = "uv-0.8.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e571132495d7ab24d2f0270c559d6facd4224745d9db7dff8c20ec0c71ae105a"}, - {file = "uv-0.8.9-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:67507c66837d8465daaad9f2ccd7da7af981d8c94eb8e32798f62a98c28de82d"}, - {file = "uv-0.8.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a3162f495805a26fba5aacbee49c8650e1e74313c7a2e6df6aec5de9d1299087"}, - {file = "uv-0.8.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:60eb70afeb1c66180e12a15afd706bcc0968dbefccf7ef6e5d27a1aaa765419b"}, - {file = "uv-0.8.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:011d2b2d4781555f7f7d29d2f0d6b2638fc60eeff479406ed570052664589e6a"}, - {file = "uv-0.8.9-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:97621843e087a68c0b4969676367d757e1de43c00a9f554eb7da35641bdff8a2"}, - {file = "uv-0.8.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:b1be6a7b49d23b75d598691cc5c065a9e3cdf5e6e75d7b7f42f24d758ceef3c4"}, - {file = "uv-0.8.9-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:91598361309c3601382c552dc22256f70b2491ad03357b66caa4be6fdf1111dd"}, - {file = "uv-0.8.9-py3-none-musllinux_1_1_i686.whl", hash = "sha256:dc81df9dd7571756e34255592caab92821652face35c3f52ad05efaa4bcc39d3"}, - {file = "uv-0.8.9-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:9ef728e0a5caa2bb129c009a68b30819552e7addf934916a466116e302748bed"}, - {file = "uv-0.8.9-py3-none-win32.whl", hash = "sha256:a347c2f2630a45a3b7ceae28a78a528137edfec4847bb29da1561bd8d1f7d254"}, - {file = "uv-0.8.9-py3-none-win_amd64.whl", hash = "sha256:dc12048cdb53210d0c7218bb403ad30118b1fe8eeff3fbcc184c13c26fcc47d4"}, - {file = "uv-0.8.9-py3-none-win_arm64.whl", hash = "sha256:53332de28e9ee00effb695a15cdc70b2455d6b5f6b596d556076b5dd1fd3aa26"}, - {file = "uv-0.8.9.tar.gz", hash = "sha256:54d76faf5338d1e5643a32b048c600de0cdaa7084e5909106103df04f3306615"}, -] - -[[package]] -name = "virtualenv" -version = "20.34.0" -description = "Virtual Python Environment builder" -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "virtualenv-20.34.0-py3-none-any.whl", hash = "sha256:341f5afa7eee943e4984a9207c025feedd768baff6753cd660c857ceb3e36026"}, - {file = "virtualenv-20.34.0.tar.gz", hash = "sha256:44815b2c9dee7ed86e387b842a84f20b93f7f417f95886ca1996a72a4138eb1a"}, -] - -[package.dependencies] -distlib = ">=0.3.7,<1" -filelock = ">=3.12.2,<4" -platformdirs = ">=3.9.1,<5" - -[package.extras] -docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] -test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8) ; platform_python_implementation == \"PyPy\" or platform_python_implementation == \"GraalVM\" or platform_python_implementation == \"CPython\" and sys_platform == \"win32\" and python_version >= \"3.13\"", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10) ; platform_python_implementation == \"CPython\""] - -[[package]] -name = "voluptuous" -version = "0.15.2" -description = "Python data validation library" -optional = false -python-versions = ">=3.9" -groups = ["main", "dev"] -files = [ - {file = "voluptuous-0.15.2-py3-none-any.whl", hash = "sha256:016348bc7788a9af9520b1764ebd4de0df41fe2138ebe9e06fa036bf86a65566"}, - {file = "voluptuous-0.15.2.tar.gz", hash = "sha256:6ffcab32c4d3230b4d2af3a577c87e1908a714a11f6f95570456b1849b0279aa"}, -] - -[[package]] -name = "voluptuous-openapi" -version = "0.1.0" -description = "Convert voluptuous schemas to OpenAPI Schema object" -optional = false -python-versions = ">=3.9" -groups = ["main", "dev"] -files = [ - {file = "voluptuous_openapi-0.1.0-py3-none-any.whl", hash = "sha256:c3aac740286d368c90a99e007d55ddca7fcddf790d218c60ee0eeec2fcd3db2b"}, - {file = "voluptuous_openapi-0.1.0.tar.gz", hash = "sha256:84bc44107c472ba8782f7a4cb342d19d155d5fe7f92367f092cd96cc850ff1b7"}, -] - -[package.dependencies] -voluptuous = "*" - -[[package]] -name = "voluptuous-serialize" -version = "2.7.0" -description = "Convert voluptuous schemas to dictionaries" -optional = false -python-versions = ">=3.9" -groups = ["main", "dev"] -files = [ - {file = "voluptuous_serialize-2.7.0-py3-none-any.whl", hash = "sha256:ee3ebecace6136f38d0bf8c20ee97155db2486c6b2d0795563fafd04a519e76f"}, - {file = "voluptuous_serialize-2.7.0.tar.gz", hash = "sha256:d0da959f2fd93c8f1eb779c5d116231940493b51020c2c1026bab76eb56cd09e"}, -] - -[package.dependencies] -voluptuous = "*" - -[[package]] -name = "voluptuous-stubs" -version = "0.1.1" -description = "voluptuous stubs" -optional = false -python-versions = "*" -groups = ["dev"] -files = [ - {file = "voluptuous-stubs-0.1.1.tar.gz", hash = "sha256:70fb1c088242f20e11023252b5648cd77f831f692cd910c8f9713cc135cf8cc8"}, - {file = "voluptuous_stubs-0.1.1-py3-none-any.whl", hash = "sha256:f216c427ed7e190b8413e26cf4f67e1bda692ea8225ed0d875f7724d10b7cb10"}, -] - -[package.dependencies] -mypy = ">=0.720" -typing-extensions = ">=3.7.4" - -[[package]] -name = "webrtc-models" -version = "0.3.0" -description = "Python WebRTC models" -optional = false -python-versions = ">=3.12.0" -groups = ["main", "dev"] -files = [ - {file = "webrtc_models-0.3.0-py3-none-any.whl", hash = "sha256:8fddded3ffd7ca837de878033501927580799a2c1b7829f7ae8a0f43b49004ea"}, - {file = "webrtc_models-0.3.0.tar.gz", hash = "sha256:559c743e5cc3bcc8133be1b6fb5e8492a9ddb17151129c21cbb2e3f2a1166526"}, -] - -[package.dependencies] -mashumaro = ">=3.13,<4.0" -orjson = ">=3.10.7" - -[[package]] -name = "winrt-runtime" -version = "3.2.1" -description = "Python projection of Windows Runtime (WinRT) APIs" -optional = false -python-versions = ">=3.9" -groups = ["main", "dev"] -markers = "platform_system == \"Windows\"" -files = [ - {file = "winrt_runtime-3.2.1-cp310-cp310-win32.whl", hash = "sha256:25a2d1e2b45423742319f7e10fa8ca2e7063f01284b6e85e99d805c4b50bbfb3"}, - {file = "winrt_runtime-3.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:dc81d5fb736bf1ddecf743928622253dce4d0aac9a57faad776d7a3834e13257"}, - {file = "winrt_runtime-3.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:363f584b1e9fcb601e3e178636d8877e6f0537ac3c96ce4a96f06066f8ff0eae"}, - {file = "winrt_runtime-3.2.1-cp311-cp311-win32.whl", hash = "sha256:9e9b64f1ba631cc4b9fe60b8ff16fef3f32c7ce2fcc84735a63129ff8b15c022"}, - {file = "winrt_runtime-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:c0a9046ae416808420a358c51705af8ae100acd40bc578be57ddfdd51cbb0f9c"}, - {file = "winrt_runtime-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:e94f3cb40ea2d723c44c82c16d715c03c6b3bd977d135b49535fdd5415fd9130"}, - {file = "winrt_runtime-3.2.1-cp312-cp312-win32.whl", hash = "sha256:762b3d972a2f7037f7db3acbaf379dd6d8f6cda505f71f66c6b425d1a1eae2f1"}, - {file = "winrt_runtime-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:06510db215d4f0dc45c00fbb1251c6544e91742a0ad928011db33b30677e1576"}, - {file = "winrt_runtime-3.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:14562c29a087ccad38e379e585fef333e5c94166c807bdde67b508a6261aa195"}, - {file = "winrt_runtime-3.2.1-cp313-cp313-win32.whl", hash = "sha256:44e2733bc709b76c554aee6c7fe079443b8306b2e661e82eecfebe8b9d71e4d1"}, - {file = "winrt_runtime-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:3c1fdcaeedeb2920dc3b9039db64089a6093cad2be56a3e64acc938849245a6d"}, - {file = "winrt_runtime-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:28f3dab083412625ff4d2b46e81246932e6bebddf67bea7f05e01712f54e6159"}, - {file = "winrt_runtime-3.2.1-cp39-cp39-win32.whl", hash = "sha256:07c0cb4a53a4448c2cb7597b62ae8c94343c289eeebd8f83f946eb2c817bde01"}, - {file = "winrt_runtime-3.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:1856325ca3354b45e0789cf279be9a882134085d34214946db76110d98391efa"}, - {file = "winrt_runtime-3.2.1-cp39-cp39-win_arm64.whl", hash = "sha256:cf237858de1d62e4c9b132c66b52028a7a3e8534e8ab90b0e29a68f24f7be39d"}, - {file = "winrt_runtime-3.2.1.tar.gz", hash = "sha256:c8dca19e12b234ae6c3dadf1a4d0761b51e708457492c13beb666556958801ea"}, -] - -[package.dependencies] -typing_extensions = ">=4.12.2" - -[[package]] -name = "winrt-windows-devices-bluetooth" -version = "3.2.1" -description = "Python projection of Windows Runtime (WinRT) APIs" -optional = false -python-versions = ">=3.9" -groups = ["main", "dev"] -markers = "platform_system == \"Windows\"" -files = [ - {file = "winrt_windows_devices_bluetooth-3.2.1-cp310-cp310-win32.whl", hash = "sha256:49489351037094a088a08fbdf0f99c94e3299b574edb211f717c4c727770af78"}, - {file = "winrt_windows_devices_bluetooth-3.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:20f6a21029034c18ea6a6b6df399671813b071102a0d6d8355bb78cf4f547cdb"}, - {file = "winrt_windows_devices_bluetooth-3.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:69c523814eab795bc1bf913292309cb1025ef0a67d5fc33863a98788995e551d"}, - {file = "winrt_windows_devices_bluetooth-3.2.1-cp311-cp311-win32.whl", hash = "sha256:f4082a00b834c1e34b961e0612f3e581356bdb38c5798bd6842f88ec02e5152b"}, - {file = "winrt_windows_devices_bluetooth-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:44277a3f2cc5ac32ce9b4b2d96c5c5f601d394ac5f02cc71bcd551f738660e2d"}, - {file = "winrt_windows_devices_bluetooth-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:0803a417403a7d225316b9b0c4fe3f8446579d6a22f2f729a2c21f4befc74a80"}, - {file = "winrt_windows_devices_bluetooth-3.2.1-cp312-cp312-win32.whl", hash = "sha256:18c833ec49e7076127463679e85efc59f61785ade0dc185c852586b21be1f31c"}, - {file = "winrt_windows_devices_bluetooth-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:9b6702c462b216c91e32388023a74d0f87210cef6fd5d93b7191e9427ce2faca"}, - {file = "winrt_windows_devices_bluetooth-3.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:419fd1078c7749119f6b4bbf6be4e586e03a0ed544c03b83178f1d85f1b3d148"}, - {file = "winrt_windows_devices_bluetooth-3.2.1-cp313-cp313-win32.whl", hash = "sha256:12b0a16fb36ce0b42243ca81f22a6b53fbb344ed7ea07a6eeec294604f0505e4"}, - {file = "winrt_windows_devices_bluetooth-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:6703dfbe444ee22426738830fb305c96a728ea9ccce905acfdf811d81045fdb3"}, - {file = "winrt_windows_devices_bluetooth-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:2cf8a0bfc9103e32dc7237af15f84be06c791f37711984abdca761f6318bbdb2"}, - {file = "winrt_windows_devices_bluetooth-3.2.1-cp39-cp39-win32.whl", hash = "sha256:32fc355bfdc5d6b3b1875df16eaf12f9b9fc0445e01177833c27d9a4fc0d50b6"}, - {file = "winrt_windows_devices_bluetooth-3.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:b886ef1fc0ed49163ae6c2422dd5cb8dd4709da7972af26c8627e211872818d0"}, - {file = "winrt_windows_devices_bluetooth-3.2.1-cp39-cp39-win_arm64.whl", hash = "sha256:8643afa53f9fb8fe3b05967227f86f0c8e1d7b822289e60a848c6368acc977d2"}, - {file = "winrt_windows_devices_bluetooth-3.2.1.tar.gz", hash = "sha256:db496d2d92742006d5a052468fc355bf7bb49e795341d695c374746113d74505"}, -] - -[package.dependencies] -winrt-runtime = ">=3.2.1.0,<3.3.0.0" - -[package.extras] -all = ["winrt-Windows.Devices.Bluetooth.GenericAttributeProfile[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Devices.Bluetooth.Rfcomm[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Devices.Enumeration[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Devices.Radios[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Foundation.Collections[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Foundation[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Networking[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Storage.Streams[all] (>=3.2.1.0,<3.3.0.0)"] - -[[package]] -name = "winrt-windows-devices-bluetooth-advertisement" -version = "3.2.1" -description = "Python projection of Windows Runtime (WinRT) APIs" -optional = false -python-versions = ">=3.9" -groups = ["main", "dev"] -markers = "platform_system == \"Windows\"" -files = [ - {file = "winrt_windows_devices_bluetooth_advertisement-3.2.1-cp310-cp310-win32.whl", hash = "sha256:a758c5f81a98cc38347fdfb024ce62720969480e8c5b98e402b89d2b09b32866"}, - {file = "winrt_windows_devices_bluetooth_advertisement-3.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:f982ef72e729ddd60cdb975293866e84bb838798828933012a57ee4bf12b0ea1"}, - {file = "winrt_windows_devices_bluetooth_advertisement-3.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:e88a72e1e09c7ccc899a9e6d2ab3fc0f43b5dd4509bcc49ec4abf65b55ab015f"}, - {file = "winrt_windows_devices_bluetooth_advertisement-3.2.1-cp311-cp311-win32.whl", hash = "sha256:fe17c2cf63284646622e8b2742b064bf7970bbf53cfab02062136c67fa6b06c9"}, - {file = "winrt_windows_devices_bluetooth_advertisement-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:78e99dd48b4d89b71b7778c5085fdba64e754dd3ebc54fd09c200fe5222c6e09"}, - {file = "winrt_windows_devices_bluetooth_advertisement-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:6d5d2295474deab444fc4311580c725a2ca8a814b0f3344d0779828891d75401"}, - {file = "winrt_windows_devices_bluetooth_advertisement-3.2.1-cp312-cp312-win32.whl", hash = "sha256:901933cc40de5eb7e5f4188897c899dd0b0f577cb2c13eab1a63c7dfe89b08c4"}, - {file = "winrt_windows_devices_bluetooth_advertisement-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:e6c66e7d4f4ca86d2c801d30efd2b9673247b59a2b4c365d9e11650303d68d89"}, - {file = "winrt_windows_devices_bluetooth_advertisement-3.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:447d19defd8982d39944642eb7ebe89e4e20259ec9734116cf88879fb2c514ff"}, - {file = "winrt_windows_devices_bluetooth_advertisement-3.2.1-cp313-cp313-win32.whl", hash = "sha256:4122348ea525a914e85615647a0b54ae8b2f42f92cdbf89c5a12eea53ef6ed90"}, - {file = "winrt_windows_devices_bluetooth_advertisement-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:b66410c04b8dae634a7e4b615c3b7f8adda9c7d4d6902bcad5b253da1a684943"}, - {file = "winrt_windows_devices_bluetooth_advertisement-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:07af19b1d252ddb9dd3eb2965118bc2b7cabff4dda6e499341b765e5038ca61d"}, - {file = "winrt_windows_devices_bluetooth_advertisement-3.2.1-cp39-cp39-win32.whl", hash = "sha256:6c4747d2e5b0e2ef24e9b84a848cf8fc50fb5b268a2086b5ee8680206d1e0197"}, - {file = "winrt_windows_devices_bluetooth_advertisement-3.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:18d4c5d8b80ee2d29cc13c2fc1353fdb3c0f620c8083701c9b9ecf5e6c503c8d"}, - {file = "winrt_windows_devices_bluetooth_advertisement-3.2.1-cp39-cp39-win_arm64.whl", hash = "sha256:75dd856611d847299078d56aee60e319df52975b931c992cd1d32ad5143fe772"}, - {file = "winrt_windows_devices_bluetooth_advertisement-3.2.1.tar.gz", hash = "sha256:0223852a7b7fa5c8dea3c6a93473bd783df4439b1ed938d9871f947933e574cc"}, -] - -[package.dependencies] -winrt-runtime = ">=3.2.1.0,<3.3.0.0" - -[package.extras] -all = ["winrt-Windows.Devices.Bluetooth[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Foundation.Collections[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Foundation[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Storage.Streams[all] (>=3.2.1.0,<3.3.0.0)"] - -[[package]] -name = "winrt-windows-devices-bluetooth-genericattributeprofile" -version = "3.2.1" -description = "Python projection of Windows Runtime (WinRT) APIs" -optional = false -python-versions = ">=3.9" -groups = ["main", "dev"] -markers = "platform_system == \"Windows\"" -files = [ - {file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp310-cp310-win32.whl", hash = "sha256:af4914d7b30b49232092cd3b934e3ed6f5d3b1715ba47238541408ee595b7f46"}, - {file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:0e557dd52fc80392b8bd7c237e1153a50a164b3983838b4ac674551072efc9ed"}, - {file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:64cff62baa6b7aadd6c206e61d149113fdcda17360feb6e9d05bc8bbda4b9fde"}, - {file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp311-cp311-win32.whl", hash = "sha256:832cf65d035a11e6dbfef4fd66abdcc46be7e911ec96e2e72e98e12d8d5b9d3c"}, - {file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:8179638a6c721b0bbf04ba251ef98d5e02d9a17f0cce377398e42c4fbb441415"}, - {file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:70b7edfca3190b89ae38bf60972b11978311b6d933d3142ae45560c955dbf5c7"}, - {file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp312-cp312-win32.whl", hash = "sha256:ef894d21e0a805f3e114940254636a8045335fa9de766c7022af5d127dfad557"}, - {file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:db05de95cd1b24a51abb69cb936a8b17e9214e015757d0b37e3a5e207ddceb3d"}, - {file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:8d4e131cf3d15fc5ad81c1bcde3509ac171298217381abed6bdf687f29871984"}, - {file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp313-cp313-win32.whl", hash = "sha256:b1879c8dcf46bd2110b9ad4b0b185f4e2a5f95170d014539203a5fee2b2115f0"}, - {file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d8d89f01e9b6931fb48217847caac3227a0aeb38a5b7782af71c2e7b262ec30"}, - {file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:4e71207bb89798016b1795bb15daf78afe45529f2939b3b9e78894cfe650b383"}, - {file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp39-cp39-win32.whl", hash = "sha256:963339a0161f9970b577a6193924be783978d11693da48b41a025f61b3c5562a"}, - {file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:d43615c5dfa939dd30fe80dc0649434a13cc7cf0294ad0d7283d5a9f48c6ce86"}, - {file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp39-cp39-win_arm64.whl", hash = "sha256:8e70fa970997e2e67a8a4172bc00b0b2a79b5ff5bb2668f79cf10b3fd63d3974"}, - {file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1.tar.gz", hash = "sha256:cdf6ddc375e9150d040aca67f5a17c41ceaf13a63f3668f96608bc1d045dde71"}, -] - -[package.dependencies] -winrt-runtime = ">=3.2.1.0,<3.3.0.0" - -[package.extras] -all = ["winrt-Windows.Devices.Bluetooth[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Devices.Enumeration[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Foundation.Collections[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Foundation[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Storage.Streams[all] (>=3.2.1.0,<3.3.0.0)"] - -[[package]] -name = "winrt-windows-devices-enumeration" -version = "3.2.1" -description = "Python projection of Windows Runtime (WinRT) APIs" -optional = false -python-versions = ">=3.9" -groups = ["main", "dev"] -markers = "platform_system == \"Windows\"" -files = [ - {file = "winrt_windows_devices_enumeration-3.2.1-cp310-cp310-win32.whl", hash = "sha256:40dac777d8f45b41449f3ff1ae70f0d457f1ede53f53962a6e2521b651533db5"}, - {file = "winrt_windows_devices_enumeration-3.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:a101ec3e0ad0a0783032fdcd5dc48e7cd68ee034cbde4f903a8c7b391532c71a"}, - {file = "winrt_windows_devices_enumeration-3.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:3296a3863ac086928ff3f3dc872b2a2fb971dab728817424264f3ca547504e9e"}, - {file = "winrt_windows_devices_enumeration-3.2.1-cp311-cp311-win32.whl", hash = "sha256:9f29465a6c6b0456e4330d4ad09eccdd53a17e1e97695c2e57db0d4666cc0011"}, - {file = "winrt_windows_devices_enumeration-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2a725d04b4cb43aa0e2af035f73a60d16a6c0ff165fcb6b763383e4e33a975fd"}, - {file = "winrt_windows_devices_enumeration-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:6365ef5978d4add26678827286034acf474b6b133aa4054e76567d12194e6817"}, - {file = "winrt_windows_devices_enumeration-3.2.1-cp312-cp312-win32.whl", hash = "sha256:1db22b0292b93b0688d11ad932ad1f3629d4f471310281a2fbfe187530c2c1f3"}, - {file = "winrt_windows_devices_enumeration-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:a73bc88d7f510af454f2b392985501c96f39b89fd987140708ccaec1588ceebc"}, - {file = "winrt_windows_devices_enumeration-3.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:2853d687803f0dd76ae1afe3648abc0453e09dff0e7eddbb84b792eddb0473ca"}, - {file = "winrt_windows_devices_enumeration-3.2.1-cp313-cp313-win32.whl", hash = "sha256:14a71cdcc84f624c209cbb846ed6bd9767a9a9437b2bf26b48ac9a91599da6e9"}, - {file = "winrt_windows_devices_enumeration-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:6ca40d334734829e178ad46375275c4f7b5d6d2d4fc2e8879690452cbfb36015"}, - {file = "winrt_windows_devices_enumeration-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:2d14d187f43e4409c7814b7d1693c03a270e77489b710d92fcbbaeca5de260d4"}, - {file = "winrt_windows_devices_enumeration-3.2.1-cp39-cp39-win32.whl", hash = "sha256:986e8d651b769a0e60d2834834bdd3f6959f6a88caa0c9acb917797e6b43a588"}, - {file = "winrt_windows_devices_enumeration-3.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:10da7d403ac4afd385fe13bd5808c9a5dd616a8ef31ca5c64cea3f87673661c1"}, - {file = "winrt_windows_devices_enumeration-3.2.1-cp39-cp39-win_arm64.whl", hash = "sha256:679e471d21ac22cb50de1bf4dfc4c0c3f5da9f3e3fbc7f08dcacfe9de9d6dd58"}, - {file = "winrt_windows_devices_enumeration-3.2.1.tar.gz", hash = "sha256:df316899e39bfc0ffc1f3cb0f5ee54d04e1d167fbbcc1484d2d5121449a935cf"}, -] - -[package.dependencies] -winrt-runtime = ">=3.2.1.0,<3.3.0.0" - -[package.extras] -all = ["winrt-Windows.ApplicationModel.Background[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Foundation.Collections[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Foundation[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Security.Credentials[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Storage.Streams[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.UI.Popups[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.UI[all] (>=3.2.1.0,<3.3.0.0)"] - -[[package]] -name = "winrt-windows-foundation" -version = "3.2.1" -description = "Python projection of Windows Runtime (WinRT) APIs" -optional = false -python-versions = ">=3.9" -groups = ["main", "dev"] -markers = "platform_system == \"Windows\"" -files = [ - {file = "winrt_windows_foundation-3.2.1-cp310-cp310-win32.whl", hash = "sha256:677e98165dcbbf7a2367f905bc61090ef2c568b6e465f87cf7276df4734f3b0b"}, - {file = "winrt_windows_foundation-3.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:a8f27b4f0fdb73ccc4a3e24bc8010a6607b2bdd722fa799eafce7daa87d19d39"}, - {file = "winrt_windows_foundation-3.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:d900c6165fab4ea589811efa2feed27b532e1b6f505f63bf63e2052b8cb6bdc4"}, - {file = "winrt_windows_foundation-3.2.1-cp311-cp311-win32.whl", hash = "sha256:d1b5970241ccd61428f7330d099be75f4f52f25e510d82c84dbbdaadd625e437"}, - {file = "winrt_windows_foundation-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:f3762be2f6e0f2aedf83a0742fd727290b397ffe3463d963d29211e4ebb53a7e"}, - {file = "winrt_windows_foundation-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:806c77818217b3476e6c617293b3d5b0ff8a9901549dc3417586f6799938d671"}, - {file = "winrt_windows_foundation-3.2.1-cp312-cp312-win32.whl", hash = "sha256:867642ccf629611733db482c4288e17b7919f743a5873450efb6d69ae09fdc2b"}, - {file = "winrt_windows_foundation-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:45550c5b6c2125cde495c409633e6b1ea5aa1677724e3b95eb8140bfccbe30c9"}, - {file = "winrt_windows_foundation-3.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:94f4661d71cb35ebc52be7af112f2eeabdfa02cb05e0243bf9d6bd2cafaa6f37"}, - {file = "winrt_windows_foundation-3.2.1-cp313-cp313-win32.whl", hash = "sha256:3998dc58ed50ecbdbabace1cdef3a12920b725e32a5806d648ad3f4829d5ba46"}, - {file = "winrt_windows_foundation-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:6e98617c1e46665c7a56ce3f5d28e252798416d1ebfee3201267a644a4e3c479"}, - {file = "winrt_windows_foundation-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:2a8c1204db5c352f6a563130a5a41d25b887aff7897bb677d4ff0b660315aad4"}, - {file = "winrt_windows_foundation-3.2.1-cp39-cp39-win32.whl", hash = "sha256:14d5191725301498e4feb744d91f5b46ce317bf3d28370efda407d5c87f4423b"}, - {file = "winrt_windows_foundation-3.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:de5e4f61d253a91ba05019dbf4338c43f962bdad935721ced5e7997933994af5"}, - {file = "winrt_windows_foundation-3.2.1-cp39-cp39-win_arm64.whl", hash = "sha256:ebbf6e8168398c9ed0c72c8bdde95a406b9fbb9a23e3705d4f0fe28e5a209705"}, - {file = "winrt_windows_foundation-3.2.1.tar.gz", hash = "sha256:ad2f1fcaa6c34672df45527d7c533731fdf65b67c4638c2b4aca949f6eec0656"}, -] - -[package.dependencies] -winrt-runtime = ">=3.2.1.0,<3.3.0.0" - -[package.extras] -all = ["winrt-Windows.Foundation.Collections[all] (>=3.2.1.0,<3.3.0.0)"] - -[[package]] -name = "winrt-windows-foundation-collections" -version = "3.2.1" -description = "Python projection of Windows Runtime (WinRT) APIs" -optional = false -python-versions = ">=3.9" -groups = ["main", "dev"] -markers = "platform_system == \"Windows\"" -files = [ - {file = "winrt_windows_foundation_collections-3.2.1-cp310-cp310-win32.whl", hash = "sha256:46948484addfc4db981dab35688d4457533ceb54d4954922af41503fddaa8389"}, - {file = "winrt_windows_foundation_collections-3.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:899eaa3a93c35bfb1857d649e8dd60c38b978dda7cedd9725fcdbcebba156fd6"}, - {file = "winrt_windows_foundation_collections-3.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:c36eb49ad1eba1b32134df768bb47af13cabb9b59f974a3cea37843e2d80e0e6"}, - {file = "winrt_windows_foundation_collections-3.2.1-cp311-cp311-win32.whl", hash = "sha256:9b272d9936e7db4840881c5dcf921eb26789ae4ef23fb6ec15e13e19a16254e7"}, - {file = "winrt_windows_foundation_collections-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:c646a5d442dd6540ade50890081ca118b41f073356e19032d0a5d7d0d38fbc89"}, - {file = "winrt_windows_foundation_collections-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:2c4630027c93cdd518b0cf4cc726b8fbdbc3388e36d02aa1de190a0fc18ca523"}, - {file = "winrt_windows_foundation_collections-3.2.1-cp312-cp312-win32.whl", hash = "sha256:15704eef3125788f846f269cf54a3d89656fa09a1dc8428b70871f717d595ad6"}, - {file = "winrt_windows_foundation_collections-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:550dfb8c82fe74d9e0728a2a16a9175cc9e34ca2b8ef758d69b2a398894b698b"}, - {file = "winrt_windows_foundation_collections-3.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:810ad4bd11ab4a74fdbcd3ed33b597ef7c0b03af73fc9d7986c22bcf3bd24f84"}, - {file = "winrt_windows_foundation_collections-3.2.1-cp313-cp313-win32.whl", hash = "sha256:4267a711b63476d36d39227883aeb3fb19ac92b88a9fc9973e66fbce1fd4aed9"}, - {file = "winrt_windows_foundation_collections-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:5e12a6e75036ee90484c33e204b85fb6785fcc9e7c8066ad65097301f48cdd10"}, - {file = "winrt_windows_foundation_collections-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:34b556255562f1b36d07fba933c2bcd9f0db167fa96727a6cbb4717b152ad7a2"}, - {file = "winrt_windows_foundation_collections-3.2.1-cp39-cp39-win32.whl", hash = "sha256:20610f098b84c87765018cbc71471092197881f3b92e5d06158fad3bfcea2563"}, - {file = "winrt_windows_foundation_collections-3.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:e9739775320ac4c0238e1775d94a54e886d621f9995977e65d4feb8b3778c111"}, - {file = "winrt_windows_foundation_collections-3.2.1-cp39-cp39-win_arm64.whl", hash = "sha256:e4c6bddb1359d5014ceb45fe2ecd838d4afeb1184f2ea202c2d21037af0d08a3"}, - {file = "winrt_windows_foundation_collections-3.2.1.tar.gz", hash = "sha256:0eff1ad0d8d763ad17e9e7bbd0c26a62b27215016393c05b09b046d6503ae6d5"}, -] - -[package.dependencies] -winrt-runtime = ">=3.2.1.0,<3.3.0.0" - -[package.extras] -all = ["winrt-Windows.Foundation[all] (>=3.2.1.0,<3.3.0.0)"] - -[[package]] -name = "winrt-windows-storage-streams" -version = "3.2.1" -description = "Python projection of Windows Runtime (WinRT) APIs" -optional = false -python-versions = ">=3.9" -groups = ["main", "dev"] -markers = "platform_system == \"Windows\"" -files = [ - {file = "winrt_windows_storage_streams-3.2.1-cp310-cp310-win32.whl", hash = "sha256:89bb2d667ebed6861af36ed2710757456e12921ee56347946540320dacf6c003"}, - {file = "winrt_windows_storage_streams-3.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:48a78e5dc7d3488eb77e449c278bc6d6ac28abcdda7df298462c4112d7635d00"}, - {file = "winrt_windows_storage_streams-3.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:da71231d4a554f9f15f1249b4990c6431176f6dfb0e3385c7caa7896f4ca24d6"}, - {file = "winrt_windows_storage_streams-3.2.1-cp311-cp311-win32.whl", hash = "sha256:7dace2f9e364422255d0e2f335f741bfe7abb1f4d4f6003622b2450b87c91e69"}, - {file = "winrt_windows_storage_streams-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:b02fa251a7eef6081eca1a5f64ecf349cfd1ac0ac0c5a5a30be52897d060bed5"}, - {file = "winrt_windows_storage_streams-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:efdf250140340a75647e8e8ad002782d91308e9fdd1e19470a5b9cc969ae4780"}, - {file = "winrt_windows_storage_streams-3.2.1-cp312-cp312-win32.whl", hash = "sha256:77c1f0e004b84347b5bd705e8f0fc63be8cd29a6093be13f1d0869d0d97b7d78"}, - {file = "winrt_windows_storage_streams-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:e4508ee135af53e4fc142876abbf4bc7c2a95edfc7d19f52b291a8499cacd6dc"}, - {file = "winrt_windows_storage_streams-3.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:040cb94e6fb26b0d00a00e8b88b06fadf29dfe18cf24ed6cb3e69709c3613307"}, - {file = "winrt_windows_storage_streams-3.2.1-cp313-cp313-win32.whl", hash = "sha256:401bb44371720dc43bd1e78662615a2124372e7d5d9d65dfa8f77877bbcb8163"}, - {file = "winrt_windows_storage_streams-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:202c5875606398b8bfaa2a290831458bb55f2196a39c1d4e5fa88a03d65ef915"}, - {file = "winrt_windows_storage_streams-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:ca3c5ec0aab60895006bf61053a1aca6418bc7f9a27a34791ba3443b789d230d"}, - {file = "winrt_windows_storage_streams-3.2.1-cp39-cp39-win32.whl", hash = "sha256:1c630cfdece58fcf82e4ed86c826326123529836d6d4d855ae8e9ceeff67b627"}, - {file = "winrt_windows_storage_streams-3.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:d7ff22434a4829d616a04b068a191ac79e008f6c27541bb178c1f6f1fe7a1657"}, - {file = "winrt_windows_storage_streams-3.2.1-cp39-cp39-win_arm64.whl", hash = "sha256:fa90244191108f85f6f7afb43a11d365aca4e0722fe8adc62fb4d2c678d0993d"}, - {file = "winrt_windows_storage_streams-3.2.1.tar.gz", hash = "sha256:476f522722751eb0b571bc7802d85a82a3cae8b1cce66061e6e758f525e7b80f"}, -] - -[package.dependencies] -winrt-runtime = ">=3.2.1.0,<3.3.0.0" - -[package.extras] -all = ["winrt-Windows.Foundation.Collections[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Foundation[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Storage[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.System[all] (>=3.2.1.0,<3.3.0.0)"] - -[[package]] -name = "yarl" -version = "1.20.1" -description = "Yet another URL library" -optional = false -python-versions = ">=3.9" -groups = ["main", "dev"] -files = [ - {file = "yarl-1.20.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:6032e6da6abd41e4acda34d75a816012717000fa6839f37124a47fcefc49bec4"}, - {file = "yarl-1.20.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2c7b34d804b8cf9b214f05015c4fee2ebe7ed05cf581e7192c06555c71f4446a"}, - {file = "yarl-1.20.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0c869f2651cc77465f6cd01d938d91a11d9ea5d798738c1dc077f3de0b5e5fed"}, - {file = "yarl-1.20.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:62915e6688eb4d180d93840cda4110995ad50c459bf931b8b3775b37c264af1e"}, - {file = "yarl-1.20.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:41ebd28167bc6af8abb97fec1a399f412eec5fd61a3ccbe2305a18b84fb4ca73"}, - {file = "yarl-1.20.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:21242b4288a6d56f04ea193adde174b7e347ac46ce6bc84989ff7c1b1ecea84e"}, - {file = "yarl-1.20.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bea21cdae6c7eb02ba02a475f37463abfe0a01f5d7200121b03e605d6a0439f8"}, - {file = "yarl-1.20.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1f8a891e4a22a89f5dde7862994485e19db246b70bb288d3ce73a34422e55b23"}, - {file = "yarl-1.20.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dd803820d44c8853a109a34e3660e5a61beae12970da479cf44aa2954019bf70"}, - {file = "yarl-1.20.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b982fa7f74c80d5c0c7b5b38f908971e513380a10fecea528091405f519b9ebb"}, - {file = "yarl-1.20.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:33f29ecfe0330c570d997bcf1afd304377f2e48f61447f37e846a6058a4d33b2"}, - {file = "yarl-1.20.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:835ab2cfc74d5eb4a6a528c57f05688099da41cf4957cf08cad38647e4a83b30"}, - {file = "yarl-1.20.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:46b5e0ccf1943a9a6e766b2c2b8c732c55b34e28be57d8daa2b3c1d1d4009309"}, - {file = "yarl-1.20.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:df47c55f7d74127d1b11251fe6397d84afdde0d53b90bedb46a23c0e534f9d24"}, - {file = "yarl-1.20.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:76d12524d05841276b0e22573f28d5fbcb67589836772ae9244d90dd7d66aa13"}, - {file = "yarl-1.20.1-cp310-cp310-win32.whl", hash = "sha256:6c4fbf6b02d70e512d7ade4b1f998f237137f1417ab07ec06358ea04f69134f8"}, - {file = "yarl-1.20.1-cp310-cp310-win_amd64.whl", hash = "sha256:aef6c4d69554d44b7f9d923245f8ad9a707d971e6209d51279196d8e8fe1ae16"}, - {file = "yarl-1.20.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:47ee6188fea634bdfaeb2cc420f5b3b17332e6225ce88149a17c413c77ff269e"}, - {file = "yarl-1.20.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d0f6500f69e8402d513e5eedb77a4e1818691e8f45e6b687147963514d84b44b"}, - {file = "yarl-1.20.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7a8900a42fcdaad568de58887c7b2f602962356908eedb7628eaf6021a6e435b"}, - {file = "yarl-1.20.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bad6d131fda8ef508b36be3ece16d0902e80b88ea7200f030a0f6c11d9e508d4"}, - {file = "yarl-1.20.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:df018d92fe22aaebb679a7f89fe0c0f368ec497e3dda6cb81a567610f04501f1"}, - {file = "yarl-1.20.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f969afbb0a9b63c18d0feecf0db09d164b7a44a053e78a7d05f5df163e43833"}, - {file = "yarl-1.20.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:812303eb4aa98e302886ccda58d6b099e3576b1b9276161469c25803a8db277d"}, - {file = "yarl-1.20.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98c4a7d166635147924aa0bf9bfe8d8abad6fffa6102de9c99ea04a1376f91e8"}, - {file = "yarl-1.20.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:12e768f966538e81e6e7550f9086a6236b16e26cd964cf4df35349970f3551cf"}, - {file = "yarl-1.20.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fe41919b9d899661c5c28a8b4b0acf704510b88f27f0934ac7a7bebdd8938d5e"}, - {file = "yarl-1.20.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:8601bc010d1d7780592f3fc1bdc6c72e2b6466ea34569778422943e1a1f3c389"}, - {file = "yarl-1.20.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:daadbdc1f2a9033a2399c42646fbd46da7992e868a5fe9513860122d7fe7a73f"}, - {file = "yarl-1.20.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:03aa1e041727cb438ca762628109ef1333498b122e4c76dd858d186a37cec845"}, - {file = "yarl-1.20.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:642980ef5e0fa1de5fa96d905c7e00cb2c47cb468bfcac5a18c58e27dbf8d8d1"}, - {file = "yarl-1.20.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:86971e2795584fe8c002356d3b97ef6c61862720eeff03db2a7c86b678d85b3e"}, - {file = "yarl-1.20.1-cp311-cp311-win32.whl", hash = "sha256:597f40615b8d25812f14562699e287f0dcc035d25eb74da72cae043bb884d773"}, - {file = "yarl-1.20.1-cp311-cp311-win_amd64.whl", hash = "sha256:26ef53a9e726e61e9cd1cda6b478f17e350fb5800b4bd1cd9fe81c4d91cfeb2e"}, - {file = "yarl-1.20.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdcc4cd244e58593a4379fe60fdee5ac0331f8eb70320a24d591a3be197b94a9"}, - {file = "yarl-1.20.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b29a2c385a5f5b9c7d9347e5812b6f7ab267193c62d282a540b4fc528c8a9d2a"}, - {file = "yarl-1.20.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1112ae8154186dfe2de4732197f59c05a83dc814849a5ced892b708033f40dc2"}, - {file = "yarl-1.20.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90bbd29c4fe234233f7fa2b9b121fb63c321830e5d05b45153a2ca68f7d310ee"}, - {file = "yarl-1.20.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:680e19c7ce3710ac4cd964e90dad99bf9b5029372ba0c7cbfcd55e54d90ea819"}, - {file = "yarl-1.20.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4a979218c1fdb4246a05efc2cc23859d47c89af463a90b99b7c56094daf25a16"}, - {file = "yarl-1.20.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:255b468adf57b4a7b65d8aad5b5138dce6a0752c139965711bdcb81bc370e1b6"}, - {file = "yarl-1.20.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a97d67108e79cfe22e2b430d80d7571ae57d19f17cda8bb967057ca8a7bf5bfd"}, - {file = "yarl-1.20.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8570d998db4ddbfb9a590b185a0a33dbf8aafb831d07a5257b4ec9948df9cb0a"}, - {file = "yarl-1.20.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:97c75596019baae7c71ccf1d8cc4738bc08134060d0adfcbe5642f778d1dca38"}, - {file = "yarl-1.20.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1c48912653e63aef91ff988c5432832692ac5a1d8f0fb8a33091520b5bbe19ef"}, - {file = "yarl-1.20.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4c3ae28f3ae1563c50f3d37f064ddb1511ecc1d5584e88c6b7c63cf7702a6d5f"}, - {file = "yarl-1.20.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c5e9642f27036283550f5f57dc6156c51084b458570b9d0d96100c8bebb186a8"}, - {file = "yarl-1.20.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:2c26b0c49220d5799f7b22c6838409ee9bc58ee5c95361a4d7831f03cc225b5a"}, - {file = "yarl-1.20.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:564ab3d517e3d01c408c67f2e5247aad4019dcf1969982aba3974b4093279004"}, - {file = "yarl-1.20.1-cp312-cp312-win32.whl", hash = "sha256:daea0d313868da1cf2fac6b2d3a25c6e3a9e879483244be38c8e6a41f1d876a5"}, - {file = "yarl-1.20.1-cp312-cp312-win_amd64.whl", hash = "sha256:48ea7d7f9be0487339828a4de0360d7ce0efc06524a48e1810f945c45b813698"}, - {file = "yarl-1.20.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:0b5ff0fbb7c9f1b1b5ab53330acbfc5247893069e7716840c8e7d5bb7355038a"}, - {file = "yarl-1.20.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:14f326acd845c2b2e2eb38fb1346c94f7f3b01a4f5c788f8144f9b630bfff9a3"}, - {file = "yarl-1.20.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f60e4ad5db23f0b96e49c018596707c3ae89f5d0bd97f0ad3684bcbad899f1e7"}, - {file = "yarl-1.20.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:49bdd1b8e00ce57e68ba51916e4bb04461746e794e7c4d4bbc42ba2f18297691"}, - {file = "yarl-1.20.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:66252d780b45189975abfed839616e8fd2dbacbdc262105ad7742c6ae58f3e31"}, - {file = "yarl-1.20.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:59174e7332f5d153d8f7452a102b103e2e74035ad085f404df2e40e663a22b28"}, - {file = "yarl-1.20.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e3968ec7d92a0c0f9ac34d5ecfd03869ec0cab0697c91a45db3fbbd95fe1b653"}, - {file = "yarl-1.20.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d1a4fbb50e14396ba3d375f68bfe02215d8e7bc3ec49da8341fe3157f59d2ff5"}, - {file = "yarl-1.20.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:11a62c839c3a8eac2410e951301309426f368388ff2f33799052787035793b02"}, - {file = "yarl-1.20.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:041eaa14f73ff5a8986b4388ac6bb43a77f2ea09bf1913df7a35d4646db69e53"}, - {file = "yarl-1.20.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:377fae2fef158e8fd9d60b4c8751387b8d1fb121d3d0b8e9b0be07d1b41e83dc"}, - {file = "yarl-1.20.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1c92f4390e407513f619d49319023664643d3339bd5e5a56a3bebe01bc67ec04"}, - {file = "yarl-1.20.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d25ddcf954df1754ab0f86bb696af765c5bfaba39b74095f27eececa049ef9a4"}, - {file = "yarl-1.20.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:909313577e9619dcff8c31a0ea2aa0a2a828341d92673015456b3ae492e7317b"}, - {file = "yarl-1.20.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:793fd0580cb9664548c6b83c63b43c477212c0260891ddf86809e1c06c8b08f1"}, - {file = "yarl-1.20.1-cp313-cp313-win32.whl", hash = "sha256:468f6e40285de5a5b3c44981ca3a319a4b208ccc07d526b20b12aeedcfa654b7"}, - {file = "yarl-1.20.1-cp313-cp313-win_amd64.whl", hash = "sha256:495b4ef2fea40596bfc0affe3837411d6aa3371abcf31aac0ccc4bdd64d4ef5c"}, - {file = "yarl-1.20.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:f60233b98423aab21d249a30eb27c389c14929f47be8430efa7dbd91493a729d"}, - {file = "yarl-1.20.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6f3eff4cc3f03d650d8755c6eefc844edde99d641d0dcf4da3ab27141a5f8ddf"}, - {file = "yarl-1.20.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:69ff8439d8ba832d6bed88af2c2b3445977eba9a4588b787b32945871c2444e3"}, - {file = "yarl-1.20.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3cf34efa60eb81dd2645a2e13e00bb98b76c35ab5061a3989c7a70f78c85006d"}, - {file = "yarl-1.20.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8e0fe9364ad0fddab2688ce72cb7a8e61ea42eff3c7caeeb83874a5d479c896c"}, - {file = "yarl-1.20.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f64fbf81878ba914562c672024089e3401974a39767747691c65080a67b18c1"}, - {file = "yarl-1.20.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f6342d643bf9a1de97e512e45e4b9560a043347e779a173250824f8b254bd5ce"}, - {file = "yarl-1.20.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:56dac5f452ed25eef0f6e3c6a066c6ab68971d96a9fb441791cad0efba6140d3"}, - {file = "yarl-1.20.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7d7f497126d65e2cad8dc5f97d34c27b19199b6414a40cb36b52f41b79014be"}, - {file = "yarl-1.20.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:67e708dfb8e78d8a19169818eeb5c7a80717562de9051bf2413aca8e3696bf16"}, - {file = "yarl-1.20.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:595c07bc79af2494365cc96ddeb772f76272364ef7c80fb892ef9d0649586513"}, - {file = "yarl-1.20.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7bdd2f80f4a7df852ab9ab49484a4dee8030023aa536df41f2d922fd57bf023f"}, - {file = "yarl-1.20.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c03bfebc4ae8d862f853a9757199677ab74ec25424d0ebd68a0027e9c639a390"}, - {file = "yarl-1.20.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:344d1103e9c1523f32a5ed704d576172d2cabed3122ea90b1d4e11fe17c66458"}, - {file = "yarl-1.20.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:88cab98aa4e13e1ade8c141daeedd300a4603b7132819c484841bb7af3edce9e"}, - {file = "yarl-1.20.1-cp313-cp313t-win32.whl", hash = "sha256:b121ff6a7cbd4abc28985b6028235491941b9fe8fe226e6fdc539c977ea1739d"}, - {file = "yarl-1.20.1-cp313-cp313t-win_amd64.whl", hash = "sha256:541d050a355bbbc27e55d906bc91cb6fe42f96c01413dd0f4ed5a5240513874f"}, - {file = "yarl-1.20.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:e42ba79e2efb6845ebab49c7bf20306c4edf74a0b20fc6b2ccdd1a219d12fad3"}, - {file = "yarl-1.20.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:41493b9b7c312ac448b7f0a42a089dffe1d6e6e981a2d76205801a023ed26a2b"}, - {file = "yarl-1.20.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f5a5928ff5eb13408c62a968ac90d43f8322fd56d87008b8f9dabf3c0f6ee983"}, - {file = "yarl-1.20.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:30c41ad5d717b3961b2dd785593b67d386b73feca30522048d37298fee981805"}, - {file = "yarl-1.20.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:59febc3969b0781682b469d4aca1a5cab7505a4f7b85acf6db01fa500fa3f6ba"}, - {file = "yarl-1.20.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d2b6fb3622b7e5bf7a6e5b679a69326b4279e805ed1699d749739a61d242449e"}, - {file = "yarl-1.20.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:749d73611db8d26a6281086f859ea7ec08f9c4c56cec864e52028c8b328db723"}, - {file = "yarl-1.20.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9427925776096e664c39e131447aa20ec738bdd77c049c48ea5200db2237e000"}, - {file = "yarl-1.20.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ff70f32aa316393eaf8222d518ce9118148eddb8a53073c2403863b41033eed5"}, - {file = "yarl-1.20.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:c7ddf7a09f38667aea38801da8b8d6bfe81df767d9dfc8c88eb45827b195cd1c"}, - {file = "yarl-1.20.1-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:57edc88517d7fc62b174fcfb2e939fbc486a68315d648d7e74d07fac42cec240"}, - {file = "yarl-1.20.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:dab096ce479d5894d62c26ff4f699ec9072269d514b4edd630a393223f45a0ee"}, - {file = "yarl-1.20.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:14a85f3bd2d7bb255be7183e5d7d6e70add151a98edf56a770d6140f5d5f4010"}, - {file = "yarl-1.20.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:2c89b5c792685dd9cd3fa9761c1b9f46fc240c2a3265483acc1565769996a3f8"}, - {file = "yarl-1.20.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:69e9b141de5511021942a6866990aea6d111c9042235de90e08f94cf972ca03d"}, - {file = "yarl-1.20.1-cp39-cp39-win32.whl", hash = "sha256:b5f307337819cdfdbb40193cad84978a029f847b0a357fbe49f712063cfc4f06"}, - {file = "yarl-1.20.1-cp39-cp39-win_amd64.whl", hash = "sha256:eae7bfe2069f9c1c5b05fc7fe5d612e5bbc089a39309904ee8b829e322dcad00"}, - {file = "yarl-1.20.1-py3-none-any.whl", hash = "sha256:83b8eb083fe4683c6115795d9fc1cfaf2cbbefb19b3a1cb68f6527460f483a77"}, - {file = "yarl-1.20.1.tar.gz", hash = "sha256:d017a4997ee50c91fd5466cef416231bb82177b93b029906cefc542ce14c35ac"}, -] - -[package.dependencies] -idna = ">=2.0" -multidict = ">=4.0" -propcache = ">=0.2.1" - -[[package]] -name = "zeroconf" -version = "0.147.0" -description = "A pure python implementation of multicast DNS service discovery" -optional = false -python-versions = "<4.0,>=3.9" -groups = ["main", "dev"] -files = [ - {file = "zeroconf-0.147.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:de0e1439c90df08fbb59d07445904aa9cb0ed3c548bb2f89a8a7bb3fa50071cd"}, - {file = "zeroconf-0.147.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a2b8776e5e3ba7f19578a6baffa231f148390156a231eb17924c30e192ec9b00"}, - {file = "zeroconf-0.147.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a80cf348e8a1c597a427b500cf5327adf1a1402b010c84ba78b39ea7d3d7598"}, - {file = "zeroconf-0.147.0-cp310-cp310-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:8014da8e9d1efa04df8091c60bc8e23a25d6374b2f99f2ba515ca8d7d6d9b271"}, - {file = "zeroconf-0.147.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9a6c359af0ff3ac50812c9bdbeb006279bb4c401d721f1f22651522f73237295"}, - {file = "zeroconf-0.147.0-cp310-cp310-manylinux_2_31_armv7l.manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8d6e3d598bff4c07b61e7be89a56aa75ef3e2e2a6622881b76a0d06ef527fc39"}, - {file = "zeroconf-0.147.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ecb83e1b27eb8a9f14bc5458b4f87594d24d5f206dbaefae93021a702a311905"}, - {file = "zeroconf-0.147.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:f8cb0b47eb18f2340505f5106adc2dc698a131785c6e0e6ca56f9f5c09ef6469"}, - {file = "zeroconf-0.147.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:7204d4d41ef95021acc3ff7a7d3266558ea2032a167a3591c4fb570cc64b522c"}, - {file = "zeroconf-0.147.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a37e54a991e9e7203650f7884a44097616bed9c050f19db50316abda409ab208"}, - {file = "zeroconf-0.147.0-cp310-cp310-win32.whl", hash = "sha256:df1dfdf49fcff8bacc7d490eeafeb3d74aea5588f78a459b76994b7acf3fe513"}, - {file = "zeroconf-0.147.0-cp310-cp310-win_amd64.whl", hash = "sha256:1f0d3c119e77ec960d03ad5b92e651e5cc8c49d5521cbd58a561f8db709c79cc"}, - {file = "zeroconf-0.147.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2f36096616f89f3aec678613316014e3a35e293c3c2e26466695d91688c49a34"}, - {file = "zeroconf-0.147.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:abb3f45ce14fdbcf70d2cb711714553e20e704cc2d86988125936d853f01c132"}, - {file = "zeroconf-0.147.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6f511b32c1693f5f76acea9ce7add629ed62071f001929d818bea09aaa2c2969"}, - {file = "zeroconf-0.147.0-cp311-cp311-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:6269129298004531b0b809a32bfe82865e8641f7940647c33a0b9c9e36012187"}, - {file = "zeroconf-0.147.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ed2b6369a50e4a27667a6c80011ff276d63d205f8ccf5771f9fceddb0c9ce6b4"}, - {file = "zeroconf-0.147.0-cp311-cp311-manylinux_2_31_armv7l.manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:02205731db993193034b0f5cc5108e2cf2c580e445ff401be05ea80aeb762c7c"}, - {file = "zeroconf-0.147.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5bd0984a2f457d399e414622941cce30bb52bd9bcbfc0594a266ed724a030469"}, - {file = "zeroconf-0.147.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:e4a1ccbbbeb3c5e1867f38aee18647245069ca01572f6e9188d549f294f2ac77"}, - {file = "zeroconf-0.147.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:9f2b7f930407403f9813525537a78613510d9c394a11328cafb8bb46f060d89e"}, - {file = "zeroconf-0.147.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f7b6ee515a76bb983b26590eda099d57fede043f3b753427ff9dc4e1c7405395"}, - {file = "zeroconf-0.147.0-cp311-cp311-win32.whl", hash = "sha256:3dec7640d85362d70a2e4c89e34e0c865f41c00073378597afdac0a2e146b061"}, - {file = "zeroconf-0.147.0-cp311-cp311-win_amd64.whl", hash = "sha256:5c8962eb8c829fd22bbda2ec23443e6e14e6f44adf2a823da0f074a941d8b8e7"}, - {file = "zeroconf-0.147.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:89542174ec4f1e86ba428cab19a33c77378dcac0ee37577df8859af849cf736e"}, - {file = "zeroconf-0.147.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0086b8c280fc3410fa887a0da68cc6dc5a7d1da8a57157f6c7e4acb029674ce9"}, - {file = "zeroconf-0.147.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:04018f48c38fa174af8e2c6cd00e6fb8a9a7269a187ba1f52cf32c14f2ddd941"}, - {file = "zeroconf-0.147.0-cp312-cp312-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:71a5690ad94a8b5809bb3888e3c30de943458b9fb751730fa9a60e580d1b47ca"}, - {file = "zeroconf-0.147.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:34b5f1f472eadd090232af6b52c16c417359d9541e7bb31afbde3a0e33e55594"}, - {file = "zeroconf-0.147.0-cp312-cp312-manylinux_2_31_armv7l.manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:213b9ebdb5876d7b30515e2850d250625ac1a5c54b022a0fb407ea25af39251f"}, - {file = "zeroconf-0.147.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:10e75e41160f376b7b43a242ffeee5803878fd31d30991eb60cd1d0949373d51"}, - {file = "zeroconf-0.147.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d7b488d1a74bac55080a9cadb6b8690f0aefa89ae8130485557bc53ba866d445"}, - {file = "zeroconf-0.147.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a3f6d9c6c226a7fd6669584dfcb8160a60b8711f00229a8d7347451447b220e2"}, - {file = "zeroconf-0.147.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:137d83815347e0ede605f9d7a0d5253f95d62a9872f291a123679c4a01e016bb"}, - {file = "zeroconf-0.147.0-cp312-cp312-win32.whl", hash = "sha256:1ae71afd284f07d9a46acf69674efef283c039f5e326250c29578e0605f7dac6"}, - {file = "zeroconf-0.147.0-cp312-cp312-win_amd64.whl", hash = "sha256:e66b2044a626f20bc4ddab92de662b4f4bce8e4f1e85672d88186c9e776505a1"}, - {file = "zeroconf-0.147.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1deedbedea7402754b3a1a05a2a1c881443451ccd600b2a7f979e97dd9fcbe6d"}, - {file = "zeroconf-0.147.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5c57d551e65a2a9b6333b685e3b074601f6e85762e4b4a490c663f1f2e215b24"}, - {file = "zeroconf-0.147.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:095bdb0cd369355ff919e3be930991b38557baaa8292d82f4a4a8567a3944f05"}, - {file = "zeroconf-0.147.0-cp313-cp313-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:8ae0fe0bb947b3a128af586c76a16b5a7d027daa65e67637b042c745f9b136c4"}, - {file = "zeroconf-0.147.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6cbeea4d8d0c4f6eb5a82099d53f5729b628685039a44c1a84422080f8ec5b0d"}, - {file = "zeroconf-0.147.0-cp313-cp313-manylinux_2_31_armv7l.manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:728f82417800c5c5dd3298f65cf7a8fef1707123b457d3832dbdf17d38f68840"}, - {file = "zeroconf-0.147.0-cp313-cp313-manylinux_2_36_x86_64.whl", hash = "sha256:a2dc9ae96cd49b50d651a78204aafe9f41e907122dc98e719be5376b4dddec6f"}, - {file = "zeroconf-0.147.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e9570ab3203cc4bd3ad023737ef4339558cdf1f33a5d45d76ed3fe77e5fa5f57"}, - {file = "zeroconf-0.147.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:fd783d9bac258e79d07e2bd164c1962b8f248579392b5078fd607e7bb6760b53"}, - {file = "zeroconf-0.147.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b4acc76063cc379774db407dce0263616518bb5135057eb5eeafc447b3c05a81"}, - {file = "zeroconf-0.147.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:acc5334cb6cb98db3917bf9a3d6b6b7fdd205f8a74fd6f4b885abb4f61098857"}, - {file = "zeroconf-0.147.0-cp313-cp313-win32.whl", hash = "sha256:7c52c523aa756e67bf18d46db298a5964291f7d868b4a970163432e7d745b992"}, - {file = "zeroconf-0.147.0-cp313-cp313-win_amd64.whl", hash = "sha256:60f623af0e45fba69f5fe80d7b300c913afe7928fb43f4b9757f0f76f80f0d82"}, - {file = "zeroconf-0.147.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a5b570c0697144876bf54ff9d87dc46f5f7c8b2709702b7182b99b363e37e301"}, - {file = "zeroconf-0.147.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:12a24145231b26d1e6d7ded60984f8f32e2b17fe4f9a824be26b7622b875bc12"}, - {file = "zeroconf-0.147.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cf73a1a202a02f2e4810eb582f054104bff1a29a550ce0819c5fc6dfafe50f94"}, - {file = "zeroconf-0.147.0-cp39-cp39-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:aa55888411404cc7ff1c6822ace733680172a02931eb20561afda94d06342c4f"}, - {file = "zeroconf-0.147.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5291a5f26d961cfc0cd22905187a0627d07913e566783191f97a5a49dec9fc32"}, - {file = "zeroconf-0.147.0-cp39-cp39-manylinux_2_31_armv7l.manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:930b434ee3471430f3938f129979926a13c5ee1802ad6fb51383314f7193d33d"}, - {file = "zeroconf-0.147.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:4add353da94e067fd3a4fdb47e9be64bd451bc7bc2234d74775514bde5d8096c"}, - {file = "zeroconf-0.147.0-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:8c0a1c69ef9ff5aab6acdf33b892a45d5ca2f96af11fd53891a43ec816ab6c25"}, - {file = "zeroconf-0.147.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:743fff2187da3d9501606555b1cf27699c24c36b9f1a6f02c168033034c5c22e"}, - {file = "zeroconf-0.147.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:9a629b63f8f80a32121c4832264c23fbc6cec4e15200d4fd8e715257c06d84b1"}, - {file = "zeroconf-0.147.0-cp39-cp39-win32.whl", hash = "sha256:600b2a2453e7877b45c55d0c011e24f9eb7b75d71eee8a302e9b8b55e878b62b"}, - {file = "zeroconf-0.147.0-cp39-cp39-win_amd64.whl", hash = "sha256:34d40e09a7e4aff294ce086a63c5133c4d683b0165054af07c15035af1718829"}, - {file = "zeroconf-0.147.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:8a2ceada07a6e89d7000e19826598a2fe013192e7a752637c1232cbc6041f434"}, - {file = "zeroconf-0.147.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:60b548b8b58f4f7435f96e4a8bed0ffedc6023ebd9e30872d3e71e3f5d8bd8e3"}, - {file = "zeroconf-0.147.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b7edafeb07c88398d6c2f01ce6810c566199d5d50c9da7ac93e9f0da1d34b7c8"}, - {file = "zeroconf-0.147.0-pp310-pypy310_pp73-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:b39f83ff516ad764bda67ad242ef85481d1653d85bd4bc98cc62070bb086789c"}, - {file = "zeroconf-0.147.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:abe4c6da969c64419e30ca83368475d97ae57421edb2ec86e5c2baa8f659bad5"}, - {file = "zeroconf-0.147.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:8ec79d8693874f0dda108be48f56bdd343ddb9a469d629a796f853ba0865f07e"}, - {file = "zeroconf-0.147.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b89c62ea0220235da8b353958ea8340d62ed820dca34be0ff08146674a93168b"}, - {file = "zeroconf-0.147.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:f4fd6848de32756ec34531ea9b3cf1988c9115b7d8363687d3ffaa2cdba244d4"}, - {file = "zeroconf-0.147.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c0f51b5e363c137291a56688898fe284c5f99f52b7f8a1528ebe9d81e8daeecf"}, - {file = "zeroconf-0.147.0-pp311-pypy311_pp73-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:de84c8e09a49009ff44fb1146bfd2380545cde2a9f5ebfa7038afc578b187b2a"}, - {file = "zeroconf-0.147.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:81ea55950ace20a93ce1ef684b111498cb212e06cc190a311a572d42a49809fc"}, - {file = "zeroconf-0.147.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:f0e1e1abe27ab81bbaaac2e4d94f9e817e64128ab03ba5909462d1f4cc560bc5"}, - {file = "zeroconf-0.147.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:6883f1b39ef1fea628e1ad027c4e5ed5058582d1453ea0f8dee5f7afd421b228"}, - {file = "zeroconf-0.147.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:f0c1157a7500145224b2606ca1efe393afa9879133acf40b1b428dfa5503581a"}, - {file = "zeroconf-0.147.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f815a2202a4fdc66bd6cf81e4cd55657fb903b5f748455f6b9ca1702fb7d559"}, - {file = "zeroconf-0.147.0-pp39-pypy39_pp73-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:5f139fcf33fa166779b959185c46feae5c82dc86b09a8ee77484d1bd4155a87f"}, - {file = "zeroconf-0.147.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:536f5f07d2f2d5c872dc6acc8e8a560254c2a5874eba45165dc5deb14a49cc19"}, - {file = "zeroconf-0.147.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:760d9b318e6ba311054348719f3e9ea33f678afbbf9e9585d7993638bba12220"}, - {file = "zeroconf-0.147.0.tar.gz", hash = "sha256:f517375de6bf2041df826130da41dc7a3e8772176d3076a5da58854c7d2e8d7a"}, -] - -[package.dependencies] -ifaddr = ">=0.1.7" - -[metadata] -lock-version = "2.1" -python-versions = ">=3.13.2,<3.14" -content-hash = "b7c3a8769b83f7def52a996f759341bc120ccf3761153b284d3b52753f947eeb" diff --git a/prek.toml b/prek.toml new file mode 100644 index 00000000..fb65adcb --- /dev/null +++ b/prek.toml @@ -0,0 +1,186 @@ +default_stages = ["pre-commit"] + +[[repos]] +repo = "https://github.com/pre-commit/pre-commit-hooks" +rev = "v5.0.0" +hooks = [ + { id = "check-yaml", exclude = '\.\S*_cache/.*|dist/.*|venv/.*' }, + { id = "check-toml", exclude = '\.\S*_cache/.*|dist/.*|venv/.*' }, + { id = "check-json", exclude = '\.\S*_cache/.*|dist/.*|venv/.*' }, + { id = "check-added-large-files" }, + { id = "check-merge-conflict", exclude = '\.\S*_cache/.*|dist/.*|venv/.*' }, + { id = "debug-statements", exclude = '^tests/.*|\.\S*_cache/.*|dist/.*|venv/.*', language_version = "python3.14" }, + { id = "trailing-whitespace", exclude = '\.\S*_cache/.*|dist/.*|venv/.*' }, + { id = "end-of-file-fixer", exclude = '\.\S*_cache/.*|dist/.*|venv/.*' }, + { id = "mixed-line-ending", args = ["--fix=lf"], exclude = '\.\S*_cache/.*|dist/.*|venv/.*' }, +] + +# Sync translations/en.json from strings.json and validate translation files +[[repos]] +repo = "local" +hooks = [ + { + id = "sync-translations", + name = "sync and validate translations", + entry = "python scripts/sync_translations.py", + language = "system", + pass_filenames = false, + files = '(strings\.json|translations/.*\.json)$', + verbose = true, + }, +] + +# Pylint check for import-outside-toplevel (run before ruff) +[[repos]] +repo = "local" +hooks = [ + { + id = "pylint-import-check", + name = "pylint import-outside-toplevel check", + entry = "uv run pylint", + language = "system", + args = ["--disable=all", "--enable=import-outside-toplevel", "--score=no", "custom_components/span_panel/"], + pass_filenames = false, + files = '\.py$', + exclude = '^tests/.*|^scripts/.*', + }, +] + +# Ruff for linting, import sorting, and primary formatting +[[repos]] +repo = "https://github.com/astral-sh/ruff-pre-commit" +rev = "v0.15.1" +hooks = [ + { id = "ruff-format", exclude = '^tests/.*|scripts/.*|\.\S*_cache/.*|dist/.*|venv/.*' }, + { id = "ruff-check", args = ["--fix"], exclude = '^tests/.*|scripts/.*|\.\S*_cache/.*|dist/.*|venv/.*' }, +] + +# MyPy for type checking (local hook — needs project venv for span-panel-api) +[[repos]] +repo = "local" +hooks = [ + { + id = "mypy", + name = "mypy", + entry = "uv run mypy", + language = "system", + args = ["--config-file=pyproject.toml", "custom_components/span_panel/"], + pass_filenames = false, + files = '\.py$', + exclude = '^tests/.*|^scripts/.*|docs/.*|\.\S*_cache/.*|dist/.*|venv/.*', + }, +] + +# Markdownlint for markdown files +[[repos]] +repo = "https://github.com/DavidAnson/markdownlint-cli2" +rev = "v0.18.1" +hooks = [ + { + id = "markdownlint-cli2", + args = ["--config", ".markdownlint-cli2.jsonc"], + exclude = '\.\S*_cache/.*|dist/.*|venv/.*|\.venv/.*|node_modules/.*|htmlcov/.*|tests/.*', + }, +] + +# Markdown formatting using shared script (local only) +[[repos]] +repo = "local" +hooks = [ + { + id = "markdown-format", + name = "Format markdown files", + entry = "scripts/fix-markdown.sh", + language = "system", + args = ["."], + types = ["markdown"], + pass_filenames = false, + always_run = true, + stages = ["manual"], + }, +] + +# Check for common security issues +[[repos]] +repo = "https://github.com/PyCQA/bandit" +rev = "1.8.3" +hooks = [ + { + id = "bandit", + args = ["-c", "pyproject.toml"], + additional_dependencies = ["bandit[toml]"], + exclude = '^tests/.*|^scripts/.*|\.\S*_cache/.*|dist/.*|venv/.*', + }, +] + +# Vulture for dead code detection +[[repos]] +repo = "local" +hooks = [ + { + id = "vulture", + name = "vulture dead code check", + entry = "uv run vulture", + language = "system", + args = ["custom_components/span_panel/", "--min-confidence=80"], + pass_filenames = false, + files = '\.py$', + exclude = '^tests/.*|^scripts/.*|\.\S*_cache/.*|dist/.*|venv/.*', + }, +] + +# Radon for code metrics and maintainability (local) +[[repos]] +repo = "local" +hooks = [ + { + id = "radon-complexity", + name = "radon complexity check", + entry = "uv run radon", + language = "system", + args = ["cc", "--min=B", "--show-complexity", "custom_components/span_panel/"], + pass_filenames = false, + files = '\.py$', + }, + { + id = "radon-maintainability", + name = "radon maintainability index", + entry = "uv run radon", + language = "system", + args = ["mi", "--min=B", "--show", "custom_components/span_panel/"], + pass_filenames = false, + files = '\.py$', + }, +] + +# Coverage check with pytest output and coverage report +[[repos]] +repo = "local" +hooks = [ + { + id = "pytest-cov-summary", + name = "coverage summary", + entry = "bash", + language = "system", + args = ["-c", 'echo "Running tests with coverage..."; uv run pytest tests/ --cov=custom_components/span_panel --cov-config=pyproject.toml --cov-report=term-missing:skip-covered -v; exit_code=$?; echo; if [ $exit_code -eq 0 ]; then echo "Tests passed with coverage report above"; else echo "Tests failed"; fi; exit $exit_code'], + pass_filenames = false, + always_run = true, + verbose = true, + }, +] + +# Sync dependencies from manifest.json to CI workflow (run last) +[[repos]] +repo = "local" +hooks = [ + { + id = "sync-dependencies", + name = "sync dependency versions between manifest.json and CI workflow", + entry = "python scripts/sync-dependencies.py", + language = "system", + pass_filenames = false, + always_run = true, + stages = ["pre-commit"], + verbose = true, + }, +] diff --git a/pyproject.toml b/pyproject.toml index 28cb9933..b3e559bc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,41 +1,41 @@ -[tool.poetry] +[project] name = "span" -# integration version is managed in the manifest.json for HA -# version = "0.0.0" +version = "2.0.7" description = "Span Panel Custom Integration for Home Assistant" -authors = ["SpanPanel"] -license = "MIT" +authors = [{name = "SpanPanel"}] +license = {text = "MIT"} readme = "README.md" -package-mode = false - -[tool.poetry.dependencies] -python = ">=3.13.2,<3.14" -homeassistant = "2025.9.3,<2026.0.0" # Pin to exact version for custom component compatibility -span-panel-api = {path = "../span-panel-api", develop = true} - -[tool.poetry.group.dev.dependencies] -# Type stubs and dev-only tools that don't conflict with HA runtime -homeassistant-stubs = "2025.9.3" -types-requests = "*" -types-PyYAML = "*" -mypy = "*" -pyright = "*" -ruff = "*" -bandit = "*" -pre-commit = "*" -voluptuous-stubs = "*" -python-direnv = "*" -prettier = "*" -radon = "*" -pylint = "*" # For import-outside-toplevel checks -# Testing dependencies -pytest = "^8.4.1" # Compatible with Python 3.13 -pytest-homeassistant-custom-component = "^0.13.280" # Latest version compatible with HA 2025.8.2 -isort = "*" - -[build-system] -requires = ["poetry-core"] -build-backend = "poetry.core.masonry.api" +requires-python = ">=3.14.3,<3.15" +dependencies = [ + "homeassistant==2026.5.4", + "span-panel-api==2.6.4", +] + +[dependency-groups] +dev = [ + "homeassistant-stubs==2026.5.4", + "types-requests", + "types-PyYAML", + "mypy==1.20.2", + "pyright==1.1.409", + "ruff==0.15.12", + "bandit[toml]==1.9.4", + "prek>=0.3.11", + "voluptuous-stubs", + "python-direnv", + "prettier", + "radon==6.0.1", + "pylint==4.0.5", + "pytest-homeassistant-custom-component>=0.13.333", + "isort", + "vulture>=2.14", +] + +[tool.uv] +package = false + +[tool.uv.sources] +span-panel-api = { path = "../span-panel-api", editable = true } [tool.jscpd] path = ["custom_components/span_panel", "./*.{html,md}"] @@ -111,7 +111,7 @@ exclude = [ ".git", ".pytest_cache", ".mypy_cache", - ".ruff_cache" + ".ruff_cache", "**/node_modules", "**/__pycache__", "**/.*" ] extraPaths = [ "./custom_components", @@ -119,7 +119,7 @@ extraPaths = [ "../ha-synthetic-sensors/src" ] pythonPlatform = "Darwin" -pythonVersion = "3.13" +pythonVersion = "3.14" typeCheckingMode = "basic" useLibraryCodeForTypes = true autoImportCompletions = true @@ -130,7 +130,7 @@ venv = ".venv" [tool.ruff] line-length = 100 -target-version = "py311" +target-version = "py313" [tool.ruff.lint] select = [ diff --git a/requirements_test.txt b/requirements_test.txt index f8e7257a..0733eb9d 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,5 +1,5 @@ -pytest>=7.0.0 -pytest-asyncio>=0.21.0 +pytest>=9.0.3 +pytest-asyncio>=1.3.0 setuptools>=65.7.0 -pytest-homeassistant-custom-component>=0.12.0 -homeassistant>=2025.5.0 +pytest-homeassistant-custom-component>=0.13.333 +homeassistant>=2026.5.4 diff --git a/scripts/build-frontend.sh b/scripts/build-frontend.sh new file mode 100755 index 00000000..af0f3290 --- /dev/null +++ b/scripts/build-frontend.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# Build the span-card frontend and copy dist files into the integration. +# +# Usage: +# ./scripts/build-frontend.sh /path/to/span-card +# SPAN_CARD_DIR=/path/to/span-card ./scripts/build-frontend.sh +# +# Set SPAN_CARD_DIR in your .envrc (direnv) so you don't have to pass it every time. +# +# After running, the updated JS files are in custom_components/span_panel/frontend/dist/. +# Stage and commit them normally — no submodule dance required. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +DEST_DIR="$REPO_ROOT/custom_components/span_panel/frontend/dist" + +# Resolve span-card repo location (argument takes precedence over env var) +CARD_DIR="${1:-${SPAN_CARD_DIR:-}}" + +if [ -z "$CARD_DIR" ]; then + echo "Error: span-card repo path not specified." + echo "" + echo "Either:" + echo " 1. Pass the path: ./scripts/build-frontend.sh /path/to/span-card" + echo " 2. Set env var: export SPAN_CARD_DIR=/path/to/span-card" + echo " 3. Add to .envrc: echo 'export SPAN_CARD_DIR=/path/to/span-card' >> .envrc" + exit 1 +fi + +CARD_DIR="$(cd "$CARD_DIR" 2>/dev/null && pwd)" || { + echo "Error: span-card repo not found at: $CARD_DIR" + exit 1 +} + +echo "span-card repo: $CARD_DIR" +echo "Destination: $DEST_DIR" + +# Install deps if needed +if [ ! -d "$CARD_DIR/node_modules" ]; then + echo "Installing dependencies..." + (cd "$CARD_DIR" && npm install) +fi + +# Build +echo "Building..." +(cd "$CARD_DIR" && npm run build) + +# Copy +mkdir -p "$DEST_DIR" +cp "$CARD_DIR/dist/span-panel.js" "$DEST_DIR/" +cp "$CARD_DIR/dist/span-panel-card.js" "$DEST_DIR/" + +echo "" +echo "Done. Files updated:" +ls -la "$DEST_DIR" +echo "" +echo "Next: git add custom_components/span_panel/frontend/dist/ && git commit" diff --git a/scripts/fix_energy_spike_statistics.py b/scripts/fix_energy_spike_statistics.py new file mode 100644 index 00000000..e8b5660f --- /dev/null +++ b/scripts/fix_energy_spike_statistics.py @@ -0,0 +1,141 @@ +#!/usr/bin/env python3 +""" +Fix false energy spikes in HA long-term statistics caused by span-panel-api +2.5.2 property clearing on lifecycle resets. + +Usage (run locally — HA must be stopped first): + 1. ha core stop + 2. scp root@homeassistant.home.arpa:/config/home-assistant_v2.db /tmp/ha.db + 3. python3 scripts/fix_energy_spike_statistics.py /tmp/ha.db # dry run + 4. python3 scripts/fix_energy_spike_statistics.py /tmp/ha.db --apply # apply + 5. scp /tmp/ha.db root@homeassistant.home.arpa:/config/home-assistant_v2.db + 6. ha core start +""" + +from __future__ import annotations + +import argparse +import sqlite3 + +# Minimum jump in state (Wh) per HOUR to qualify as a spike. +# 50 kWh/hr is far beyond any residential circuit. +# The threshold is pro-rated by the actual gap between rows to avoid +# flagging legitimate accumulation over multi-hour gaps in sparse data. +SPIKE_THRESHOLD_WH_PER_HOUR = 50_000.0 + +# Interval between rows in seconds for each statistics table. +# statistics = hourly (3600s), statistics_short_term = 5 min (300s). +TABLE_INTERVAL = { + "statistics": 3600, + "statistics_short_term": 300, +} + + +def find_spike( + cur: sqlite3.Cursor, + table: str, + metadata_id: int, +) -> tuple[float, float] | None: + """Find the first anomalous upward jump. Returns (start_ts, delta) or None.""" + cur.execute( + f"SELECT start_ts, state, sum FROM {table} " + "WHERE metadata_id = ? ORDER BY start_ts", + (metadata_id,), + ) + rows = cur.fetchall() + if len(rows) < 2: + return None + + for i in range(1, len(rows)): + prev_ts = rows[i - 1][0] + curr_ts = rows[i][0] + prev_state = rows[i - 1][1] + curr_state = rows[i][1] + if prev_state is None or curr_state is None: + continue + + delta = curr_state - prev_state + gap_seconds = curr_ts - prev_ts + gap_hours = max(gap_seconds / 3600.0, 1.0) + threshold = SPIKE_THRESHOLD_WH_PER_HOUR * gap_hours + + if delta >= threshold: + return (rows[i][0], delta) + + return None + + +def main() -> None: + parser = argparse.ArgumentParser(description="Fix energy spike statistics") + parser.add_argument("db_path", help="Path to home-assistant_v2.db") + parser.add_argument( + "--apply", action="store_true", help="Actually modify the database" + ) + args = parser.parse_args() + + mode = "APPLY" if args.apply else "DRY RUN" + print(f"=== Energy spike statistics fix ({mode}) ===\n") + + conn = sqlite3.connect(args.db_path) + cur = conn.cursor() + + cur.execute( + "SELECT id, statistic_id, unit_of_measurement " + "FROM statistics_meta " + "WHERE statistic_id LIKE '%span_panel%energy%' " + "ORDER BY statistic_id" + ) + sensors = cur.fetchall() + print(f"Found {len(sensors)} span_panel energy sensors\n") + + total_fixed = 0 + + for metadata_id, statistic_id, unit in sensors: + for table in ("statistics", "statistics_short_term"): + result = find_spike(cur, table, metadata_id) + if result is None: + continue + + spike_ts, delta = result + + # Count affected rows + cur.execute( + f"SELECT COUNT(*) FROM {table} " + "WHERE metadata_id = ? AND start_ts >= ?", + (metadata_id, spike_ts), + ) + count = cur.fetchone()[0] + + print( + f" {statistic_id}\n" + f" {table}: spike of +{delta:,.1f} {unit} at ts={spike_ts}\n" + f" {'WOULD ADJUST' if not args.apply else 'ADJUSTING'} " + f"{count} rows by -{delta:,.1f} {unit}" + ) + + if args.apply: + cur.execute( + f"UPDATE {table} " + "SET state = state - ?, sum = sum - ? " + "WHERE metadata_id = ? AND start_ts >= ?", + (delta, delta, metadata_id, spike_ts), + ) + print(f" -> {cur.rowcount} rows updated") + + total_fixed += count + print() + + if args.apply and total_fixed > 0: + conn.commit() + print(f"=== Committed corrections to {total_fixed} rows ===") + elif total_fixed > 0: + print(f"=== DRY RUN: {total_fixed} rows would be corrected ===") + print("Re-run with --apply to fix") + else: + print("=== No spikes detected ===") + + conn.close() + + +if __name__ == "__main__": + main() diff --git a/scripts/run-in-env.sh b/scripts/run-in-env.sh index f068f8cc..c326201a 100755 --- a/scripts/run-in-env.sh +++ b/scripts/run-in-env.sh @@ -1,7 +1,7 @@ #!/bin/bash # Ensures the script runs in the correct Python environment -# Handles pyenv/virtualenv/poetry activation if needed +# Handles pyenv/virtualenv/uv activation if needed # Find the project root directory (where .git is) PROJECT_ROOT=$(git rev-parse --show-toplevel) @@ -19,7 +19,6 @@ VENV_PATHS=( "venv" ".env" "env" - "$(poetry env info --path 2>/dev/null)" # Try to get Poetry's venv path ) for venv_path in "${VENV_PATHS[@]}"; do @@ -31,12 +30,11 @@ for venv_path in "${VENV_PATHS[@]}"; do fi done -# If poetry is available, ensure dependencies -if command -v poetry &> /dev/null && [ -f "pyproject.toml" ]; then - # Check if pylint is missing +# If uv is available, ensure dependencies +if command -v uv &> /dev/null && [ -f "pyproject.toml" ]; then if ! command -v pylint &> /dev/null; then - echo "pylint not found, installing dependencies with poetry..." - poetry install --only dev + echo "pylint not found, installing dependencies with uv..." + uv sync fi fi diff --git a/scripts/run_mypy.py b/scripts/run_mypy.py index 500fdc75..96efecba 100644 --- a/scripts/run_mypy.py +++ b/scripts/run_mypy.py @@ -9,7 +9,7 @@ def main() -> None: """Run mypy with Home Assistant core path configuration.""" # Run mypy with the provided arguments - result = subprocess.check_call(["poetry", "run", "mypy"] + sys.argv[1:]) # nosec B603 + result = subprocess.check_call(["uv", "run", "mypy"] + sys.argv[1:]) # nosec B603 sys.exit(result) diff --git a/scripts/sync-dependencies.py b/scripts/sync-dependencies.py index c639c519..f318e568 100755 --- a/scripts/sync-dependencies.py +++ b/scripts/sync-dependencies.py @@ -1,10 +1,10 @@ #!/usr/bin/env python3 -"""Synchronize dependency versions from manifest.json to CI workflow. +"""Synchronize dependency versions from manifest.json to pyproject.toml. This script reads the dependency versions from custom_components/span_panel/manifest.json -and updates the corresponding sed commands in .github/workflows/ci.yml to match. +and updates the corresponding dependencies in pyproject.toml to match. -Used as a pre-commit hook to ensure CI workflow stays in sync with manifest versions. +Used as a pre-commit hook to ensure pyproject.toml stays in sync with manifest versions. """ import json @@ -29,13 +29,13 @@ def get_manifest_versions(): for req in requirements: if req.startswith("span-panel-api"): - # Extract version from span-panel-api~=1.1.0 - match = re.search(r"span-panel-api[~=]+([0-9.]+)", req) + # Extract full specifier (e.g. ==2.3.0, >=2.0.0, ~=1.1.0) + match = re.search(r"span-panel-api([>~=!]+[0-9.]+)", req) if match: versions["span-panel-api"] = match.group(1) elif req.startswith("ha-synthetic-sensors"): - # Extract version from ha-synthetic-sensors~=1.0.8 - match = re.search(r"ha-synthetic-sensors[~=]+([0-9.]+)", req) + # Extract full specifier (e.g. >=1.0.8, ~=1.0.8) + match = re.search(r"ha-synthetic-sensors([>~=!]+[0-9.]+)", req) if match: versions["ha-synthetic-sensors"] = match.group(1) @@ -45,40 +45,39 @@ def get_manifest_versions(): return None -def update_ci_workflow(versions): - """Update the CI workflow with the specified versions.""" - ci_path = Path(".github/workflows/ci.yml") +def update_pyproject_dependencies(versions): + """Update pyproject.toml dependencies with manifest versions.""" + pyproject_path = Path("pyproject.toml") - if not ci_path.exists(): + if not pyproject_path.exists(): return False try: - with open(ci_path) as f: + with open(pyproject_path) as f: content = f.read() original_content = content - # Update span-panel-api version + # Update span-panel-api version in [project] dependencies if "span-panel-api" in versions: - span_version = versions["span-panel-api"] + span_spec = versions["span-panel-api"] content = re.sub( - r'span-panel-api = "\^[0-9.]+"', - f'span-panel-api = "^{span_version}"', + r'"span-panel-api[><=~!]+[0-9.]+"', + f'"span-panel-api{span_spec}"', content, ) - # Update ha-synthetic-sensors version + # Update ha-synthetic-sensors version in [project] dependencies if "ha-synthetic-sensors" in versions: - ha_version = versions["ha-synthetic-sensors"] + ha_spec = versions["ha-synthetic-sensors"] content = re.sub( - r'ha-synthetic-sensors = "\^[0-9.]+"', - f'ha-synthetic-sensors = "^{ha_version}"', + r'"ha-synthetic-sensors[><=~!]+[0-9.]+"', + f'"ha-synthetic-sensors{ha_spec}"', content, ) - # Check if changes were made if content != original_content: - with open(ci_path, "w") as f: + with open(pyproject_path, "w") as f: f.write(content) return True @@ -90,15 +89,11 @@ def update_ci_workflow(versions): def main(): """Main function.""" - - # Get versions from manifest versions = get_manifest_versions() if not versions: sys.exit(1) - - # Update CI workflow - changes_made = update_ci_workflow(versions) + changes_made = update_pyproject_dependencies(versions) if changes_made: sys.exit(1) # Exit with error to fail pre-commit diff --git a/scripts/sync-ha-deps.py b/scripts/sync-ha-deps.py index b258522e..cad1bd84 100644 --- a/scripts/sync-ha-deps.py +++ b/scripts/sync-ha-deps.py @@ -12,10 +12,10 @@ def get_ha_dependencies(): - """Get HomeAssistant's dependency pins from poetry show.""" + """Get HomeAssistant's dependency pins from uv pip show.""" try: result = subprocess.run( - ["poetry", "show", "homeassistant", "--format", "json"], + ["uv", "pip", "show", "homeassistant", "--format", "json"], capture_output=True, text=True, check=True, @@ -50,25 +50,26 @@ def update_pyproject_constraints(ha_deps): "pyyaml", } - # Update constraints for deps we care about - deps = ( - pyproject.setdefault("tool", {}).setdefault("poetry", {}).setdefault("dependencies", {}) - ) + deps = pyproject.get("project", {}).get("dependencies", []) updated = [] - for dep_name, version in ha_deps.items(): - if dep_name in exact_match_deps and dep_name in deps: - old_constraint = deps[dep_name] - new_constraint = f"^{version}" # Allow patch updates - if old_constraint != new_constraint: - deps[dep_name] = new_constraint - updated.append(f"{dep_name}: {old_constraint} -> {new_constraint}") + new_deps = [] + for dep_str in deps: + dep_name = dep_str.split("==")[0].split(">=")[0].split("<=")[0].split("~=")[0].split("!=")[0].strip() + if dep_name.lower() in exact_match_deps and dep_name.lower() in {d.lower() for d in ha_deps}: + version = ha_deps.get(dep_name) or ha_deps.get(dep_name.lower()) + if version: + new_constraint = f"{dep_name}>={version}" + if dep_str != new_constraint: + updated.append(f"{dep_name}: {dep_str} -> {new_constraint}") + new_deps.append(new_constraint) + continue + new_deps.append(dep_str) if updated: + pyproject["project"]["dependencies"] = new_deps with open(pyproject_path, "w") as f: toml.dump(pyproject, f) - for _update in updated: - pass return True else: return False diff --git a/scripts/sync_translations.py b/scripts/sync_translations.py new file mode 100644 index 00000000..0423cb1f --- /dev/null +++ b/scripts/sync_translations.py @@ -0,0 +1,133 @@ +"""Sync and validate translation files against strings.json. + +Generates translations/en.json from strings.json and validates that all +other translation files are complete (no missing keys) and contain no +orphaned keys that don't exist in strings.json. +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +COMPONENT_DIR = Path(__file__).resolve().parent.parent / "custom_components" / "span_panel" +STRINGS_PATH = COMPONENT_DIR / "strings.json" +TRANSLATIONS_DIR = COMPONENT_DIR / "translations" +EN_PATH = TRANSLATIONS_DIR / "en.json" + + +def collect_leaf_keys(obj: dict | str, prefix: str = "") -> set[str]: + """Recursively collect dot-delimited paths to leaf (non-dict) values.""" + keys: set[str] = set() + if isinstance(obj, dict): + for key, value in obj.items(): + full = f"{prefix}.{key}" if prefix else key + if isinstance(value, dict): + keys.update(collect_leaf_keys(value, full)) + else: + keys.add(full) + return keys + + +def collect_all_keys(obj: dict | str, prefix: str = "") -> set[str]: + """Recursively collect all dot-delimited key paths from a nested dict.""" + keys: set[str] = set() + if isinstance(obj, dict): + for key, value in obj.items(): + full = f"{prefix}.{key}" if prefix else key + keys.add(full) + keys.update(collect_all_keys(value, full)) + return keys + + +def find_orphaned_keys(source_keys: set[str], translation: dict) -> list[str]: + """Return keys present in the translation but absent from the source.""" + translation_keys = collect_all_keys(translation) + return sorted(translation_keys - source_keys) + + +def find_missing_keys(source_leaf_keys: set[str], translation: dict) -> list[str]: + """Return leaf keys present in the source but absent from the translation.""" + translation_leaf_keys = collect_leaf_keys(translation) + return sorted(source_leaf_keys - translation_leaf_keys) + + +def sync_en(source: dict) -> bool: + """Write strings.json content to translations/en.json. Return True if changed.""" + TRANSLATIONS_DIR.mkdir(parents=True, exist_ok=True) + new_content = json.dumps(source, indent=2, ensure_ascii=False) + "\n" + + if EN_PATH.exists(): + existing = EN_PATH.read_text(encoding="utf-8") + if existing == new_content: + return False + + EN_PATH.write_text(new_content, encoding="utf-8") + return True + + +def validate_translations( + source_all_keys: set[str], source_leaf_keys: set[str] +) -> list[str]: + """Validate all non-en translation files. Return list of error messages.""" + errors: list[str] = [] + + for lang_file in sorted(TRANSLATIONS_DIR.glob("*.json")): + if lang_file.name == "en.json": + continue + + lang = lang_file.stem + try: + translation = json.loads(lang_file.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + errors.append(f"{lang}: invalid JSON — {exc}") + continue + + orphaned = find_orphaned_keys(source_all_keys, translation) + if orphaned: + errors.append( + f"{lang}: {len(orphaned)} orphaned key(s) not in strings.json:\n" + + "\n".join(f" - {k}" for k in orphaned) + ) + + missing = find_missing_keys(source_leaf_keys, translation) + if missing: + errors.append( + f"{lang}: {len(missing)} missing key(s) from strings.json:\n" + + "\n".join(f" - {k}" for k in missing) + ) + + return errors + + +def main() -> int: + if not STRINGS_PATH.exists(): + print(f"ERROR: {STRINGS_PATH} not found", file=sys.stderr) + return 1 + + source = json.loads(STRINGS_PATH.read_text(encoding="utf-8")) + source_all_keys = collect_all_keys(source) + source_leaf_keys = collect_leaf_keys(source) + + changed = sync_en(source) + if changed: + try: + display_path = EN_PATH.relative_to(Path.cwd()) + except ValueError: + display_path = EN_PATH + print(f"Updated {display_path}") + + errors = validate_translations(source_all_keys, source_leaf_keys) + if errors: + print("Translation validation failed:", file=sys.stderr) + for error in errors: + print(f" {error}", file=sys.stderr) + return 1 + + print("Translations OK") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/setup-hooks.sh b/setup-hooks.sh index 64e203af..589ee2fb 100755 --- a/setup-hooks.sh +++ b/setup-hooks.sh @@ -1,19 +1,14 @@ #!/bin/bash -# Ensure dependencies are installed first -if [[ ! -f ".deps-installed" ]] || [[ "pyproject.toml" -nt ".deps-installed" ]]; then - echo "Installing/updating dependencies..." - - poetry install --with dev - - if [[ $? -ne 0 ]]; then - echo "Failed to install dependencies. Please check the output above." - exit 1 - fi - touch .deps-installed +# Ensure dependencies are up to date (uv sync is fast and idempotent) +uv sync +if [[ $? -ne 0 ]]; then + echo "Failed to install dependencies. Please check the output above." + exit 1 fi -# Install pre-commit hooks -poetry run pre-commit install - -echo "Git hooks installed successfully!" +# Install pre-commit hooks (only if not already installed) +if [[ ! -f ".git/hooks/pre-commit" ]]; then + prek install + echo "Git hooks installed successfully!" +fi diff --git a/tests/conftest.py b/tests/conftest.py index bf8f1513..9499daaa 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,82 +1,41 @@ """Configure test framework.""" import logging -import os from pathlib import Path import sys import types from unittest.mock import AsyncMock, MagicMock, patch -# Synthetic sensors package removed - no longer needed import pytest -from tests.test_factories.span_panel_simulation_factory import SpanPanelSimulationFactory +# The real span_panel_api library is used directly (no sys.modules mocking). +# Individual tests mock SpanMqttClient as needed. -# sys.path.insert(0, str(Path(__file__).parent.parent)) # Removed - using pytest pythonpath instead +_PROJECT_ROOT = str(Path(__file__).resolve().parent.parent) +_CC_DIR = str(Path(_PROJECT_ROOT) / "custom_components") -# Mock span_panel_api before importing custom_components -# Create mock modules for span_panel_api -span_panel_api_mock = MagicMock() -span_panel_api_exceptions_mock = MagicMock() -# Create proper mock exception classes that maintain distinct types -class MockSpanPanelError(Exception): - """Base mock exception.""" +def _ensure_span_panel_importable() -> None: + """Ensure custom_components.span_panel can be imported. - pass + The HA test plugin initialises the ``custom_components`` namespace package + from a temporary config directory. When tests that don't use the ``hass`` + fixture run in isolation, our project's ``custom_components`` directory is + never added to the namespace path. This helper repairs that at call time. + """ + import importlib + if _PROJECT_ROOT not in sys.path: + sys.path.insert(0, _PROJECT_ROOT) -class MockSpanPanelAuthError(MockSpanPanelError): - """Mock auth error.""" - - pass - - -class MockSpanPanelConnectionError(MockSpanPanelError): - """Mock connection error.""" - - pass - - -class MockSpanPanelTimeoutError(MockSpanPanelError): - """Mock timeout error.""" - - pass - - -class MockSpanPanelRetriableError(MockSpanPanelError): - """Mock retriable error.""" - - pass - - -class MockSpanPanelServerError(MockSpanPanelError): - """Mock server error.""" - - pass - - -class MockSpanPanelAPIError(MockSpanPanelError): - """Mock API error.""" - - pass - - -# Add exception classes that are used -span_panel_api_exceptions_mock.SpanPanelAuthError = MockSpanPanelAuthError -span_panel_api_exceptions_mock.SpanPanelConnectionError = MockSpanPanelConnectionError -span_panel_api_exceptions_mock.SpanPanelTimeoutError = MockSpanPanelTimeoutError -span_panel_api_exceptions_mock.SpanPanelRetriableError = MockSpanPanelRetriableError -span_panel_api_exceptions_mock.SpanPanelServerError = MockSpanPanelServerError -span_panel_api_exceptions_mock.SpanPanelAPIError = MockSpanPanelAPIError - -# Only mock span_panel_api if not using real simulation mode -if os.environ.get('SPAN_USE_REAL_SIMULATION', '').lower() not in ('1', 'true', 'yes'): - sys.modules["span_panel_api"] = span_panel_api_mock - sys.modules["span_panel_api.exceptions"] = span_panel_api_exceptions_mock - -# This import is required for patching even though it's not directly referenced -# import custom_components.span_panel # noqa: F401 # pylint: disable=unused-import # Moved to fixture + # If custom_components is already cached in sys.modules (pointing to the + # HA temp config dir), extend its __path__ to include our directory. + if "custom_components" in sys.modules: + cc_mod = sys.modules["custom_components"] + if hasattr(cc_mod, "__path__") and _CC_DIR not in list(cc_mod.__path__): + cc_mod.__path__.append(_CC_DIR) # type: ignore[union-attr] + else: + importlib.import_module("custom_components") @pytest.fixture(autouse=True) @@ -88,6 +47,7 @@ def auto_enable_custom_integrations(enable_custom_integrations): @pytest.fixture(autouse=True) def ensure_custom_components_imported(): """Ensure custom_components module is imported before tests run.""" + _ensure_span_panel_importable() import custom_components.span_panel # noqa: F401 # pylint: disable=unused-import yield @@ -102,85 +62,20 @@ def patch_dispatcher_send_for_teardown(): @pytest.fixture(autouse=True) def reset_static_state(): """Reset static state before each test to prevent pollution.""" - # Reset before test runs - # LEGACY TEST IMPORT - these modules have been removed and replaced by template-based system - # SpanSensorManager has been removed - no static state to reset for template-based system - - # Also clean up YAML files that might persist between tests - import os - from pathlib import Path - - # Clean up YAML files in both possible locations - yaml_locations = [ - # Current working directory - Path.cwd() / "custom_components" / "span_panel", - # pytest testing directory (absolute path) - Path(os.getcwd()) - / ".venv/lib/python3.13/site-packages/pytest_homeassistant_custom_component/testing_config/custom_components/span_panel", - ] - - yaml_filenames = ["span_sensors.yaml", "solar_synthetic_sensors.yaml"] - - for location in yaml_locations: - for filename in yaml_filenames: - yaml_file = location / filename - if yaml_file.exists(): - print(f"DEBUG: Cleaning up YAML file: {yaml_file}") - yaml_file.unlink() - print(f"DEBUG: Successfully removed: {yaml_file}") - - # LEGACY: SyntheticConfigManager has been removed - no singleton cache to clear for template-based system - - # Synthetic sensors package removed - no longer needed - # Reset ha-synthetic-sensors package state if it exists - # try: - # import ha_synthetic_sensors - # - # # Clear any internal state that might persist - # if hasattr(ha_synthetic_sensors, "_global_sensor_managers"): - # ha_synthetic_sensors._global_sensor_managers.clear() - # if hasattr(ha_synthetic_sensors, "_registered_integrations"): - # ha_synthetic_sensors._registered_integrations.clear() - # except (ImportError, AttributeError): - # pass - yield @pytest.fixture(autouse=True) def configure_ha_synthetic_logging(): - """Configure logging for ha-synthetic-sensors package.""" - import sys - - # Be aggressive: remove all existing handlers and set up our own + """Configure logging for tests.""" root_logger = logging.getLogger() for handler in root_logger.handlers[:]: root_logger.removeHandler(handler) - # Add a new handler to stream to stdout handler = logging.StreamHandler(sys.stdout) handler.setFormatter(logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")) root_logger.addHandler(handler) - # Synthetic sensors package removed - no longer needed - # Set debug level for all ha-synthetic-sensors loggers - # for logger_name in [ - # "ha_synthetic_sensors", - # "ha_synthetic_sensors.sensor_manager", - # "ha_synthetic_sensors.config_manager", - # "ha_synthetic_sensors.name_resolver", - # "ha_synthetic_sensors.evaluator", - # "ha_synthetic_sensors.integration", - # "ha_synthetic_sensors.collection_resolver", - # "ha_synthetic_sensors.dependency_parser", - # "ha_synthetic_sensors.entity_factory", - # "ha_synthetic_sensors.service_layer", - # "ha_synthetic_sensors.variable_resolver", - # ]: - # logger = logging.getLogger(logger_name) - # logger.setLevel(logging.DEBUG) - # logger.propagate = False # Don't propagate to avoid double logging - yield @@ -197,171 +92,6 @@ def patch_frontend_and_panel_custom(): yield -# Synthetic sensors package removed - no longer needed -# @pytest.fixture(autouse=True) -# def force_ha_synthetic_sensors_logging(): -# import sys -# -# logger_names = [ -# "ha_synthetic_sensors", -# "ha_synthetic_sensors.sensor_manager", -# "ha_synthetic_sensors.config_manager", -# "ha_synthetic_sensors.name_resolver", -# "ha_synthetic_sensors.evaluator", -# "ha_synthetic_sensors.integration", -# "ha_synthetic_sensors.collection_resolver", -# "ha_synthetic_sensors.dependency_parser", -# "ha_synthetic_sensors.entity_factory", -# "ha_synthetic_sensors.service_layer", -# "ha_synthetic_sensors.variable_resolver", -# ] -# for logger_name in logger_names: -# logger = logging.getLogger(logger_name) -# logger.setLevel(logging.DEBUG) -# logger.propagate = True -# # Remove all handlers to avoid duplicate logs -# for handler in logger.handlers[:]: -# logger.removeHandler(handler) -# handler = logging.StreamHandler(sys.stdout) -# handler.setLevel(logging.DEBUG) -# handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s %(message)s")) -# logger.addHandler(handler) -# yield - - -# Synthetic sensors package removed - no longer needed -# @pytest.fixture -# def mock_ha_storage(): -# """Mock Home Assistant storage system for use with ha-synthetic-sensors package. -# -# This creates a properly mocked storage system that the ha-synthetic-sensors -# package can use for its StorageManager operations. The storage persists -# across operations within a single test but is reset between tests. -# """ -# storage_data = {} - -# class MockStore: -# def __init__(self, hass, version: int, key: str, *, encoder=None, decoder=None): -# self.hass = hass -# self.version = version -# self.key = key -# self.encoder = encoder -# self.decoder = decoder -# self._data = storage_data.get(key, {}) -# -# async def async_load(self): -# """Load data from mock storage.""" -# return self._data.copy() if self._data else None -# -# async def async_save(self, data): -# """Save data to mock storage.""" -# storage_data[self.key] = data.copy() if data else {} -# self._data = data.copy() if data else {} -# -# async def async_remove(self): -# """Remove data from mock storage.""" -# if self.key in storage_data: -# del storage_data[self.key] -# self._data = {} -# -# with patch("homeassistant.helpers.storage.Store", MockStore): -# yield storage_data - - -# Synthetic sensors package removed - no longer needed -# @pytest.fixture -# async def synthetic_storage_manager(hass): -# """Create a synthetic sensors storage manager for testing.""" -# storage_manager = StorageManager(hass, "test_synthetic_sensors") -# await storage_manager.async_load() -# return storage_manager - - -# Synthetic sensors package removed - no longer needed -# @pytest.fixture -# async def mock_synthetic_sensor_manager(hass, synthetic_storage_manager): -# """Create a synthetic sensor manager with mocked storage for testing.""" -# # Mock async_add_entities since we're not actually adding entities in tests -# mock_add_entities = AsyncMock() -# -# # Create a basic mock config entry for testing -# from homeassistant.config_entries import ConfigEntry -# from unittest.mock import Mock -# -# mock_config_entry = Mock(spec=ConfigEntry) -# mock_config_entry.entry_id = "test_span_panel" -# mock_config_entry.domain = "span_panel" -# mock_config_entry.title = "Test SPAN Panel" -# mock_config_entry.data = {"host": "192.168.1.100"} -# mock_config_entry.options = {} -# -# # Create a sensor set first with some basic sensors -# sensor_set_id = f"{mock_config_entry.entry_id}_sensors" -# device_identifier = "test_device_123" -# -# # Create sensor set if it doesn't exist -# if not synthetic_storage_manager.sensor_set_exists(sensor_set_id): -# await synthetic_storage_manager.async_create_sensor_set( -# sensor_set_id=sensor_set_id, -# device_identifier=device_identifier, -# name="Test SPAN Panel Sensors" -# ) -# -# # Get the sensor set and add some basic test sensors -# sensor_set = synthetic_storage_manager.get_sensor_set(sensor_set_id) -# -# # Create a minimal test sensor configuration -# test_sensor_yaml = f""" -# version: "1.0" -# global_settings: -# device_identifier: "{device_identifier}" -# sensors: -# test_power_sensor: -# name: "Test Power Sensor" -# entity_id: "sensor.test_power" -# formula: "state" -# metadata: -# unit_of_measurement: "W" -# device_class: "power" -# """ -# -# # Import the test configuration -# await sensor_set.async_import_yaml(test_sensor_yaml) - -# # Create data provider that returns test values -# def data_provider_callback(entity_id: str): -# """Test data provider that returns mock values.""" -# # Return mock values for common test entity IDs -# if "power" in entity_id: -# return {"value": 1500.0, "exists": True} -# elif "energy" in entity_id: -# return {"value": 10000.0, "exists": True} -# elif "voltage" in entity_id: -# return {"value": 240.0, "exists": True} -# else: -# return {"value": None, "exists": False} -# -# # Set up synthetic sensors with the convenience function -# sensor_manager = await async_setup_synthetic_sensors( -# hass=hass, -# config_entry=mock_config_entry, -# async_add_entities=mock_add_entities, -# storage_manager=synthetic_storage_manager, -# sensor_set_id=sensor_set_id, # Use the created sensor set -# data_provider_callback=data_provider_callback, -# ) -# -# return sensor_manager - - -@pytest.fixture -async def baseline_serial_number(): - """Fixture to provide the serial number from the baseline YAML (friendly_names.yaml).""" - fixtures_dir = os.path.join(os.path.dirname(__file__), "fixtures") - baseline_path = os.path.join(fixtures_dir, "friendly_names.yaml") - return await SpanPanelSimulationFactory.extract_serial_number_from_yaml(baseline_path) - - @pytest.fixture def async_add_entities(): """Mock async_add_entities callback for testing.""" diff --git a/tests/docs/simulation_test_documentation.md b/tests/docs/simulation_test_documentation.md deleted file mode 100644 index c37b62cc..00000000 --- a/tests/docs/simulation_test_documentation.md +++ /dev/null @@ -1,188 +0,0 @@ -# SPAN Panel Simulation Testing Documentation - -## Overview - -The SPAN Panel integration includes a simulation testing mode that allows tests to run against real simulation data instead of mocked responses. This provides -more accurate testing by using the actual SpanPanelApi library with a built-in simulator. - -## Solution: CI-Friendly Simulation Tests - -### Problem Solved - -The main challenge was that simulation tests require `SPAN_USE_REAL_SIMULATION=1` to be set **before** the test module imports, but this made it difficult to -integrate into CI where you want regular `pytest` to work without environment variables. - -### Solution: Module-Level Skip - -Simulation tests now automatically skip when the environment variable isn't set: - -```python -import os -import pytest - -# Skip this test if SPAN_USE_REAL_SIMULATION is not set externally -if not os.environ.get('SPAN_USE_REAL_SIMULATION', '').lower() in ('1', 'true', 'yes'): - pytest.skip("Simulation tests require SPAN_USE_REAL_SIMULATION=1", allow_module_level=True) - -os.environ['SPAN_USE_REAL_SIMULATION'] = '1' -``` - -## How It Works - -### Regular Test Runs (Default) - -```bash -# Regular pytest - simulation tests are automatically skipped -pytest tests/ -pytest tests/test_solar_configuration_with_simulator.py # Shows "SKIPPED" -``` - -### Simulation Test Runs - -```bash -# Run simulation tests with environment variable -SPAN_USE_REAL_SIMULATION=1 pytest tests/test_solar_configuration_with_simulator.py -v - -# Clean output (reduced YAML noise) -SPAN_USE_REAL_SIMULATION=1 python -m pytest tests/test_solar_configuration_with_simulator.py::test_solar_configuration_with_simulator_friendly_names -v -``` - -### Mock vs Simulation Architecture - -#### Regular Tests (Default) - -- `tests/conftest.py` checks for `SPAN_USE_REAL_SIMULATION` environment variable -- If not set, installs mock modules for `span_panel_api` and `span_panel_api.exceptions` -- All API calls are mocked with predefined responses -- Fast execution but limited real-world accuracy - -#### Simulation Tests (With Environment Variable) - -- Environment variable set before module imports -- `tests/conftest.py` sees the variable and skips mock installation -- Real `span_panel_api` library is imported and used -- Integration connects to built-in simulator with realistic data - -## Reduced Logging Output - -The simulation test includes automatic logging configuration to reduce YAML and other verbose output: - -```python -# Configure logging to reduce noise BEFORE other imports -import logging -logging.getLogger("homeassistant.core").setLevel(logging.WARNING) -logging.getLogger("homeassistant.loader").setLevel(logging.WARNING) -logging.getLogger("homeassistant.setup").setLevel(logging.WARNING) -logging.getLogger("homeassistant.components").setLevel(logging.WARNING) -logging.getLogger("aiohttp").setLevel(logging.WARNING) -logging.getLogger("urllib3").setLevel(logging.WARNING) -logging.getLogger("yaml").setLevel(logging.WARNING) -logging.getLogger("homeassistant.helpers").setLevel(logging.WARNING) -logging.getLogger("homeassistant.config_entries").setLevel(logging.WARNING) - -# Keep our own logs visible for debugging -logging.getLogger("custom_components.span_panel").setLevel(logging.INFO) -logging.getLogger("ha_synthetic_sensors").setLevel(logging.INFO) -``` - -## CI Integration - -### GitHub Actions Example - -```yaml -# Regular tests - simulation tests are automatically skipped -- name: Run tests with coverage - run: poetry run pytest tests/ --cov=custom_components/span_panel --cov-report=xml - -# Optional: Run simulation tests separately -- name: Run simulation tests - env: - SPAN_USE_REAL_SIMULATION: 1 - run: poetry run pytest tests/test_solar_configuration_with_simulator.py -v -``` - -### Advantages for CI/CD - -1. **No Configuration Required**: Regular `pytest` just works -2. **Automatic Skipping**: Simulation tests skip gracefully without environment setup -3. **Optional Simulation**: Can run simulation tests in separate CI job if desired -4. **Clean Output**: Reduced logging noise for better CI readability -5. **Flexible**: Can run all tests together or separately - -## Solar Configuration Testing - -### Test Flow for Solar Sensors - -The solar configuration test follows this specific sequence: - -1. **Initial Setup**: Integration loads with native sensors only -2. **Options Change**: Trigger solar configuration to create solar sensors -3. **Reload**: Integration reloads to activate the new solar sensors -4. **Verification**: Solar sensors are verified in the entity registry - -### Why This Sequence is Necessary - -Solar sensors are created via the options flow (when users change settings in the UI), not during initial integration setup. The test simulates this by: - -1. Setting up the integration normally -2. Manually calling `handle_solar_options_change()` -3. Reloading the integration to activate the new sensors - -## Available Simulation Data - -The built-in simulator provides: - -- **Circuits**: Realistic circuit data including tabs 30 and 32 used for solar testing -- **Power Data**: Simulated power consumption and generation values -- **Device Info**: Realistic device metadata and status information -- **Unmapped Tabs**: Circuits not assigned to specific loads (essential for solar) - -## Running Simulation Tests - -### Local Development - -```bash -# Check test status without running -pytest tests/test_solar_configuration_with_simulator.py -v -# Output: SKIPPED [1] Simulation tests require SPAN_USE_REAL_SIMULATION=1 - -# Run simulation tests -SPAN_USE_REAL_SIMULATION=1 pytest tests/test_solar_configuration_with_simulator.py -v - -# Run specific test with clean output -SPAN_USE_REAL_SIMULATION=1 python -m pytest tests/test_solar_configuration_with_simulator.py::test_solar_configuration_with_simulator_friendly_names -v -``` - -### CI/CD Pipeline - -```bash -# Regular tests (simulation automatically skipped) -pytest tests/ --cov=custom_components/span_panel - -# Simulation tests (separate job) -SPAN_USE_REAL_SIMULATION=1 pytest tests/test_solar_configuration_with_simulator.py -v -``` - -## Alternative: Simulation Directory Approach - -We also created an alternative approach with a dedicated `tests/simulation/` directory that has its own `conftest.py`. This approach works but has some fixture -complexity. The module-level skip approach is simpler and more reliable. - -## Advantages of Current Solution - -1. **CI-Friendly**: Works without external environment variables -2. **Automatic**: Simulation tests skip gracefully when environment not set -3. **Clean Output**: Logging configuration reduces YAML noise -4. **Flexible**: Can run regular and simulation tests separately or together -5. **Simple**: Single test file approach is easier to maintain - -## Future Improvements - -The simulation testing mechanism provides a foundation for: - -- Testing edge cases with realistic data -- Validating complex solar configurations -- Testing error recovery scenarios -- Performance testing with large circuit counts - -This documentation should be updated as the simulation capabilities expand. diff --git a/tests/entity_summary.py b/tests/entity_summary.py deleted file mode 100644 index 3c738fb1..00000000 --- a/tests/entity_summary.py +++ /dev/null @@ -1,120 +0,0 @@ -"""Entity summary logging for Span Panel integration.""" - -from __future__ import annotations - -import logging - -from homeassistant.config_entries import ConfigEntry - -from custom_components.span_panel.coordinator import SpanPanelCoordinator -from custom_components.span_panel.options import BATTERY_ENABLE, INVERTER_ENABLE -from custom_components.span_panel.sensor import ( - CIRCUITS_SENSORS, - PANEL_DATA_STATUS_SENSORS, - PANEL_SENSORS, - STATUS_SENSORS, - STORAGE_BATTERY_SENSORS, -) - -_LOGGER = logging.getLogger(__name__) - - -def log_entity_summary(coordinator: SpanPanelCoordinator, config_entry: ConfigEntry) -> None: - """Log a comprehensive summary of entities that will be created. - - Uses debug level for detailed info, info level for basic summary. - - Args: - coordinator: The SpanPanelCoordinator instance - config_entry: The config entry with options - - """ - # Check if any logging is enabled for the main span_panel module - main_logger = logging.getLogger("custom_components.span_panel") - use_debug_level = main_logger.isEnabledFor(logging.DEBUG) - use_info_level = main_logger.isEnabledFor(logging.INFO) - - if not (use_debug_level or use_info_level): - return - - span_panel_data = coordinator.data - total_circuits = len(span_panel_data.circuits) - - # Count controllable circuits and identify non-controllable ones - controllable_circuits = sum( - 1 for circuit in span_panel_data.circuits.values() if circuit.is_user_controllable - ) - non_controllable_circuits = total_circuits - controllable_circuits - - # Identify non-controllable circuits for debugging - non_controllable_circuit_names = [ - f"{circuit.name} (ID: {circuit.circuit_id})" - for circuit in span_panel_data.circuits.values() - if not circuit.is_user_controllable - ] - - solar_enabled = config_entry.options.get(INVERTER_ENABLE, False) - battery_enabled = config_entry.options.get(BATTERY_ENABLE, False) - - # Circuit sensors are created for all circuits in the circuits collection - # Solar legs are NOT in this collection - they're accessed via raw branch data - circuit_sensors = total_circuits * len(CIRCUITS_SENSORS) - # Synthetic sensors package removed - no longer needed - synthetic_sensors = 0 # len(SYNTHETIC_SENSOR_TEMPLATES) if solar_enabled else 0 - panel_sensor_count = len(PANEL_SENSORS) + len(PANEL_DATA_STATUS_SENSORS) - status_sensors = len(STATUS_SENSORS) - battery_sensors = len(STORAGE_BATTERY_SENSORS) if battery_enabled else 0 - - total_sensors = ( - circuit_sensors + synthetic_sensors + panel_sensor_count + status_sensors + battery_sensors - ) - total_switches = controllable_circuits # Only controllable circuits get switches - total_selects = controllable_circuits # Only controllable circuits get selects - - # Choose logging level based on what's enabled - log_func = main_logger.debug if use_debug_level else main_logger.info - - log_func("=== SPAN PANEL ENTITY SUMMARY ===") - log_func( - "Total circuits: %d (%d controllable, %d non-controllable)", - total_circuits, - controllable_circuits, - non_controllable_circuits, - ) - - # Show non-controllable circuit info at both info and debug levels - if non_controllable_circuit_names: - # Force info level to ensure this shows up - main_logger.info( - "Non-controllable circuits: %s", - ", ".join(non_controllable_circuit_names), - ) - else: - main_logger.info("Non-controllable circuits: None") - - log_func( - "Circuit sensors: %d (%d circuits x %d sensors per circuit)", - circuit_sensors, - total_circuits, - len(CIRCUITS_SENSORS), - ) - if solar_enabled: - log_func("Synthetic sensors: %d (solar inverter)", synthetic_sensors) - else: - log_func("Synthetic sensors: 0 (solar disabled)") - log_func("Panel sensors: %d", panel_sensor_count) - log_func("Status sensors: %d", status_sensors) - if battery_enabled: - log_func("Battery sensors: %d", battery_sensors) - else: - log_func("Battery sensors: 0 (battery disabled)") - log_func("Circuit switches: %d (controllable circuits only)", total_switches) - log_func("Circuit selects: %d (controllable circuits only)", total_selects) - log_func( - "Total entities: %d sensors + %d switches + %d selects = %d", - total_sensors, - total_switches, - total_selects, - total_sensors + total_switches + total_selects, - ) - log_func("=== END ENTITY SUMMARY ===") diff --git a/tests/factories.py b/tests/factories.py index a12503c4..ad469691 100644 --- a/tests/factories.py +++ b/tests/factories.py @@ -1,361 +1,347 @@ -"""Factories for creating test objects with defaults for Span Panel integration.""" +"""Factories for creating test objects with defaults for Span Panel integration. + +All factories return library types (SpanPanelSnapshot, SpanCircuitSnapshot, +SpanBatterySnapshot) — frozen dataclasses from span_panel_api. +""" -import copy from typing import Any +from span_panel_api import ( + SpanBatterySnapshot, + SpanCircuitSnapshot, + SpanEvseSnapshot, + SpanPanelSnapshot, + SpanPVSnapshot, +) + from custom_components.span_panel.const import ( - CIRCUITS_BREAKER_POSITIONS, - CIRCUITS_ENERGY_CONSUMED, - CIRCUITS_ENERGY_PRODUCED, - CIRCUITS_IS_NEVER_BACKUP, - CIRCUITS_IS_SHEDDABLE, - CIRCUITS_IS_USER_CONTROLLABLE, - CIRCUITS_NAME, - CIRCUITS_POWER, - CIRCUITS_PRIORITY, - CIRCUITS_RELAY, - CURRENT_RUN_CONFIG, - DSM_GRID_DOWN, - DSM_GRID_STATE, - DSM_GRID_UP, DSM_OFF_GRID, DSM_ON_GRID, - DSM_STATE, - MAIN_RELAY_STATE, PANEL_BACKUP, PANEL_ON_GRID, - PANEL_POWER, - STORAGE_BATTERY_PERCENTAGE, - SYSTEM_CELLULAR_LINK, SYSTEM_DOOR_STATE_CLOSED, - SYSTEM_ETHERNET_LINK, - SYSTEM_WIFI_LINK, CircuitPriority, CircuitRelayState, ) -class SpanPanelCircuitFactory: - """Factory for creating span panel circuit test objects.""" - - _circuit_defaults = { - "id": "1", - CIRCUITS_NAME: "Test Circuit", - CIRCUITS_RELAY: CircuitRelayState.CLOSED.name, - CIRCUITS_POWER: 150.5, - CIRCUITS_ENERGY_CONSUMED: 1500.0, - CIRCUITS_ENERGY_PRODUCED: 0.0, - CIRCUITS_BREAKER_POSITIONS: [1], - CIRCUITS_PRIORITY: CircuitPriority.NICE_TO_HAVE.name, - CIRCUITS_IS_USER_CONTROLLABLE: True, - CIRCUITS_IS_SHEDDABLE: True, - CIRCUITS_IS_NEVER_BACKUP: False, - } +class SpanCircuitSnapshotFactory: + """Factory for creating SpanCircuitSnapshot test objects.""" @staticmethod - def create_circuit( + def create( circuit_id: str = "1", name: str = "Test Circuit", relay_state: str = CircuitRelayState.CLOSED.name, - instant_power: float = 150.5, - consumed_energy: float = 1500.0, - produced_energy: float = 0.0, + instant_power_w: float = 150.5, + consumed_energy_wh: float = 1500.0, + produced_energy_wh: float = 0.0, tabs: list[int] | None = None, - priority: str = CircuitPriority.NICE_TO_HAVE.name, + priority: str = CircuitPriority.SOC_THRESHOLD.name, is_user_controllable: bool = True, is_sheddable: bool = True, is_never_backup: bool = False, - **kwargs: Any, - ) -> dict[str, Any]: - """Create a circuit with optional defaults.""" - circuit = copy.deepcopy(SpanPanelCircuitFactory._circuit_defaults) - circuit["id"] = circuit_id - circuit[CIRCUITS_NAME] = name - circuit[CIRCUITS_RELAY] = relay_state - circuit[CIRCUITS_POWER] = instant_power - circuit[CIRCUITS_ENERGY_CONSUMED] = consumed_energy - circuit[CIRCUITS_ENERGY_PRODUCED] = produced_energy - circuit[CIRCUITS_BREAKER_POSITIONS] = tabs or [int(circuit_id)] - circuit[CIRCUITS_PRIORITY] = priority - circuit[CIRCUITS_IS_USER_CONTROLLABLE] = is_user_controllable - circuit[CIRCUITS_IS_SHEDDABLE] = is_sheddable - circuit[CIRCUITS_IS_NEVER_BACKUP] = is_never_backup - - # Add any additional overrides - for k, v in kwargs.items(): - circuit[k] = v - - return circuit + device_type: str = "circuit", + relative_position: str = "0", + is_240v: bool = False, + current_a: float | None = None, + breaker_rating_a: float | None = None, + always_on: bool = False, + relay_requester: str = "UNKNOWN", + energy_accum_update_time_s: int = 0, + instant_power_update_time_s: int = 0, + relay_state_target: str | None = None, + priority_target: str | None = None, + ) -> SpanCircuitSnapshot: + """Create a SpanCircuitSnapshot with reasonable defaults.""" + if tabs is None: + tabs = [int(circuit_id)] if circuit_id.isdigit() else [1] + return SpanCircuitSnapshot( + circuit_id=circuit_id, + name=name, + relay_state=relay_state, + instant_power_w=instant_power_w, + produced_energy_wh=produced_energy_wh, + consumed_energy_wh=consumed_energy_wh, + tabs=tabs, + priority=priority, + is_user_controllable=is_user_controllable, + is_sheddable=is_sheddable, + is_never_backup=is_never_backup, + device_type=device_type, + relative_position=relative_position, + is_240v=is_240v, + current_a=current_a, + breaker_rating_a=breaker_rating_a, + always_on=always_on, + relay_requester=relay_requester, + energy_accum_update_time_s=energy_accum_update_time_s, + instant_power_update_time_s=instant_power_update_time_s, + relay_state_target=relay_state_target, + priority_target=priority_target, + ) @staticmethod - def create_kitchen_outlet_circuit() -> dict[str, Any]: + def create_kitchen_outlet() -> SpanCircuitSnapshot: """Create a kitchen outlet circuit.""" - return SpanPanelCircuitFactory.create_circuit( + return SpanCircuitSnapshotFactory.create( circuit_id="1", name="Kitchen Outlets", - instant_power=245.3, - consumed_energy=2450.8, + instant_power_w=245.3, + consumed_energy_wh=2450.8, tabs=[1], ) @staticmethod - def create_living_room_lights_circuit() -> dict[str, Any]: + def create_living_room_lights() -> SpanCircuitSnapshot: """Create a living room lights circuit.""" - return SpanPanelCircuitFactory.create_circuit( + return SpanCircuitSnapshotFactory.create( circuit_id="2", name="Living Room Lights", - instant_power=85.2, - consumed_energy=850.5, + instant_power_w=85.2, + consumed_energy_wh=850.5, tabs=[2], ) @staticmethod - def create_solar_panel_circuit() -> dict[str, Any]: + def create_solar_panel() -> SpanCircuitSnapshot: """Create a solar panel circuit (producing energy).""" - return SpanPanelCircuitFactory.create_circuit( + return SpanCircuitSnapshotFactory.create( circuit_id="15", name="Solar Panels", - instant_power=-1200.0, # Negative indicates production - consumed_energy=0.0, - produced_energy=12000.5, + instant_power_w=-1200.0, + consumed_energy_wh=0.0, + produced_energy_wh=12000.5, tabs=[15], - priority=CircuitPriority.MUST_HAVE.name, + priority=CircuitPriority.NEVER.name, is_user_controllable=False, ) @staticmethod - def create_non_controllable_circuit() -> dict[str, Any]: + def create_non_controllable() -> SpanCircuitSnapshot: """Create a non-user-controllable circuit.""" - return SpanPanelCircuitFactory.create_circuit( + return SpanCircuitSnapshotFactory.create( circuit_id="30", name="Main Panel Feed", is_user_controllable=False, - priority=CircuitPriority.MUST_HAVE.name, + priority=CircuitPriority.NEVER.name, tabs=[30], ) -class SpanPanelDataFactory: - """Factory for creating span panel data test objects.""" - - _panel_defaults = { - PANEL_POWER: 2500.75, - CURRENT_RUN_CONFIG: PANEL_ON_GRID, - DSM_GRID_STATE: DSM_GRID_UP, - DSM_STATE: DSM_ON_GRID, - MAIN_RELAY_STATE: "CLOSED", - "instantGridPowerW": 2500.75, - "feedthroughPowerW": 0.0, - "gridSampleStartMs": 1640995200000, - "gridSampleEndMs": 1640995215000, - "mainMeterEnergy": { - "producedEnergyWh": 0.0, - "consumedEnergyWh": 2500.0, - }, - "feedthroughEnergy": { - "producedEnergyWh": 0.0, - "consumedEnergyWh": 0.0, - }, - } +class SpanEvseSnapshotFactory: + """Factory for creating SpanEvseSnapshot test objects.""" @staticmethod - def create_panel_data( - grid_power: float = 2500.75, - dsm_grid_state: str = DSM_GRID_UP, - dsm_state: str = DSM_ON_GRID, - main_relay_state: str = "CLOSED", - current_run_config: str = PANEL_ON_GRID, - **kwargs: Any, - ) -> dict[str, Any]: - """Create panel data with optional defaults.""" - panel_data: dict[str, Any] = copy.deepcopy(SpanPanelDataFactory._panel_defaults) - panel_data[PANEL_POWER] = grid_power - panel_data["instantGridPowerW"] = grid_power - panel_data[DSM_GRID_STATE] = dsm_grid_state - panel_data[DSM_STATE] = dsm_state - panel_data[MAIN_RELAY_STATE] = main_relay_state - panel_data[CURRENT_RUN_CONFIG] = current_run_config - - # Add any additional overrides - for k, v in kwargs.items(): - panel_data[k] = v - - return panel_data - - @staticmethod - def create_on_grid_panel_data() -> dict[str, Any]: - """Create panel data for on-grid operation.""" - return SpanPanelDataFactory.create_panel_data( - grid_power=1850.5, - dsm_grid_state=DSM_GRID_UP, - dsm_state=DSM_ON_GRID, - current_run_config=PANEL_ON_GRID, + def create( + node_id: str = "evse-0", + feed_circuit_id: str = "evse_circuit_1", + status: str = "CHARGING", + lock_state: str = "LOCKED", + advertised_current_a: float | None = 32.0, + vendor_name: str | None = "SPAN", + product_name: str | None = "SPAN Drive", + part_number: str | None = None, + serial_number: str | None = "SN-EVSE-001", + software_version: str | None = "2.1.0", + ) -> SpanEvseSnapshot: + """Create a SpanEvseSnapshot with reasonable defaults.""" + return SpanEvseSnapshot( + node_id=node_id, + feed_circuit_id=feed_circuit_id, + status=status, + lock_state=lock_state, + advertised_current_a=advertised_current_a, + vendor_name=vendor_name, + product_name=product_name, + part_number=part_number, + serial_number=serial_number, + software_version=software_version, ) @staticmethod - def create_backup_panel_data() -> dict[str, Any]: - """Create panel data for backup operation.""" - return SpanPanelDataFactory.create_panel_data( - grid_power=0.0, - dsm_grid_state=DSM_GRID_DOWN, - dsm_state=DSM_OFF_GRID, - current_run_config=PANEL_BACKUP, + def create_available() -> SpanEvseSnapshot: + """Create an EVSE snapshot in AVAILABLE state.""" + return SpanEvseSnapshotFactory.create( + status="AVAILABLE", + lock_state="UNLOCKED", ) -class SpanPanelStatusFactory: - """Factory for creating span panel status test objects.""" - - _status_defaults = { - "software": { - "firmwareVersion": "1.2.3", - "updateStatus": "IDLE", - "env": "prod", - }, - "system": { - "serial": "sp3-242424-001", - "manufacturer": "Span", - "model": "Panel", - "doorState": SYSTEM_DOOR_STATE_CLOSED, - "uptime": 86400000, # 24 hours in ms - }, - "network": { - SYSTEM_ETHERNET_LINK: True, - SYSTEM_WIFI_LINK: True, - SYSTEM_CELLULAR_LINK: False, - }, - } +class SpanBatterySnapshotFactory: + """Factory for creating SpanBatterySnapshot test objects.""" @staticmethod - def create_status( - serial_number: str = "Unspecified", - software_version: str = "1.2.3", - door_state: str = SYSTEM_DOOR_STATE_CLOSED, - ethernet_link: bool = True, - cellular_link: bool = False, - wifi_link: bool = True, - **kwargs: Any, - ) -> dict[str, Any]: - """Create status data with optional defaults.""" - status: dict[str, Any] = copy.deepcopy(SpanPanelStatusFactory._status_defaults) - status["system"]["serial"] = serial_number - status["serial_number"] = serial_number - status["software"]["firmwareVersion"] = software_version - status["system"]["doorState"] = door_state - status["network"][SYSTEM_ETHERNET_LINK] = ethernet_link - status["network"][SYSTEM_CELLULAR_LINK] = cellular_link - status["network"][SYSTEM_WIFI_LINK] = wifi_link - - # Add any additional overrides - for k, v in kwargs.items(): - if "." in k: - # Handle nested keys like "system.uptime" - parts = k.split(".") - current: dict[str, Any] = status - for part in parts[:-1]: - if part not in current: - current[part] = {} - current = current[part] # type: ignore[assignment] - current[parts[-1]] = v - else: - status[k] = v - - return status - + def create( + soe_percentage: float | None = 85.0, + soe_kwh: float | None = None, + vendor_name: str | None = None, + product_name: str | None = None, + serial_number: str | None = None, + software_version: str | None = None, + nameplate_capacity_kwh: float | None = None, + connected: bool | None = None, + ) -> SpanBatterySnapshot: + """Create a SpanBatterySnapshot with reasonable defaults.""" + return SpanBatterySnapshot( + soe_percentage=soe_percentage, + soe_kwh=soe_kwh, + vendor_name=vendor_name, + product_name=product_name, + serial_number=serial_number, + software_version=software_version, + nameplate_capacity_kwh=nameplate_capacity_kwh, + connected=connected, + ) -class SpanPanelStorageBatteryFactory: - """Factory for creating span panel storage battery test objects.""" - _battery_defaults = { - STORAGE_BATTERY_PERCENTAGE: 85, - } +class SpanPanelSnapshotFactory: + """Factory for creating SpanPanelSnapshot test objects.""" @staticmethod - def create_battery_data( - battery_percentage: int = 85, - **kwargs: Any, - ) -> dict[str, Any]: - """Create battery data with optional defaults.""" - battery: dict[str, Any] = copy.deepcopy(SpanPanelStorageBatteryFactory._battery_defaults) - battery[STORAGE_BATTERY_PERCENTAGE] = battery_percentage - - # Add any additional overrides - for k, v in kwargs.items(): - battery[k] = v - - return battery - - -class SpanPanelApiResponseFactory: - """Factory for creating complete API response objects for testing.""" + def create( + serial_number: str = "sp3-242424-001", + firmware_version: str = "1.2.3", + main_relay_state: str = "CLOSED", + instant_grid_power_w: float = 2500.75, + feedthrough_power_w: float = 0.0, + main_meter_energy_consumed_wh: float = 2500.0, + main_meter_energy_produced_wh: float = 0.0, + feedthrough_energy_consumed_wh: float = 0.0, + feedthrough_energy_produced_wh: float = 0.0, + dsm_state: str = DSM_ON_GRID, + current_run_config: str = PANEL_ON_GRID, + door_state: str = SYSTEM_DOOR_STATE_CLOSED, + proximity_proven: bool = False, + uptime_s: int = 86400, + eth0_link: bool = True, + wlan_link: bool = True, + wwan_link: bool = False, + circuits: dict[str, SpanCircuitSnapshot] | None = None, + battery: SpanBatterySnapshot | None = None, + dominant_power_source: str | None = None, + grid_state: str | None = None, + grid_islandable: bool | None = None, + l1_voltage: float | None = None, + l2_voltage: float | None = None, + main_breaker_rating_a: int | None = None, + wifi_ssid: str | None = None, + vendor_cloud: str | None = None, + power_flow_battery: float | None = None, + power_flow_site: float | None = None, + power_flow_pv: float | None = None, + panel_size: int = 32, + power_flow_grid: float | None = None, + upstream_l1_current_a: float | None = None, + upstream_l2_current_a: float | None = None, + downstream_l1_current_a: float | None = None, + downstream_l2_current_a: float | None = None, + pv: SpanPVSnapshot | None = None, + evse: dict[str, SpanEvseSnapshot] | None = None, + ) -> SpanPanelSnapshot: + """Create a SpanPanelSnapshot with reasonable defaults.""" + if circuits is None: + circuits = {} + if battery is None: + battery = SpanBatterySnapshot() + if pv is None: + pv = SpanPVSnapshot() + if evse is None: + evse = {} + return SpanPanelSnapshot( + serial_number=serial_number, + firmware_version=firmware_version, + main_relay_state=main_relay_state, + instant_grid_power_w=instant_grid_power_w, + feedthrough_power_w=feedthrough_power_w, + main_meter_energy_consumed_wh=main_meter_energy_consumed_wh, + main_meter_energy_produced_wh=main_meter_energy_produced_wh, + feedthrough_energy_consumed_wh=feedthrough_energy_consumed_wh, + feedthrough_energy_produced_wh=feedthrough_energy_produced_wh, + dsm_state=dsm_state, + current_run_config=current_run_config, + door_state=door_state, + proximity_proven=proximity_proven, + uptime_s=uptime_s, + eth0_link=eth0_link, + wlan_link=wlan_link, + wwan_link=wwan_link, + circuits=circuits, + battery=battery, + dominant_power_source=dominant_power_source, + grid_state=grid_state, + grid_islandable=grid_islandable, + l1_voltage=l1_voltage, + l2_voltage=l2_voltage, + main_breaker_rating_a=main_breaker_rating_a, + wifi_ssid=wifi_ssid, + vendor_cloud=vendor_cloud, + power_flow_battery=power_flow_battery, + power_flow_site=power_flow_site, + panel_size=panel_size, + power_flow_pv=power_flow_pv, + power_flow_grid=power_flow_grid, + upstream_l1_current_a=upstream_l1_current_a, + upstream_l2_current_a=upstream_l2_current_a, + downstream_l1_current_a=downstream_l1_current_a, + downstream_l2_current_a=downstream_l2_current_a, + pv=pv, + evse=evse, + ) @staticmethod - def create_complete_panel_response( - circuits: list[dict[str, Any]] | None = None, - panel_data: dict[str, Any] | None = None, - status_data: dict[str, Any] | None = None, - battery_data: dict[str, Any] | None = None, - serial_number: str | None = None, - ) -> dict[str, Any]: - """Create a complete panel response with all components.""" + def create_complete( + serial_number: str = "sp3-242424-001", + circuits: list[SpanCircuitSnapshot] | None = None, + battery: SpanBatterySnapshot | None = None, + **kwargs: Any, + ) -> SpanPanelSnapshot: + """Create a complete panel snapshot with default circuits and battery.""" if circuits is None: - circuits = [ - SpanPanelCircuitFactory.create_kitchen_outlet_circuit(), - SpanPanelCircuitFactory.create_living_room_lights_circuit(), - SpanPanelCircuitFactory.create_solar_panel_circuit(), + circuits_list = [ + SpanCircuitSnapshotFactory.create_kitchen_outlet(), + SpanCircuitSnapshotFactory.create_living_room_lights(), + SpanCircuitSnapshotFactory.create_solar_panel(), ] + else: + circuits_list = circuits - if panel_data is None: - panel_data = SpanPanelDataFactory.create_on_grid_panel_data() - - if status_data is None: - if serial_number is not None: - status_data = SpanPanelStatusFactory.create_status(serial_number=serial_number) - else: - status_data = SpanPanelStatusFactory.create_status() - - if battery_data is None: - battery_data = SpanPanelStorageBatteryFactory.create_battery_data() - - # Include circuit data as "branches" in panel data for solar calculations - # The SpanPanelData.from_dict method expects branches to be indexed starting from 0 - # Create a list of 32 branches (max circuits) with empty defaults and populate with actual circuits - branches = [] - for _ in range(32): # SPAN panels support up to 32 circuits - # Default empty branch - default_branch = { - "instantPowerW": 0.0, - "importedActiveEnergyWh": 0.0, - "exportedActiveEnergyWh": 0.0, - } - branches.append(default_branch) + circuits_dict = {c.circuit_id: c for c in circuits_list} - # Populate actual circuit data at the correct indices - for circuit in circuits: - circuit_id = int(circuit["id"]) - if 1 <= circuit_id <= 32: - branch_index = circuit_id - 1 # Convert to 0-based index - branches[branch_index] = { - "instantPowerW": circuit.get(CIRCUITS_POWER, 0.0), - "importedActiveEnergyWh": circuit.get(CIRCUITS_ENERGY_PRODUCED, 0.0), - "exportedActiveEnergyWh": circuit.get(CIRCUITS_ENERGY_CONSUMED, 0.0), - } + if battery is None: + battery = SpanBatterySnapshotFactory.create() - # Add branches to panel data - panel_data["branches"] = branches + return SpanPanelSnapshotFactory.create( + serial_number=serial_number, + circuits=circuits_dict, + battery=battery, + **kwargs, + ) - response = { - "circuits": {circuit["id"]: circuit for circuit in circuits}, - "panel": panel_data, - "status": status_data, - "battery": battery_data, - } + @staticmethod + def create_on_grid(serial_number: str = "sp3-242424-001") -> SpanPanelSnapshot: + """Create a panel snapshot in on-grid state.""" + return SpanPanelSnapshotFactory.create_complete( + serial_number=serial_number, + instant_grid_power_w=1850.5, + dsm_state=DSM_ON_GRID, + current_run_config=PANEL_ON_GRID, + ) - return response + @staticmethod + def create_backup(serial_number: str = "sp3-242424-001") -> SpanPanelSnapshot: + """Create a panel snapshot in backup state.""" + return SpanPanelSnapshotFactory.create_complete( + serial_number=serial_number, + instant_grid_power_w=0.0, + dsm_state=DSM_OFF_GRID, + current_run_config=PANEL_BACKUP, + ) @staticmethod - def create_minimal_panel_response() -> dict[str, Any]: - """Create a minimal panel response for basic testing.""" - return SpanPanelApiResponseFactory.create_complete_panel_response( - circuits=[SpanPanelCircuitFactory.create_kitchen_outlet_circuit()], + def create_minimal(serial_number: str = "sp3-242424-001") -> SpanPanelSnapshot: + """Create a minimal panel snapshot with a single circuit.""" + return SpanPanelSnapshotFactory.create_complete( + serial_number=serial_number, + circuits=[SpanCircuitSnapshotFactory.create_kitchen_outlet()], ) diff --git a/tests/fixtures/circuit_numbers.yaml b/tests/fixtures/circuit_numbers.yaml deleted file mode 100644 index 02bcb50d..00000000 --- a/tests/fixtures/circuit_numbers.yaml +++ /dev/null @@ -1,2356 +0,0 @@ -version: '1.0' -global_settings: - device_identifier: "sp3-simulation-001" - variables: - energy_grace_period_minutes: 15 - metadata: - attribution: Data from SPAN Panel -sensors: - "span_sp3-simulation-001_current_power": - name: Current Power - entity_id: sensor.current_power - formula: state - attributes: - voltage: 240 - amperage: - formula: state / 240 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_feed_through_power": - name: Feed Through Power - entity_id: sensor.feed_through_power - formula: state - attributes: - voltage: 240 - amperage: - formula: state / 240 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_main_meter_produced_energy": - name: Main Meter Produced Energy - entity_id: sensor.main_meter_produced_energy - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: panel - voltage: 240 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_main_meter_consumed_energy": - name: Main Meter Consumed Energy - entity_id: sensor.main_meter_consumed_energy - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: panel - voltage: 240 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_feed_through_produced_energy": - name: Feed Through Produced Energy - entity_id: sensor.feed_through_produced_energy - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: panel - voltage: 240 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_feed_through_consumed_energy": - name: Feed Through Consumed Energy - entity_id: sensor.feed_through_consumed_energy - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: panel - voltage: 240 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_master_bedroom_lights_power": - name: Master Bedroom Lights Power - entity_id: sensor.circuit_1_power - formula: state - attributes: - tabs: "tabs [1]" - voltage: 120 - amperage: - formula: state / 120 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_master_bedroom_lights_energy_produced": - name: Master Bedroom Lights Produced Energy - entity_id: sensor.circuit_1_energy_produced - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [1]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_master_bedroom_lights_energy_consumed": - name: Master Bedroom Lights Consumed Energy - entity_id: sensor.circuit_1_energy_consumed - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [1]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_living_room_lights_power": - name: Living Room Lights Power - entity_id: sensor.circuit_2_power - formula: state - attributes: - tabs: "tabs [2]" - voltage: 120 - amperage: - formula: state / 120 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_living_room_lights_energy_produced": - name: Living Room Lights Produced Energy - entity_id: sensor.circuit_2_energy_produced - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [2]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_living_room_lights_energy_consumed": - name: Living Room Lights Consumed Energy - entity_id: sensor.circuit_2_energy_consumed - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [2]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_kitchen_lights_power": - name: Kitchen Lights Power - entity_id: sensor.circuit_3_power - formula: state - attributes: - tabs: "tabs [3]" - voltage: 120 - amperage: - formula: state / 120 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_kitchen_lights_energy_produced": - name: Kitchen Lights Produced Energy - entity_id: sensor.circuit_3_energy_produced - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [3]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_kitchen_lights_energy_consumed": - name: Kitchen Lights Consumed Energy - entity_id: sensor.circuit_3_energy_consumed - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [3]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_bedroom_lights_power": - name: Bedroom Lights Power - entity_id: sensor.circuit_4_power - formula: state - attributes: - tabs: "tabs [4]" - voltage: 120 - amperage: - formula: state / 120 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_bedroom_lights_energy_produced": - name: Bedroom Lights Produced Energy - entity_id: sensor.circuit_4_energy_produced - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [4]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_bedroom_lights_energy_consumed": - name: Bedroom Lights Consumed Energy - entity_id: sensor.circuit_4_energy_consumed - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [4]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_bathroom_lights_power": - name: Bathroom Lights Power - entity_id: sensor.circuit_5_power - formula: state - attributes: - tabs: "tabs [5]" - voltage: 120 - amperage: - formula: state / 120 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_bathroom_lights_energy_produced": - name: Bathroom Lights Produced Energy - entity_id: sensor.circuit_5_energy_produced - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [5]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_bathroom_lights_energy_consumed": - name: Bathroom Lights Consumed Energy - entity_id: sensor.circuit_5_energy_consumed - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [5]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_exterior_lights_power": - name: Exterior Lights Power - entity_id: sensor.circuit_6_power - formula: state - attributes: - tabs: "tabs [6]" - voltage: 120 - amperage: - formula: state / 120 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_exterior_lights_energy_produced": - name: Exterior Lights Produced Energy - entity_id: sensor.circuit_6_energy_produced - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [6]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_exterior_lights_energy_consumed": - name: Exterior Lights Consumed Energy - entity_id: sensor.circuit_6_energy_consumed - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [6]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_master_bedroom_outlets_power": - name: Master Bedroom Outlets Power - entity_id: sensor.circuit_7_power - formula: state - attributes: - tabs: "tabs [7]" - voltage: 120 - amperage: - formula: state / 120 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_master_bedroom_outlets_energy_produced": - name: Master Bedroom Outlets Produced Energy - entity_id: sensor.circuit_7_energy_produced - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [7]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_master_bedroom_outlets_energy_consumed": - name: Master Bedroom Outlets Consumed Energy - entity_id: sensor.circuit_7_energy_consumed - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [7]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_living_room_outlets_power": - name: Living Room Outlets Power - entity_id: sensor.circuit_8_power - formula: state - attributes: - tabs: "tabs [8]" - voltage: 120 - amperage: - formula: state / 120 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_living_room_outlets_energy_produced": - name: Living Room Outlets Produced Energy - entity_id: sensor.circuit_8_energy_produced - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [8]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_living_room_outlets_energy_consumed": - name: Living Room Outlets Consumed Energy - entity_id: sensor.circuit_8_energy_consumed - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [8]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_kitchen_outlets_1_power": - name: Kitchen Outlets 1 Power - entity_id: sensor.circuit_9_power - formula: state - attributes: - tabs: "tabs [9]" - voltage: 120 - amperage: - formula: state / 120 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_kitchen_outlets_1_energy_produced": - name: Kitchen Outlets 1 Produced Energy - entity_id: sensor.circuit_9_energy_produced - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [9]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_kitchen_outlets_1_energy_consumed": - name: Kitchen Outlets 1 Consumed Energy - entity_id: sensor.circuit_9_energy_consumed - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [9]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_kitchen_outlets_2_power": - name: Kitchen Outlets 2 Power - entity_id: sensor.circuit_12_power - formula: state - attributes: - tabs: "tabs [12]" - voltage: 120 - amperage: - formula: state / 120 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_kitchen_outlets_2_energy_produced": - name: Kitchen Outlets 2 Produced Energy - entity_id: sensor.circuit_12_energy_produced - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [12]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_kitchen_outlets_2_energy_consumed": - name: Kitchen Outlets 2 Consumed Energy - entity_id: sensor.circuit_12_energy_consumed - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [12]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_office_outlets_power": - name: Office Outlets Power - entity_id: sensor.circuit_11_power - formula: state - attributes: - tabs: "tabs [11]" - voltage: 120 - amperage: - formula: state / 120 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_office_outlets_energy_produced": - name: Office Outlets Produced Energy - entity_id: sensor.circuit_11_energy_produced - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [11]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_office_outlets_energy_consumed": - name: Office Outlets Consumed Energy - entity_id: sensor.circuit_11_energy_consumed - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [11]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_garage_outlets_power": - name: Garage Outlets Power - entity_id: sensor.circuit_10_power - formula: state - attributes: - tabs: "tabs [10]" - voltage: 120 - amperage: - formula: state / 120 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_garage_outlets_energy_produced": - name: Garage Outlets Produced Energy - entity_id: sensor.circuit_10_energy_produced - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [10]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_garage_outlets_energy_consumed": - name: Garage Outlets Consumed Energy - entity_id: sensor.circuit_10_energy_consumed - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [10]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_laundry_outlets_power": - name: Laundry Room Outlets Power - entity_id: sensor.circuit_13_power - formula: state - attributes: - tabs: "tabs [13]" - voltage: 120 - amperage: - formula: state / 120 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_laundry_outlets_energy_produced": - name: Laundry Room Outlets Produced Energy - entity_id: sensor.circuit_13_energy_produced - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [13]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_laundry_outlets_energy_consumed": - name: Laundry Room Outlets Consumed Energy - entity_id: sensor.circuit_13_energy_consumed - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [13]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_guest_room_outlets_power": - name: Guest Room Outlets Power - entity_id: sensor.circuit_14_power - formula: state - attributes: - tabs: "tabs [14]" - voltage: 120 - amperage: - formula: state / 120 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_guest_room_outlets_energy_produced": - name: Guest Room Outlets Produced Energy - entity_id: sensor.circuit_14_energy_produced - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [14]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_guest_room_outlets_energy_consumed": - name: Guest Room Outlets Consumed Energy - entity_id: sensor.circuit_14_energy_consumed - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [14]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_refrigerator_power": - name: Refrigerator Power - entity_id: sensor.circuit_15_power - formula: state - attributes: - tabs: "tabs [15]" - voltage: 120 - amperage: - formula: state / 120 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_refrigerator_energy_produced": - name: Refrigerator Produced Energy - entity_id: sensor.circuit_15_energy_produced - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [15]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_refrigerator_energy_consumed": - name: Refrigerator Consumed Energy - entity_id: sensor.circuit_15_energy_consumed - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [15]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_dishwasher_power": - name: Dishwasher Power - entity_id: sensor.circuit_16_power - formula: state - attributes: - tabs: "tabs [16]" - voltage: 120 - amperage: - formula: state / 120 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_dishwasher_energy_produced": - name: Dishwasher Produced Energy - entity_id: sensor.circuit_16_energy_produced - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [16]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_dishwasher_energy_consumed": - name: Dishwasher Consumed Energy - entity_id: sensor.circuit_16_energy_consumed - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [16]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_washing_machine_power": - name: Washing Machine Power - entity_id: sensor.circuit_17_power - formula: state - attributes: - tabs: "tabs [17]" - voltage: 120 - amperage: - formula: state / 120 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_washing_machine_energy_produced": - name: Washing Machine Produced Energy - entity_id: sensor.circuit_17_energy_produced - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [17]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_washing_machine_energy_consumed": - name: Washing Machine Consumed Energy - entity_id: sensor.circuit_17_energy_consumed - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [17]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_dryer_power": - name: Electric Dryer Power - entity_id: sensor.circuit_18_20_power - formula: state - attributes: - tabs: "tabs [18:20]" - voltage: 240 - amperage: - formula: state / 240 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_dryer_energy_produced": - name: Electric Dryer Produced Energy - entity_id: sensor.circuit_18_20_energy_produced - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [18:20]" - voltage: 240 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_dryer_energy_consumed": - name: Electric Dryer Consumed Energy - entity_id: sensor.circuit_18_20_energy_consumed - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [18:20]" - voltage: 240 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_oven_power": - name: Electric Oven Power - entity_id: sensor.circuit_19_21_power - formula: state - attributes: - tabs: "tabs [19:21]" - voltage: 240 - amperage: - formula: state / 240 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_oven_energy_produced": - name: Electric Oven Produced Energy - entity_id: sensor.circuit_19_21_energy_produced - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [19:21]" - voltage: 240 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_oven_energy_consumed": - name: Electric Oven Consumed Energy - entity_id: sensor.circuit_19_21_energy_consumed - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [19:21]" - voltage: 240 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_microwave_power": - name: Microwave Power - entity_id: sensor.circuit_22_power - formula: state - attributes: - tabs: "tabs [22]" - voltage: 120 - amperage: - formula: state / 120 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_microwave_energy_produced": - name: Microwave Produced Energy - entity_id: sensor.circuit_22_energy_produced - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [22]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_microwave_energy_consumed": - name: Microwave Consumed Energy - entity_id: sensor.circuit_22_energy_consumed - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [22]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_main_hvac_power": - name: Main HVAC Unit Power - entity_id: sensor.circuit_23_25_power - formula: state - attributes: - tabs: "tabs [23:25]" - voltage: 240 - amperage: - formula: state / 240 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_main_hvac_energy_produced": - name: Main HVAC Unit Produced Energy - entity_id: sensor.circuit_23_25_energy_produced - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [23:25]" - voltage: 240 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_main_hvac_energy_consumed": - name: Main HVAC Unit Consumed Energy - entity_id: sensor.circuit_23_25_energy_consumed - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [23:25]" - voltage: 240 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_heat_pump_backup_power": - name: Heat Pump Backup Power - entity_id: sensor.circuit_24_26_power - formula: state - attributes: - tabs: "tabs [24:26]" - voltage: 240 - amperage: - formula: state / 240 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_heat_pump_backup_energy_produced": - name: Heat Pump Backup Produced Energy - entity_id: sensor.circuit_24_26_energy_produced - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [24:26]" - voltage: 240 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_heat_pump_backup_energy_consumed": - name: Heat Pump Backup Consumed Energy - entity_id: sensor.circuit_24_26_energy_consumed - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [24:26]" - voltage: 240 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_ev_charger_garage_power": - name: Garage EV Charger Power - entity_id: sensor.circuit_27_29_power - formula: state - attributes: - tabs: "tabs [27:29]" - voltage: 240 - amperage: - formula: state / 240 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_ev_charger_garage_energy_produced": - name: Garage EV Charger Produced Energy - entity_id: sensor.circuit_27_29_energy_produced - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [27:29]" - voltage: 240 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_ev_charger_garage_energy_consumed": - name: Garage EV Charger Consumed Energy - entity_id: sensor.circuit_27_29_energy_consumed - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [27:29]" - voltage: 240 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 diff --git a/tests/fixtures/expected_solar_circuit_numbers.yaml b/tests/fixtures/expected_solar_circuit_numbers.yaml deleted file mode 100644 index dfa8c365..00000000 --- a/tests/fixtures/expected_solar_circuit_numbers.yaml +++ /dev/null @@ -1,2439 +0,0 @@ -version: '1.0' -global_settings: - device_identifier: "sp3-simulation-001" - variables: - energy_grace_period_minutes: 15 - metadata: - attribution: Data from SPAN Panel -sensors: - "span_sp3-simulation-001_current_power": - name: Current Power - entity_id: sensor.current_power - formula: state - attributes: - voltage: 240 - amperage: - formula: state / 240 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_feed_through_power": - name: Feed Through Power - entity_id: sensor.feed_through_power - formula: state - attributes: - voltage: 240 - amperage: - formula: state / 240 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_main_meter_produced_energy": - name: Main Meter Produced Energy - entity_id: sensor.main_meter_produced_energy - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: panel - voltage: 240 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_main_meter_consumed_energy": - name: Main Meter Consumed Energy - entity_id: sensor.main_meter_consumed_energy - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: panel - voltage: 240 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_feed_through_produced_energy": - name: Feed Through Produced Energy - entity_id: sensor.feed_through_produced_energy - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: panel - voltage: 240 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_feed_through_consumed_energy": - name: Feed Through Consumed Energy - entity_id: sensor.feed_through_consumed_energy - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: panel - voltage: 240 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_master_bedroom_lights_power": - name: Master Bedroom Lights Power - entity_id: sensor.circuit_1_power - formula: state - attributes: - tabs: "tabs [1]" - voltage: 120 - amperage: - formula: state / 120 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_master_bedroom_lights_energy_produced": - name: Master Bedroom Lights Produced Energy - entity_id: sensor.circuit_1_energy_produced - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [1]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_master_bedroom_lights_energy_consumed": - name: Master Bedroom Lights Consumed Energy - entity_id: sensor.circuit_1_energy_consumed - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [1]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_living_room_lights_power": - name: Living Room Lights Power - entity_id: sensor.circuit_2_power - formula: state - attributes: - tabs: "tabs [2]" - voltage: 120 - amperage: - formula: state / 120 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_living_room_lights_energy_produced": - name: Living Room Lights Produced Energy - entity_id: sensor.circuit_2_energy_produced - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [2]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_living_room_lights_energy_consumed": - name: Living Room Lights Consumed Energy - entity_id: sensor.circuit_2_energy_consumed - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [2]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_kitchen_lights_power": - name: Kitchen Lights Power - entity_id: sensor.circuit_3_power - formula: state - attributes: - tabs: "tabs [3]" - voltage: 120 - amperage: - formula: state / 120 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_kitchen_lights_energy_produced": - name: Kitchen Lights Produced Energy - entity_id: sensor.circuit_3_energy_produced - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [3]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_kitchen_lights_energy_consumed": - name: Kitchen Lights Consumed Energy - entity_id: sensor.circuit_3_energy_consumed - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [3]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_bedroom_lights_power": - name: Bedroom Lights Power - entity_id: sensor.circuit_4_power - formula: state - attributes: - tabs: "tabs [4]" - voltage: 120 - amperage: - formula: state / 120 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_bedroom_lights_energy_produced": - name: Bedroom Lights Produced Energy - entity_id: sensor.circuit_4_energy_produced - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [4]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_bedroom_lights_energy_consumed": - name: Bedroom Lights Consumed Energy - entity_id: sensor.circuit_4_energy_consumed - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [4]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_bathroom_lights_power": - name: Bathroom Lights Power - entity_id: sensor.circuit_5_power - formula: state - attributes: - tabs: "tabs [5]" - voltage: 120 - amperage: - formula: state / 120 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_bathroom_lights_energy_produced": - name: Bathroom Lights Produced Energy - entity_id: sensor.circuit_5_energy_produced - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [5]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_bathroom_lights_energy_consumed": - name: Bathroom Lights Consumed Energy - entity_id: sensor.circuit_5_energy_consumed - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [5]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_exterior_lights_power": - name: Exterior Lights Power - entity_id: sensor.circuit_6_power - formula: state - attributes: - tabs: "tabs [6]" - voltage: 120 - amperage: - formula: state / 120 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_exterior_lights_energy_produced": - name: Exterior Lights Produced Energy - entity_id: sensor.circuit_6_energy_produced - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [6]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_exterior_lights_energy_consumed": - name: Exterior Lights Consumed Energy - entity_id: sensor.circuit_6_energy_consumed - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [6]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_master_bedroom_outlets_power": - name: Master Bedroom Outlets Power - entity_id: sensor.circuit_7_power - formula: state - attributes: - tabs: "tabs [7]" - voltage: 120 - amperage: - formula: state / 120 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_master_bedroom_outlets_energy_produced": - name: Master Bedroom Outlets Produced Energy - entity_id: sensor.circuit_7_energy_produced - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [7]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_master_bedroom_outlets_energy_consumed": - name: Master Bedroom Outlets Consumed Energy - entity_id: sensor.circuit_7_energy_consumed - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [7]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_living_room_outlets_power": - name: Living Room Outlets Power - entity_id: sensor.circuit_8_power - formula: state - attributes: - tabs: "tabs [8]" - voltage: 120 - amperage: - formula: state / 120 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_living_room_outlets_energy_produced": - name: Living Room Outlets Produced Energy - entity_id: sensor.circuit_8_energy_produced - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [8]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_living_room_outlets_energy_consumed": - name: Living Room Outlets Consumed Energy - entity_id: sensor.circuit_8_energy_consumed - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [8]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_kitchen_outlets_1_power": - name: Kitchen Outlets 1 Power - entity_id: sensor.circuit_9_power - formula: state - attributes: - tabs: "tabs [9]" - voltage: 120 - amperage: - formula: state / 120 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_kitchen_outlets_1_energy_produced": - name: Kitchen Outlets 1 Produced Energy - entity_id: sensor.circuit_9_energy_produced - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [9]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_kitchen_outlets_1_energy_consumed": - name: Kitchen Outlets 1 Consumed Energy - entity_id: sensor.circuit_9_energy_consumed - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [9]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_kitchen_outlets_2_power": - name: Kitchen Outlets 2 Power - entity_id: sensor.circuit_12_power - formula: state - attributes: - tabs: "tabs [12]" - voltage: 120 - amperage: - formula: state / 120 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_kitchen_outlets_2_energy_produced": - name: Kitchen Outlets 2 Produced Energy - entity_id: sensor.circuit_12_energy_produced - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [12]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_kitchen_outlets_2_energy_consumed": - name: Kitchen Outlets 2 Consumed Energy - entity_id: sensor.circuit_12_energy_consumed - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [12]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_office_outlets_power": - name: Office Outlets Power - entity_id: sensor.circuit_11_power - formula: state - attributes: - tabs: "tabs [11]" - voltage: 120 - amperage: - formula: state / 120 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_office_outlets_energy_produced": - name: Office Outlets Produced Energy - entity_id: sensor.circuit_11_energy_produced - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [11]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_office_outlets_energy_consumed": - name: Office Outlets Consumed Energy - entity_id: sensor.circuit_11_energy_consumed - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [11]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_garage_outlets_power": - name: Garage Outlets Power - entity_id: sensor.circuit_10_power - formula: state - attributes: - tabs: "tabs [10]" - voltage: 120 - amperage: - formula: state / 120 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_garage_outlets_energy_produced": - name: Garage Outlets Produced Energy - entity_id: sensor.circuit_10_energy_produced - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [10]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_garage_outlets_energy_consumed": - name: Garage Outlets Consumed Energy - entity_id: sensor.circuit_10_energy_consumed - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [10]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_laundry_outlets_power": - name: Laundry Room Outlets Power - entity_id: sensor.circuit_13_power - formula: state - attributes: - tabs: "tabs [13]" - voltage: 120 - amperage: - formula: state / 120 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_laundry_outlets_energy_produced": - name: Laundry Room Outlets Produced Energy - entity_id: sensor.circuit_13_energy_produced - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [13]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_laundry_outlets_energy_consumed": - name: Laundry Room Outlets Consumed Energy - entity_id: sensor.circuit_13_energy_consumed - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [13]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_guest_room_outlets_power": - name: Guest Room Outlets Power - entity_id: sensor.circuit_14_power - formula: state - attributes: - tabs: "tabs [14]" - voltage: 120 - amperage: - formula: state / 120 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_guest_room_outlets_energy_produced": - name: Guest Room Outlets Produced Energy - entity_id: sensor.circuit_14_energy_produced - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [14]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_guest_room_outlets_energy_consumed": - name: Guest Room Outlets Consumed Energy - entity_id: sensor.circuit_14_energy_consumed - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [14]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_refrigerator_power": - name: Refrigerator Power - entity_id: sensor.circuit_15_power - formula: state - attributes: - tabs: "tabs [15]" - voltage: 120 - amperage: - formula: state / 120 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_refrigerator_energy_produced": - name: Refrigerator Produced Energy - entity_id: sensor.circuit_15_energy_produced - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [15]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_refrigerator_energy_consumed": - name: Refrigerator Consumed Energy - entity_id: sensor.circuit_15_energy_consumed - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [15]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_dishwasher_power": - name: Dishwasher Power - entity_id: sensor.circuit_16_power - formula: state - attributes: - tabs: "tabs [16]" - voltage: 120 - amperage: - formula: state / 120 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_dishwasher_energy_produced": - name: Dishwasher Produced Energy - entity_id: sensor.circuit_16_energy_produced - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [16]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_dishwasher_energy_consumed": - name: Dishwasher Consumed Energy - entity_id: sensor.circuit_16_energy_consumed - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [16]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_washing_machine_power": - name: Washing Machine Power - entity_id: sensor.circuit_17_power - formula: state - attributes: - tabs: "tabs [17]" - voltage: 120 - amperage: - formula: state / 120 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_washing_machine_energy_produced": - name: Washing Machine Produced Energy - entity_id: sensor.circuit_17_energy_produced - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [17]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_washing_machine_energy_consumed": - name: Washing Machine Consumed Energy - entity_id: sensor.circuit_17_energy_consumed - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [17]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_dryer_power": - name: Electric Dryer Power - entity_id: sensor.circuit_18_20_power - formula: state - attributes: - tabs: "tabs [18:20]" - voltage: 240 - amperage: - formula: state / 240 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_dryer_energy_produced": - name: Electric Dryer Produced Energy - entity_id: sensor.circuit_18_20_energy_produced - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [18:20]" - voltage: 240 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_dryer_energy_consumed": - name: Electric Dryer Consumed Energy - entity_id: sensor.circuit_18_20_energy_consumed - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [18:20]" - voltage: 240 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_oven_power": - name: Electric Oven Power - entity_id: sensor.circuit_19_21_power - formula: state - attributes: - tabs: "tabs [19:21]" - voltage: 240 - amperage: - formula: state / 240 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_oven_energy_produced": - name: Electric Oven Produced Energy - entity_id: sensor.circuit_19_21_energy_produced - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [19:21]" - voltage: 240 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_oven_energy_consumed": - name: Electric Oven Consumed Energy - entity_id: sensor.circuit_19_21_energy_consumed - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [19:21]" - voltage: 240 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_microwave_power": - name: Microwave Power - entity_id: sensor.circuit_22_power - formula: state - attributes: - tabs: "tabs [22]" - voltage: 120 - amperage: - formula: state / 120 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_microwave_energy_produced": - name: Microwave Produced Energy - entity_id: sensor.circuit_22_energy_produced - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [22]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_microwave_energy_consumed": - name: Microwave Consumed Energy - entity_id: sensor.circuit_22_energy_consumed - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [22]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_main_hvac_power": - name: Main HVAC Unit Power - entity_id: sensor.circuit_23_25_power - formula: state - attributes: - tabs: "tabs [23:25]" - voltage: 240 - amperage: - formula: state / 240 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_main_hvac_energy_produced": - name: Main HVAC Unit Produced Energy - entity_id: sensor.circuit_23_25_energy_produced - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [23:25]" - voltage: 240 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_main_hvac_energy_consumed": - name: Main HVAC Unit Consumed Energy - entity_id: sensor.circuit_23_25_energy_consumed - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [23:25]" - voltage: 240 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_heat_pump_backup_power": - name: Heat Pump Backup Power - entity_id: sensor.circuit_24_26_power - formula: state - attributes: - tabs: "tabs [24:26]" - voltage: 240 - amperage: - formula: state / 240 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_heat_pump_backup_energy_produced": - name: Heat Pump Backup Produced Energy - entity_id: sensor.circuit_24_26_energy_produced - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [24:26]" - voltage: 240 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_heat_pump_backup_energy_consumed": - name: Heat Pump Backup Consumed Energy - entity_id: sensor.circuit_24_26_energy_consumed - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [24:26]" - voltage: 240 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_ev_charger_garage_power": - name: Garage EV Charger Power - entity_id: sensor.circuit_27_29_power - formula: state - attributes: - tabs: "tabs [27:29]" - voltage: 240 - amperage: - formula: state / 240 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_ev_charger_garage_energy_produced": - name: Garage EV Charger Produced Energy - entity_id: sensor.circuit_27_29_energy_produced - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [27:29]" - voltage: 240 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_ev_charger_garage_energy_consumed": - name: Garage EV Charger Consumed Energy - entity_id: sensor.circuit_27_29_energy_consumed - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [27:29]" - voltage: 240 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_solar_current_power": - entity_id: sensor.solar_current_power - name: Solar Current Power - formula: leg1_power + leg2_power - variables: - leg1_power: sensor.span_panel_unmapped_tab_30_power - leg2_power: sensor.span_panel_unmapped_tab_32_power - attributes: - tabs: "tabs [30:32]" - voltage: 240 - amperage: - formula: (leg1_power + leg2_power) / 240 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_solar_energy_produced": - entity_id: sensor.solar_energy_produced - name: Solar Produced Energy - formula: leg1_produced + leg2_produced - variables: - leg1_produced: sensor.span_panel_unmapped_tab_30_energy_produced - leg2_produced: sensor.span_panel_unmapped_tab_32_energy_produced - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [30:32]" - voltage: 240 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_solar_energy_consumed": - entity_id: sensor.solar_energy_consumed - name: Solar Consumed Energy - formula: leg1_consumed + leg2_consumed - variables: - leg1_consumed: sensor.span_panel_unmapped_tab_30_energy_consumed - leg2_consumed: sensor.span_panel_unmapped_tab_32_energy_consumed - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [30:32]" - voltage: 240 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 diff --git a/tests/fixtures/expected_solar_friendly.yaml b/tests/fixtures/expected_solar_friendly.yaml deleted file mode 100644 index 1de3d360..00000000 --- a/tests/fixtures/expected_solar_friendly.yaml +++ /dev/null @@ -1,2439 +0,0 @@ -version: '1.0' -global_settings: - device_identifier: "sp3-simulation-001" - variables: - energy_grace_period_minutes: 15 - metadata: - attribution: Data from SPAN Panel -sensors: - "span_sp3-simulation-001_current_power": - name: Current Power - entity_id: sensor.span_panel_current_power - formula: state - attributes: - voltage: 240 - amperage: - formula: state / 240 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_feed_through_power": - name: Feed Through Power - entity_id: sensor.span_panel_feed_through_power - formula: state - attributes: - voltage: 240 - amperage: - formula: state / 240 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_main_meter_produced_energy": - name: Main Meter Produced Energy - entity_id: sensor.span_panel_main_meter_produced_energy - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: panel - voltage: 240 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_main_meter_consumed_energy": - name: Main Meter Consumed Energy - entity_id: sensor.span_panel_main_meter_consumed_energy - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: panel - voltage: 240 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_feed_through_produced_energy": - name: Feed Through Produced Energy - entity_id: sensor.span_panel_feed_through_produced_energy - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: panel - voltage: 240 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_feed_through_consumed_energy": - name: Feed Through Consumed Energy - entity_id: sensor.span_panel_feed_through_consumed_energy - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: panel - voltage: 240 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_master_bedroom_lights_power": - name: Master Bedroom Lights Power - entity_id: sensor.span_panel_master_bedroom_lights_power - formula: state - attributes: - tabs: "tabs [1]" - voltage: 120 - amperage: - formula: state / 120 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_master_bedroom_lights_energy_produced": - name: Master Bedroom Lights Produced Energy - entity_id: sensor.span_panel_master_bedroom_lights_energy_produced - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [1]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_master_bedroom_lights_energy_consumed": - name: Master Bedroom Lights Consumed Energy - entity_id: sensor.span_panel_master_bedroom_lights_energy_consumed - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [1]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_living_room_lights_power": - name: Living Room Lights Power - entity_id: sensor.span_panel_living_room_lights_power - formula: state - attributes: - tabs: "tabs [2]" - voltage: 120 - amperage: - formula: state / 120 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_living_room_lights_energy_produced": - name: Living Room Lights Produced Energy - entity_id: sensor.span_panel_living_room_lights_energy_produced - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [2]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_living_room_lights_energy_consumed": - name: Living Room Lights Consumed Energy - entity_id: sensor.span_panel_living_room_lights_energy_consumed - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [2]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_kitchen_lights_power": - name: Kitchen Lights Power - entity_id: sensor.span_panel_kitchen_lights_power - formula: state - attributes: - tabs: "tabs [3]" - voltage: 120 - amperage: - formula: state / 120 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_kitchen_lights_energy_produced": - name: Kitchen Lights Produced Energy - entity_id: sensor.span_panel_kitchen_lights_energy_produced - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [3]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_kitchen_lights_energy_consumed": - name: Kitchen Lights Consumed Energy - entity_id: sensor.span_panel_kitchen_lights_energy_consumed - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [3]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_bedroom_lights_power": - name: Bedroom Lights Power - entity_id: sensor.span_panel_bedroom_lights_power - formula: state - attributes: - tabs: "tabs [4]" - voltage: 120 - amperage: - formula: state / 120 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_bedroom_lights_energy_produced": - name: Bedroom Lights Produced Energy - entity_id: sensor.span_panel_bedroom_lights_energy_produced - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [4]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_bedroom_lights_energy_consumed": - name: Bedroom Lights Consumed Energy - entity_id: sensor.span_panel_bedroom_lights_energy_consumed - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [4]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_bathroom_lights_power": - name: Bathroom Lights Power - entity_id: sensor.span_panel_bathroom_lights_power - formula: state - attributes: - tabs: "tabs [5]" - voltage: 120 - amperage: - formula: state / 120 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_bathroom_lights_energy_produced": - name: Bathroom Lights Produced Energy - entity_id: sensor.span_panel_bathroom_lights_energy_produced - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [5]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_bathroom_lights_energy_consumed": - name: Bathroom Lights Consumed Energy - entity_id: sensor.span_panel_bathroom_lights_energy_consumed - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [5]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_exterior_lights_power": - name: Exterior Lights Power - entity_id: sensor.span_panel_exterior_lights_power - formula: state - attributes: - tabs: "tabs [6]" - voltage: 120 - amperage: - formula: state / 120 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_exterior_lights_energy_produced": - name: Exterior Lights Produced Energy - entity_id: sensor.span_panel_exterior_lights_energy_produced - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [6]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_exterior_lights_energy_consumed": - name: Exterior Lights Consumed Energy - entity_id: sensor.span_panel_exterior_lights_energy_consumed - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [6]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_master_bedroom_outlets_power": - name: Master Bedroom Outlets Power - entity_id: sensor.span_panel_master_bedroom_outlets_power - formula: state - attributes: - tabs: "tabs [7]" - voltage: 120 - amperage: - formula: state / 120 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_master_bedroom_outlets_energy_produced": - name: Master Bedroom Outlets Produced Energy - entity_id: sensor.span_panel_master_bedroom_outlets_energy_produced - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [7]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_master_bedroom_outlets_energy_consumed": - name: Master Bedroom Outlets Consumed Energy - entity_id: sensor.span_panel_master_bedroom_outlets_energy_consumed - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [7]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_living_room_outlets_power": - name: Living Room Outlets Power - entity_id: sensor.span_panel_living_room_outlets_power - formula: state - attributes: - tabs: "tabs [8]" - voltage: 120 - amperage: - formula: state / 120 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_living_room_outlets_energy_produced": - name: Living Room Outlets Produced Energy - entity_id: sensor.span_panel_living_room_outlets_energy_produced - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [8]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_living_room_outlets_energy_consumed": - name: Living Room Outlets Consumed Energy - entity_id: sensor.span_panel_living_room_outlets_energy_consumed - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [8]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_kitchen_outlets_1_power": - name: Kitchen Outlets 1 Power - entity_id: sensor.span_panel_kitchen_outlets_1_power - formula: state - attributes: - tabs: "tabs [9]" - voltage: 120 - amperage: - formula: state / 120 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_kitchen_outlets_1_energy_produced": - name: Kitchen Outlets 1 Produced Energy - entity_id: sensor.span_panel_kitchen_outlets_1_energy_produced - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [9]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_kitchen_outlets_1_energy_consumed": - name: Kitchen Outlets 1 Consumed Energy - entity_id: sensor.span_panel_kitchen_outlets_1_energy_consumed - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [9]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_kitchen_outlets_2_power": - name: Kitchen Outlets 2 Power - entity_id: sensor.span_panel_kitchen_outlets_2_power - formula: state - attributes: - tabs: "tabs [12]" - voltage: 120 - amperage: - formula: state / 120 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_kitchen_outlets_2_energy_produced": - name: Kitchen Outlets 2 Produced Energy - entity_id: sensor.span_panel_kitchen_outlets_2_energy_produced - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [12]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_kitchen_outlets_2_energy_consumed": - name: Kitchen Outlets 2 Consumed Energy - entity_id: sensor.span_panel_kitchen_outlets_2_energy_consumed - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [12]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_office_outlets_power": - name: Office Outlets Power - entity_id: sensor.span_panel_office_outlets_power - formula: state - attributes: - tabs: "tabs [11]" - voltage: 120 - amperage: - formula: state / 120 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_office_outlets_energy_produced": - name: Office Outlets Produced Energy - entity_id: sensor.span_panel_office_outlets_energy_produced - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [11]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_office_outlets_energy_consumed": - name: Office Outlets Consumed Energy - entity_id: sensor.span_panel_office_outlets_energy_consumed - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [11]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_garage_outlets_power": - name: Garage Outlets Power - entity_id: sensor.span_panel_garage_outlets_power - formula: state - attributes: - tabs: "tabs [10]" - voltage: 120 - amperage: - formula: state / 120 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_garage_outlets_energy_produced": - name: Garage Outlets Produced Energy - entity_id: sensor.span_panel_garage_outlets_energy_produced - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [10]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_garage_outlets_energy_consumed": - name: Garage Outlets Consumed Energy - entity_id: sensor.span_panel_garage_outlets_energy_consumed - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [10]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_laundry_outlets_power": - name: Laundry Room Outlets Power - entity_id: sensor.span_panel_laundry_room_outlets_power - formula: state - attributes: - tabs: "tabs [13]" - voltage: 120 - amperage: - formula: state / 120 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_laundry_outlets_energy_produced": - name: Laundry Room Outlets Produced Energy - entity_id: sensor.span_panel_laundry_room_outlets_energy_produced - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [13]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_laundry_outlets_energy_consumed": - name: Laundry Room Outlets Consumed Energy - entity_id: sensor.span_panel_laundry_room_outlets_energy_consumed - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [13]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_guest_room_outlets_power": - name: Guest Room Outlets Power - entity_id: sensor.span_panel_guest_room_outlets_power - formula: state - attributes: - tabs: "tabs [14]" - voltage: 120 - amperage: - formula: state / 120 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_guest_room_outlets_energy_produced": - name: Guest Room Outlets Produced Energy - entity_id: sensor.span_panel_guest_room_outlets_energy_produced - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [14]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_guest_room_outlets_energy_consumed": - name: Guest Room Outlets Consumed Energy - entity_id: sensor.span_panel_guest_room_outlets_energy_consumed - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [14]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_refrigerator_power": - name: Refrigerator Power - entity_id: sensor.span_panel_refrigerator_power - formula: state - attributes: - tabs: "tabs [15]" - voltage: 120 - amperage: - formula: state / 120 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_refrigerator_energy_produced": - name: Refrigerator Produced Energy - entity_id: sensor.span_panel_refrigerator_energy_produced - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [15]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_refrigerator_energy_consumed": - name: Refrigerator Consumed Energy - entity_id: sensor.span_panel_refrigerator_energy_consumed - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [15]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_dishwasher_power": - name: Dishwasher Power - entity_id: sensor.span_panel_dishwasher_power - formula: state - attributes: - tabs: "tabs [16]" - voltage: 120 - amperage: - formula: state / 120 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_dishwasher_energy_produced": - name: Dishwasher Produced Energy - entity_id: sensor.span_panel_dishwasher_energy_produced - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [16]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_dishwasher_energy_consumed": - name: Dishwasher Consumed Energy - entity_id: sensor.span_panel_dishwasher_energy_consumed - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [16]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_washing_machine_power": - name: Washing Machine Power - entity_id: sensor.span_panel_washing_machine_power - formula: state - attributes: - tabs: "tabs [17]" - voltage: 120 - amperage: - formula: state / 120 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_washing_machine_energy_produced": - name: Washing Machine Produced Energy - entity_id: sensor.span_panel_washing_machine_energy_produced - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [17]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_washing_machine_energy_consumed": - name: Washing Machine Consumed Energy - entity_id: sensor.span_panel_washing_machine_energy_consumed - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [17]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_dryer_power": - name: Electric Dryer Power - entity_id: sensor.span_panel_electric_dryer_power - formula: state - attributes: - tabs: "tabs [18:20]" - voltage: 240 - amperage: - formula: state / 240 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_dryer_energy_produced": - name: Electric Dryer Produced Energy - entity_id: sensor.span_panel_electric_dryer_energy_produced - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [18:20]" - voltage: 240 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_dryer_energy_consumed": - name: Electric Dryer Consumed Energy - entity_id: sensor.span_panel_electric_dryer_energy_consumed - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [18:20]" - voltage: 240 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_oven_power": - name: Electric Oven Power - entity_id: sensor.span_panel_electric_oven_power - formula: state - attributes: - tabs: "tabs [19:21]" - voltage: 240 - amperage: - formula: state / 240 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_oven_energy_produced": - name: Electric Oven Produced Energy - entity_id: sensor.span_panel_electric_oven_energy_produced - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [19:21]" - voltage: 240 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_oven_energy_consumed": - name: Electric Oven Consumed Energy - entity_id: sensor.span_panel_electric_oven_energy_consumed - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [19:21]" - voltage: 240 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_microwave_power": - name: Microwave Power - entity_id: sensor.span_panel_microwave_power - formula: state - attributes: - tabs: "tabs [22]" - voltage: 120 - amperage: - formula: state / 120 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_microwave_energy_produced": - name: Microwave Produced Energy - entity_id: sensor.span_panel_microwave_energy_produced - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [22]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_microwave_energy_consumed": - name: Microwave Consumed Energy - entity_id: sensor.span_panel_microwave_energy_consumed - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [22]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_main_hvac_power": - name: Main HVAC Unit Power - entity_id: sensor.span_panel_main_hvac_unit_power - formula: state - attributes: - tabs: "tabs [23:25]" - voltage: 240 - amperage: - formula: state / 240 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_main_hvac_energy_produced": - name: Main HVAC Unit Produced Energy - entity_id: sensor.span_panel_main_hvac_unit_energy_produced - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [23:25]" - voltage: 240 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_main_hvac_energy_consumed": - name: Main HVAC Unit Consumed Energy - entity_id: sensor.span_panel_main_hvac_unit_energy_consumed - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [23:25]" - voltage: 240 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_heat_pump_backup_power": - name: Heat Pump Backup Power - entity_id: sensor.span_panel_heat_pump_backup_power - formula: state - attributes: - tabs: "tabs [24:26]" - voltage: 240 - amperage: - formula: state / 240 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_heat_pump_backup_energy_produced": - name: Heat Pump Backup Produced Energy - entity_id: sensor.span_panel_heat_pump_backup_energy_produced - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [24:26]" - voltage: 240 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_heat_pump_backup_energy_consumed": - name: Heat Pump Backup Consumed Energy - entity_id: sensor.span_panel_heat_pump_backup_energy_consumed - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [24:26]" - voltage: 240 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_ev_charger_garage_power": - name: Garage EV Charger Power - entity_id: sensor.span_panel_garage_ev_charger_power - formula: state - attributes: - tabs: "tabs [27:29]" - voltage: 240 - amperage: - formula: state / 240 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_ev_charger_garage_energy_produced": - name: Garage EV Charger Produced Energy - entity_id: sensor.span_panel_garage_ev_charger_energy_produced - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [27:29]" - voltage: 240 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_ev_charger_garage_energy_consumed": - name: Garage EV Charger Consumed Energy - entity_id: sensor.span_panel_garage_ev_charger_energy_consumed - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [27:29]" - voltage: 240 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_solar_current_power": - entity_id: sensor.solar_current_power - name: Solar Current Power - formula: leg1_power + leg2_power - variables: - leg1_power: sensor.span_panel_unmapped_tab_30_power - leg2_power: sensor.span_panel_unmapped_tab_32_power - attributes: - tabs: "tabs [30:32]" - voltage: 240 - amperage: - formula: (leg1_power + leg2_power) / 240 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_solar_energy_produced": - entity_id: sensor.solar_energy_produced - name: Solar Produced Energy - formula: leg1_produced + leg2_produced - variables: - leg1_produced: sensor.span_panel_unmapped_tab_30_energy_produced - leg2_produced: sensor.span_panel_unmapped_tab_32_energy_produced - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [30:32]" - voltage: 240 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_solar_energy_consumed": - entity_id: sensor.solar_energy_consumed - name: Solar Consumed Energy - formula: leg1_consumed + leg2_consumed - variables: - leg1_consumed: sensor.span_panel_unmapped_tab_30_energy_consumed - leg2_consumed: sensor.span_panel_unmapped_tab_32_energy_consumed - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [30:32]" - voltage: 240 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 diff --git a/tests/fixtures/friendly_names.yaml b/tests/fixtures/friendly_names.yaml deleted file mode 100644 index 5ac80d75..00000000 --- a/tests/fixtures/friendly_names.yaml +++ /dev/null @@ -1,1956 +0,0 @@ -version: '1.0' -global_settings: - device_identifier: "sp3-simulation-001" - variables: - energy_grace_period_minutes: 15 - metadata: - attribution: Data from SPAN Panel -sensors: - "span_sp3-simulation-001_current_power": - name: Current Power - entity_id: sensor.span_panel_current_power - formula: state - attributes: - voltage: 240 - amperage: - formula: state / 240 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_feed_through_power": - name: Feed Through Power - entity_id: sensor.span_panel_feed_through_power - formula: state - attributes: - voltage: 240 - amperage: - formula: state / 240 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_main_meter_produced_energy": - name: Main Meter Produced Energy - entity_id: sensor.span_panel_main_meter_produced_energy - formula: state - alternate_states: - UNAVAILABLE: - formula: state if within_grace else UNKNOWN - UNKNOWN: - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: panel - voltage: 240 - energy_reporting_status: - formula: '''Live''' - alternate_states: - UNAVAILABLE: - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - UNKNOWN: - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_main_meter_consumed_energy": - name: Main Meter Consumed Energy - entity_id: sensor.span_panel_main_meter_consumed_energy - formula: state - alternate_states: - UNAVAILABLE: - formula: state if within_grace else UNKNOWN - UNKNOWN: - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: panel - voltage: 240 - energy_reporting_status: - formula: '''Live''' - alternate_states: - UNAVAILABLE: - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - UNKNOWN: - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_feed_through_produced_energy": - name: Feed Through Produced Energy - entity_id: sensor.span_panel_feed_through_produced_energy - formula: state - alternate_states: - UNAVAILABLE: - formula: state if within_grace else UNKNOWN - UNKNOWN: - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: panel - voltage: 240 - energy_reporting_status: - formula: '''Live''' - alternate_states: - UNAVAILABLE: - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - UNKNOWN: - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_feed_through_consumed_energy": - name: Feed Through Consumed Energy - entity_id: sensor.span_panel_feed_through_consumed_energy - formula: state - alternate_states: - UNAVAILABLE: - formula: state if within_grace else UNKNOWN - UNKNOWN: - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: panel - voltage: 240 - energy_reporting_status: - formula: '''Live''' - alternate_states: - UNAVAILABLE: - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - UNKNOWN: - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_master_bedroom_lights_power": - name: Master Bedroom Lights Power - entity_id: sensor.span_panel_master_bedroom_lights_power - formula: state - attributes: - tabs: "tabs [1]" - voltage: 120 - amperage: - formula: state / 120 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_master_bedroom_lights_energy_produced": - name: Master Bedroom Lights Produced Energy - entity_id: sensor.span_panel_master_bedroom_lights_energy_produced - formula: state - alternate_states: - UNAVAILABLE: - formula: state if within_grace else UNKNOWN - UNKNOWN: - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [1]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - UNAVAILABLE: - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - UNKNOWN: - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_master_bedroom_lights_energy_consumed": - name: Master Bedroom Lights Consumed Energy - entity_id: sensor.span_panel_master_bedroom_lights_energy_consumed - formula: state - alternate_states: - UNAVAILABLE: - formula: state if within_grace else UNKNOWN - UNKNOWN: - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [1]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - UNAVAILABLE: - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - UNKNOWN: - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_living_room_lights_power": - name: Living Room Lights Power - entity_id: sensor.span_panel_living_room_lights_power - formula: state - attributes: - tabs: "tabs [2]" - voltage: 120 - amperage: - formula: state / 120 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_living_room_lights_energy_produced": - name: Living Room Lights Produced Energy - entity_id: sensor.span_panel_living_room_lights_energy_produced - formula: state - alternate_states: - UNAVAILABLE: - formula: state if within_grace else UNKNOWN - UNKNOWN: - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [2]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - UNAVAILABLE: - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - UNKNOWN: - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_living_room_lights_energy_consumed": - name: Living Room Lights Consumed Energy - entity_id: sensor.span_panel_living_room_lights_energy_consumed - formula: state - alternate_states: - UNAVAILABLE: - formula: state if within_grace else UNKNOWN - UNKNOWN: - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [2]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - UNAVAILABLE: - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - UNKNOWN: - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_kitchen_lights_power": - name: Kitchen Lights Power - entity_id: sensor.span_panel_kitchen_lights_power - formula: state - attributes: - tabs: "tabs [3]" - voltage: 120 - amperage: - formula: state / 120 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_kitchen_lights_energy_produced": - name: Kitchen Lights Produced Energy - entity_id: sensor.span_panel_kitchen_lights_energy_produced - formula: state - alternate_states: - UNAVAILABLE: - formula: state if within_grace else UNKNOWN - UNKNOWN: - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [3]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - UNAVAILABLE: - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - UNKNOWN: - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_kitchen_lights_energy_consumed": - name: Kitchen Lights Consumed Energy - entity_id: sensor.span_panel_kitchen_lights_energy_consumed - formula: state - alternate_states: - UNAVAILABLE: - formula: state if within_grace else UNKNOWN - UNKNOWN: - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [3]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - UNAVAILABLE: - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - UNKNOWN: - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_bedroom_lights_power": - name: Bedroom Lights Power - entity_id: sensor.span_panel_bedroom_lights_power - formula: state - attributes: - tabs: "tabs [4]" - voltage: 120 - amperage: - formula: state / 120 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_bedroom_lights_energy_produced": - name: Bedroom Lights Produced Energy - entity_id: sensor.span_panel_bedroom_lights_energy_produced - formula: state - alternate_states: - UNAVAILABLE: - formula: state if within_grace else UNKNOWN - UNKNOWN: - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [4]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - UNAVAILABLE: - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - UNKNOWN: - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_bedroom_lights_energy_consumed": - name: Bedroom Lights Consumed Energy - entity_id: sensor.span_panel_bedroom_lights_energy_consumed - formula: state - alternate_states: - UNAVAILABLE: - formula: state if within_grace else UNKNOWN - UNKNOWN: - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [4]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - UNAVAILABLE: - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - UNKNOWN: - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_bathroom_lights_power": - name: Bathroom Lights Power - entity_id: sensor.span_panel_bathroom_lights_power - formula: state - attributes: - tabs: "tabs [5]" - voltage: 120 - amperage: - formula: state / 120 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_bathroom_lights_energy_produced": - name: Bathroom Lights Produced Energy - entity_id: sensor.span_panel_bathroom_lights_energy_produced - formula: state - alternate_states: - UNAVAILABLE: - formula: state if within_grace else UNKNOWN - UNKNOWN: - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [5]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - UNAVAILABLE: - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - UNKNOWN: - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_bathroom_lights_energy_consumed": - name: Bathroom Lights Consumed Energy - entity_id: sensor.span_panel_bathroom_lights_energy_consumed - formula: state - alternate_states: - UNAVAILABLE: - formula: state if within_grace else UNKNOWN - UNKNOWN: - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [5]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - UNAVAILABLE: - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - UNKNOWN: - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_exterior_lights_power": - name: Exterior Lights Power - entity_id: sensor.span_panel_exterior_lights_power - formula: state - attributes: - tabs: "tabs [6]" - voltage: 120 - amperage: - formula: state / 120 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_exterior_lights_energy_produced": - name: Exterior Lights Produced Energy - entity_id: sensor.span_panel_exterior_lights_energy_produced - formula: state - alternate_states: - UNAVAILABLE: - formula: state if within_grace else UNKNOWN - UNKNOWN: - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [6]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - UNAVAILABLE: - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - UNKNOWN: - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_exterior_lights_energy_consumed": - name: Exterior Lights Consumed Energy - entity_id: sensor.span_panel_exterior_lights_energy_consumed - formula: state - alternate_states: - UNAVAILABLE: - formula: state if within_grace else UNKNOWN - UNKNOWN: - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [6]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - UNAVAILABLE: - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - UNKNOWN: - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_master_bedroom_outlets_power": - name: Master Bedroom Outlets Power - entity_id: sensor.span_panel_master_bedroom_outlets_power - formula: state - attributes: - tabs: "tabs [7]" - voltage: 120 - amperage: - formula: state / 120 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_master_bedroom_outlets_energy_produced": - name: Master Bedroom Outlets Produced Energy - entity_id: sensor.span_panel_master_bedroom_outlets_energy_produced - formula: state - alternate_states: - UNAVAILABLE: - formula: state if within_grace else UNKNOWN - UNKNOWN: - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [7]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - UNAVAILABLE: - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - UNKNOWN: - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_master_bedroom_outlets_energy_consumed": - name: Master Bedroom Outlets Consumed Energy - entity_id: sensor.span_panel_master_bedroom_outlets_energy_consumed - formula: state - alternate_states: - UNAVAILABLE: - formula: state if within_grace else UNKNOWN - UNKNOWN: - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [7]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - UNAVAILABLE: - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - UNKNOWN: - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_living_room_outlets_power": - name: Living Room Outlets Power - entity_id: sensor.span_panel_living_room_outlets_power - formula: state - attributes: - tabs: "tabs [8]" - voltage: 120 - amperage: - formula: state / 120 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_living_room_outlets_energy_produced": - name: Living Room Outlets Produced Energy - entity_id: sensor.span_panel_living_room_outlets_energy_produced - formula: state - alternate_states: - UNAVAILABLE: - formula: state if within_grace else UNKNOWN - UNKNOWN: - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [8]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - UNAVAILABLE: - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - UNKNOWN: - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_living_room_outlets_energy_consumed": - name: Living Room Outlets Consumed Energy - entity_id: sensor.span_panel_living_room_outlets_energy_consumed - formula: state - alternate_states: - UNAVAILABLE: - formula: state if within_grace else UNKNOWN - UNKNOWN: - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [8]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - UNAVAILABLE: - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - UNKNOWN: - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_kitchen_outlets_1_power": - name: Kitchen Outlets 1 Power - entity_id: sensor.span_panel_kitchen_outlets_1_power - formula: state - attributes: - tabs: "tabs [9]" - voltage: 120 - amperage: - formula: state / 120 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_kitchen_outlets_1_energy_produced": - name: Kitchen Outlets 1 Produced Energy - entity_id: sensor.span_panel_kitchen_outlets_1_energy_produced - formula: state - alternate_states: - UNAVAILABLE: - formula: state if within_grace else UNKNOWN - UNKNOWN: - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [9]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - UNAVAILABLE: - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - UNKNOWN: - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_kitchen_outlets_1_energy_consumed": - name: Kitchen Outlets 1 Consumed Energy - entity_id: sensor.span_panel_kitchen_outlets_1_energy_consumed - formula: state - alternate_states: - UNAVAILABLE: - formula: state if within_grace else UNKNOWN - UNKNOWN: - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [9]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - UNAVAILABLE: - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - UNKNOWN: - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_kitchen_outlets_2_power": - name: Kitchen Outlets 2 Power - entity_id: sensor.span_panel_kitchen_outlets_2_power - formula: state - attributes: - tabs: "tabs [12]" - voltage: 120 - amperage: - formula: state / 120 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_kitchen_outlets_2_energy_produced": - name: Kitchen Outlets 2 Produced Energy - entity_id: sensor.span_panel_kitchen_outlets_2_energy_produced - formula: state - alternate_states: - UNAVAILABLE: - formula: state if within_grace else UNKNOWN - UNKNOWN: - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [12]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - UNAVAILABLE: - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - UNKNOWN: - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_kitchen_outlets_2_energy_consumed": - name: Kitchen Outlets 2 Consumed Energy - entity_id: sensor.span_panel_kitchen_outlets_2_energy_consumed - formula: state - alternate_states: - UNAVAILABLE: - formula: state if within_grace else UNKNOWN - UNKNOWN: - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [12]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - UNAVAILABLE: - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - UNKNOWN: - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_office_outlets_power": - name: Office Outlets Power - entity_id: sensor.span_panel_office_outlets_power - formula: state - attributes: - tabs: "tabs [11]" - voltage: 120 - amperage: - formula: state / 120 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_office_outlets_energy_produced": - name: Office Outlets Produced Energy - entity_id: sensor.span_panel_office_outlets_energy_produced - formula: state - alternate_states: - UNAVAILABLE: - formula: state if within_grace else UNKNOWN - UNKNOWN: - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [11]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - UNAVAILABLE: - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - UNKNOWN: - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_office_outlets_energy_consumed": - name: Office Outlets Consumed Energy - entity_id: sensor.span_panel_office_outlets_energy_consumed - formula: state - alternate_states: - UNAVAILABLE: - formula: state if within_grace else UNKNOWN - UNKNOWN: - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [11]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - UNAVAILABLE: - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - UNKNOWN: - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_garage_outlets_power": - name: Garage Outlets Power - entity_id: sensor.span_panel_garage_outlets_power - formula: state - attributes: - tabs: "tabs [10]" - voltage: 120 - amperage: - formula: state / 120 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_garage_outlets_energy_produced": - name: Garage Outlets Produced Energy - entity_id: sensor.span_panel_garage_outlets_energy_produced - formula: state - alternate_states: - UNAVAILABLE: - formula: state if within_grace else UNKNOWN - UNKNOWN: - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [10]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - UNAVAILABLE: - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - UNKNOWN: - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_garage_outlets_energy_consumed": - name: Garage Outlets Consumed Energy - entity_id: sensor.span_panel_garage_outlets_energy_consumed - formula: state - alternate_states: - UNAVAILABLE: - formula: state if within_grace else UNKNOWN - UNKNOWN: - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [10]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - UNAVAILABLE: - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - UNKNOWN: - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_laundry_outlets_power": - name: Laundry Room Outlets Power - entity_id: sensor.span_panel_laundry_room_outlets_power - formula: state - attributes: - tabs: "tabs [13]" - voltage: 120 - amperage: - formula: state / 120 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_laundry_outlets_energy_produced": - name: Laundry Room Outlets Produced Energy - entity_id: sensor.span_panel_laundry_room_outlets_energy_produced - formula: state - alternate_states: - UNAVAILABLE: - formula: state if within_grace else UNKNOWN - UNKNOWN: - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [13]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - UNAVAILABLE: - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - UNKNOWN: - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_laundry_outlets_energy_consumed": - name: Laundry Room Outlets Consumed Energy - entity_id: sensor.span_panel_laundry_room_outlets_energy_consumed - formula: state - alternate_states: - UNAVAILABLE: - formula: state if within_grace else UNKNOWN - UNKNOWN: - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [13]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - UNAVAILABLE: - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - UNKNOWN: - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_guest_room_outlets_power": - name: Guest Room Outlets Power - entity_id: sensor.span_panel_guest_room_outlets_power - formula: state - attributes: - tabs: "tabs [14]" - voltage: 120 - amperage: - formula: state / 120 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_guest_room_outlets_energy_produced": - name: Guest Room Outlets Produced Energy - entity_id: sensor.span_panel_guest_room_outlets_energy_produced - formula: state - alternate_states: - UNAVAILABLE: - formula: state if within_grace else UNKNOWN - UNKNOWN: - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [14]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - UNAVAILABLE: - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - UNKNOWN: - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_guest_room_outlets_energy_consumed": - name: Guest Room Outlets Consumed Energy - entity_id: sensor.span_panel_guest_room_outlets_energy_consumed - formula: state - alternate_states: - UNAVAILABLE: - formula: state if within_grace else UNKNOWN - UNKNOWN: - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [14]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - UNAVAILABLE: - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - UNKNOWN: - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_refrigerator_power": - name: Refrigerator Power - entity_id: sensor.span_panel_refrigerator_power - formula: state - attributes: - tabs: "tabs [15]" - voltage: 120 - amperage: - formula: state / 120 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_refrigerator_energy_produced": - name: Refrigerator Produced Energy - entity_id: sensor.span_panel_refrigerator_energy_produced - formula: state - alternate_states: - UNAVAILABLE: - formula: state if within_grace else UNKNOWN - UNKNOWN: - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [15]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - UNAVAILABLE: - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - UNKNOWN: - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_refrigerator_energy_consumed": - name: Refrigerator Consumed Energy - entity_id: sensor.span_panel_refrigerator_energy_consumed - formula: state - alternate_states: - UNAVAILABLE: - formula: state if within_grace else UNKNOWN - UNKNOWN: - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [15]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - UNAVAILABLE: - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - UNKNOWN: - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_dishwasher_power": - name: Dishwasher Power - entity_id: sensor.span_panel_dishwasher_power - formula: state - attributes: - tabs: "tabs [16]" - voltage: 120 - amperage: - formula: state / 120 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_dishwasher_energy_produced": - name: Dishwasher Produced Energy - entity_id: sensor.span_panel_dishwasher_energy_produced - formula: state - alternate_states: - UNAVAILABLE: - formula: state if within_grace else UNKNOWN - UNKNOWN: - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [16]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - UNAVAILABLE: - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - UNKNOWN: - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_dishwasher_energy_consumed": - name: Dishwasher Consumed Energy - entity_id: sensor.span_panel_dishwasher_energy_consumed - formula: state - alternate_states: - UNAVAILABLE: - formula: state if within_grace else UNKNOWN - UNKNOWN: - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [16]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - UNAVAILABLE: - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - UNKNOWN: - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_washing_machine_power": - name: Washing Machine Power - entity_id: sensor.span_panel_washing_machine_power - formula: state - attributes: - tabs: "tabs [17]" - voltage: 120 - amperage: - formula: state / 120 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_washing_machine_energy_produced": - name: Washing Machine Produced Energy - entity_id: sensor.span_panel_washing_machine_energy_produced - formula: state - alternate_states: - UNAVAILABLE: - formula: state if within_grace else UNKNOWN - UNKNOWN: - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [17]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - UNAVAILABLE: - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - UNKNOWN: - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_washing_machine_energy_consumed": - name: Washing Machine Consumed Energy - entity_id: sensor.span_panel_washing_machine_energy_consumed - formula: state - alternate_states: - UNAVAILABLE: - formula: state if within_grace else UNKNOWN - UNKNOWN: - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [17]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - UNAVAILABLE: - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - UNKNOWN: - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_dryer_power": - name: Electric Dryer Power - entity_id: sensor.span_panel_electric_dryer_power - formula: state - attributes: - tabs: "tabs [18:20]" - voltage: 240 - amperage: - formula: state / 240 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_dryer_energy_produced": - name: Electric Dryer Produced Energy - entity_id: sensor.span_panel_electric_dryer_energy_produced - formula: state - alternate_states: - UNAVAILABLE: - formula: state if within_grace else UNKNOWN - UNKNOWN: - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [18:20]" - voltage: 240 - energy_reporting_status: - formula: '''Live''' - alternate_states: - UNAVAILABLE: - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - UNKNOWN: - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_dryer_energy_consumed": - name: Electric Dryer Consumed Energy - entity_id: sensor.span_panel_electric_dryer_energy_consumed - formula: state - alternate_states: - UNAVAILABLE: - formula: state if within_grace else UNKNOWN - UNKNOWN: - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [18:20]" - voltage: 240 - energy_reporting_status: - formula: '''Live''' - alternate_states: - UNAVAILABLE: - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - UNKNOWN: - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_oven_power": - name: Electric Oven Power - entity_id: sensor.span_panel_electric_oven_power - formula: state - attributes: - tabs: "tabs [19:21]" - voltage: 240 - amperage: - formula: state / 240 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_oven_energy_produced": - name: Electric Oven Produced Energy - entity_id: sensor.span_panel_electric_oven_energy_produced - formula: state - alternate_states: - UNAVAILABLE: - formula: state if within_grace else UNKNOWN - UNKNOWN: - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [19:21]" - voltage: 240 - energy_reporting_status: - formula: '''Live''' - alternate_states: - UNAVAILABLE: - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - UNKNOWN: - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_oven_energy_consumed": - name: Electric Oven Consumed Energy - entity_id: sensor.span_panel_electric_oven_energy_consumed - formula: state - alternate_states: - UNAVAILABLE: - formula: state if within_grace else UNKNOWN - UNKNOWN: - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [19:21]" - voltage: 240 - energy_reporting_status: - formula: '''Live''' - alternate_states: - UNAVAILABLE: - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - UNKNOWN: - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_microwave_power": - name: Microwave Power - entity_id: sensor.span_panel_microwave_power - formula: state - attributes: - tabs: "tabs [22]" - voltage: 120 - amperage: - formula: state / 120 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_microwave_energy_produced": - name: Microwave Produced Energy - entity_id: sensor.span_panel_microwave_energy_produced - formula: state - alternate_states: - UNAVAILABLE: - formula: state if within_grace else UNKNOWN - UNKNOWN: - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [22]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - UNAVAILABLE: - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - UNKNOWN: - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_microwave_energy_consumed": - name: Microwave Consumed Energy - entity_id: sensor.span_panel_microwave_energy_consumed - formula: state - alternate_states: - UNAVAILABLE: - formula: state if within_grace else UNKNOWN - UNKNOWN: - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [22]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - UNAVAILABLE: - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - UNKNOWN: - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_main_hvac_power": - name: Main HVAC Unit Power - entity_id: sensor.span_panel_main_hvac_unit_power - formula: state - attributes: - tabs: "tabs [23:25]" - voltage: 240 - amperage: - formula: state / 240 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_main_hvac_energy_produced": - name: Main HVAC Unit Produced Energy - entity_id: sensor.span_panel_main_hvac_unit_energy_produced - formula: state - alternate_states: - UNAVAILABLE: - formula: state if within_grace else UNKNOWN - UNKNOWN: - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [23:25]" - voltage: 240 - energy_reporting_status: - formula: '''Live''' - alternate_states: - UNAVAILABLE: - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - UNKNOWN: - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_main_hvac_energy_consumed": - name: Main HVAC Unit Consumed Energy - entity_id: sensor.span_panel_main_hvac_unit_energy_consumed - formula: state - alternate_states: - UNAVAILABLE: - formula: state if within_grace else UNKNOWN - UNKNOWN: - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [23:25]" - voltage: 240 - energy_reporting_status: - formula: '''Live''' - alternate_states: - UNAVAILABLE: - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - UNKNOWN: - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_heat_pump_backup_power": - name: Heat Pump Backup Power - entity_id: sensor.span_panel_heat_pump_backup_power - formula: state - attributes: - tabs: "tabs [24:26]" - voltage: 240 - amperage: - formula: state / 240 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_heat_pump_backup_energy_produced": - name: Heat Pump Backup Produced Energy - entity_id: sensor.span_panel_heat_pump_backup_energy_produced - formula: state - alternate_states: - UNAVAILABLE: - formula: state if within_grace else UNKNOWN - UNKNOWN: - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [24:26]" - voltage: 240 - energy_reporting_status: - formula: '''Live''' - alternate_states: - UNAVAILABLE: - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - UNKNOWN: - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_heat_pump_backup_energy_consumed": - name: Heat Pump Backup Consumed Energy - entity_id: sensor.span_panel_heat_pump_backup_energy_consumed - formula: state - alternate_states: - UNAVAILABLE: - formula: state if within_grace else UNKNOWN - UNKNOWN: - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [24:26]" - voltage: 240 - energy_reporting_status: - formula: '''Live''' - alternate_states: - UNAVAILABLE: - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - UNKNOWN: - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_ev_charger_garage_power": - name: Garage EV Charger Power - entity_id: sensor.span_panel_garage_ev_charger_power - formula: state - attributes: - tabs: "tabs [27:29]" - voltage: 240 - amperage: - formula: state / 240 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_ev_charger_garage_energy_produced": - name: Garage EV Charger Produced Energy - entity_id: sensor.span_panel_garage_ev_charger_energy_produced - formula: state - alternate_states: - UNAVAILABLE: - formula: state if within_grace else UNKNOWN - UNKNOWN: - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [27:29]" - voltage: 240 - energy_reporting_status: - formula: '''Live''' - alternate_states: - UNAVAILABLE: - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - UNKNOWN: - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_ev_charger_garage_energy_consumed": - name: Garage EV Charger Consumed Energy - entity_id: sensor.span_panel_garage_ev_charger_energy_consumed - formula: state - alternate_states: - UNAVAILABLE: - formula: state if within_grace else UNKNOWN - UNKNOWN: - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [27:29]" - voltage: 240 - energy_reporting_status: - formula: '''Live''' - alternate_states: - UNAVAILABLE: - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - UNKNOWN: - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 diff --git a/tests/fixtures/legacy_no_prefix.yaml b/tests/fixtures/legacy_no_prefix.yaml deleted file mode 100644 index 23cf51eb..00000000 --- a/tests/fixtures/legacy_no_prefix.yaml +++ /dev/null @@ -1,2356 +0,0 @@ -version: '1.0' -global_settings: - device_identifier: "sp3-simulation-001" - variables: - energy_grace_period_minutes: 15 - metadata: - attribution: Data from SPAN Panel -sensors: - "span_sp3-simulation-001_current_power": - name: Current Power - entity_id: sensor.current_power - formula: state - attributes: - voltage: 240 - amperage: - formula: state / 240 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_feed_through_power": - name: Feed Through Power - entity_id: sensor.feed_through_power - formula: state - attributes: - voltage: 240 - amperage: - formula: state / 240 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_main_meter_produced_energy": - name: Main Meter Produced Energy - entity_id: sensor.main_meter_produced_energy - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: panel - voltage: 240 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_main_meter_consumed_energy": - name: Main Meter Consumed Energy - entity_id: sensor.main_meter_consumed_energy - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: panel - voltage: 240 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_feed_through_produced_energy": - name: Feed Through Produced Energy - entity_id: sensor.feed_through_produced_energy - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: panel - voltage: 240 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_feed_through_consumed_energy": - name: Feed Through Consumed Energy - entity_id: sensor.feed_through_consumed_energy - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: panel - voltage: 240 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_master_bedroom_lights_power": - name: Master Bedroom Lights Power - entity_id: sensor.master_bedroom_lights_power - formula: state - attributes: - tabs: "tabs [1]" - voltage: 120 - amperage: - formula: state / 120 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_master_bedroom_lights_energy_produced": - name: Master Bedroom Lights Produced Energy - entity_id: sensor.master_bedroom_lights_energy_produced - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [1]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_master_bedroom_lights_energy_consumed": - name: Master Bedroom Lights Consumed Energy - entity_id: sensor.master_bedroom_lights_energy_consumed - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [1]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_living_room_lights_power": - name: Living Room Lights Power - entity_id: sensor.living_room_lights_power - formula: state - attributes: - tabs: "tabs [2]" - voltage: 120 - amperage: - formula: state / 120 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_living_room_lights_energy_produced": - name: Living Room Lights Produced Energy - entity_id: sensor.living_room_lights_energy_produced - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [2]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_living_room_lights_energy_consumed": - name: Living Room Lights Consumed Energy - entity_id: sensor.living_room_lights_energy_consumed - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [2]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_kitchen_lights_power": - name: Kitchen Lights Power - entity_id: sensor.kitchen_lights_power - formula: state - attributes: - tabs: "tabs [3]" - voltage: 120 - amperage: - formula: state / 120 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_kitchen_lights_energy_produced": - name: Kitchen Lights Produced Energy - entity_id: sensor.kitchen_lights_energy_produced - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [3]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_kitchen_lights_energy_consumed": - name: Kitchen Lights Consumed Energy - entity_id: sensor.kitchen_lights_energy_consumed - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [3]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_bedroom_lights_power": - name: Bedroom Lights Power - entity_id: sensor.bedroom_lights_power - formula: state - attributes: - tabs: "tabs [4]" - voltage: 120 - amperage: - formula: state / 120 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_bedroom_lights_energy_produced": - name: Bedroom Lights Produced Energy - entity_id: sensor.bedroom_lights_energy_produced - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [4]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_bedroom_lights_energy_consumed": - name: Bedroom Lights Consumed Energy - entity_id: sensor.bedroom_lights_energy_consumed - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [4]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_bathroom_lights_power": - name: Bathroom Lights Power - entity_id: sensor.bathroom_lights_power - formula: state - attributes: - tabs: "tabs [5]" - voltage: 120 - amperage: - formula: state / 120 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_bathroom_lights_energy_produced": - name: Bathroom Lights Produced Energy - entity_id: sensor.bathroom_lights_energy_produced - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [5]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_bathroom_lights_energy_consumed": - name: Bathroom Lights Consumed Energy - entity_id: sensor.bathroom_lights_energy_consumed - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [5]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_exterior_lights_power": - name: Exterior Lights Power - entity_id: sensor.exterior_lights_power - formula: state - attributes: - tabs: "tabs [6]" - voltage: 120 - amperage: - formula: state / 120 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_exterior_lights_energy_produced": - name: Exterior Lights Produced Energy - entity_id: sensor.exterior_lights_energy_produced - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [6]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_exterior_lights_energy_consumed": - name: Exterior Lights Consumed Energy - entity_id: sensor.exterior_lights_energy_consumed - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [6]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_master_bedroom_outlets_power": - name: Master Bedroom Outlets Power - entity_id: sensor.master_bedroom_outlets_power - formula: state - attributes: - tabs: "tabs [7]" - voltage: 120 - amperage: - formula: state / 120 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_master_bedroom_outlets_energy_produced": - name: Master Bedroom Outlets Produced Energy - entity_id: sensor.master_bedroom_outlets_energy_produced - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [7]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_master_bedroom_outlets_energy_consumed": - name: Master Bedroom Outlets Consumed Energy - entity_id: sensor.master_bedroom_outlets_energy_consumed - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [7]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_living_room_outlets_power": - name: Living Room Outlets Power - entity_id: sensor.living_room_outlets_power - formula: state - attributes: - tabs: "tabs [8]" - voltage: 120 - amperage: - formula: state / 120 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_living_room_outlets_energy_produced": - name: Living Room Outlets Produced Energy - entity_id: sensor.living_room_outlets_energy_produced - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [8]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_living_room_outlets_energy_consumed": - name: Living Room Outlets Consumed Energy - entity_id: sensor.living_room_outlets_energy_consumed - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [8]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_kitchen_outlets_1_power": - name: Kitchen Outlets 1 Power - entity_id: sensor.kitchen_outlets_1_power - formula: state - attributes: - tabs: "tabs [9]" - voltage: 120 - amperage: - formula: state / 120 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_kitchen_outlets_1_energy_produced": - name: Kitchen Outlets 1 Produced Energy - entity_id: sensor.kitchen_outlets_1_energy_produced - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [9]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_kitchen_outlets_1_energy_consumed": - name: Kitchen Outlets 1 Consumed Energy - entity_id: sensor.kitchen_outlets_1_energy_consumed - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [9]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_kitchen_outlets_2_power": - name: Kitchen Outlets 2 Power - entity_id: sensor.kitchen_outlets_2_power - formula: state - attributes: - tabs: "tabs [12]" - voltage: 120 - amperage: - formula: state / 120 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_kitchen_outlets_2_energy_produced": - name: Kitchen Outlets 2 Produced Energy - entity_id: sensor.kitchen_outlets_2_energy_produced - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [12]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_kitchen_outlets_2_energy_consumed": - name: Kitchen Outlets 2 Consumed Energy - entity_id: sensor.kitchen_outlets_2_energy_consumed - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [12]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_office_outlets_power": - name: Office Outlets Power - entity_id: sensor.office_outlets_power - formula: state - attributes: - tabs: "tabs [11]" - voltage: 120 - amperage: - formula: state / 120 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_office_outlets_energy_produced": - name: Office Outlets Produced Energy - entity_id: sensor.office_outlets_energy_produced - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [11]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_office_outlets_energy_consumed": - name: Office Outlets Consumed Energy - entity_id: sensor.office_outlets_energy_consumed - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [11]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_garage_outlets_power": - name: Garage Outlets Power - entity_id: sensor.garage_outlets_power - formula: state - attributes: - tabs: "tabs [10]" - voltage: 120 - amperage: - formula: state / 120 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_garage_outlets_energy_produced": - name: Garage Outlets Produced Energy - entity_id: sensor.garage_outlets_energy_produced - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [10]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_garage_outlets_energy_consumed": - name: Garage Outlets Consumed Energy - entity_id: sensor.garage_outlets_energy_consumed - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [10]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_laundry_outlets_power": - name: Laundry Room Outlets Power - entity_id: sensor.laundry_room_outlets_power - formula: state - attributes: - tabs: "tabs [13]" - voltage: 120 - amperage: - formula: state / 120 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_laundry_outlets_energy_produced": - name: Laundry Room Outlets Produced Energy - entity_id: sensor.laundry_room_outlets_energy_produced - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [13]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_laundry_outlets_energy_consumed": - name: Laundry Room Outlets Consumed Energy - entity_id: sensor.laundry_room_outlets_energy_consumed - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [13]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_guest_room_outlets_power": - name: Guest Room Outlets Power - entity_id: sensor.guest_room_outlets_power - formula: state - attributes: - tabs: "tabs [14]" - voltage: 120 - amperage: - formula: state / 120 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_guest_room_outlets_energy_produced": - name: Guest Room Outlets Produced Energy - entity_id: sensor.guest_room_outlets_energy_produced - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [14]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_guest_room_outlets_energy_consumed": - name: Guest Room Outlets Consumed Energy - entity_id: sensor.guest_room_outlets_energy_consumed - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [14]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_refrigerator_power": - name: Refrigerator Power - entity_id: sensor.refrigerator_power - formula: state - attributes: - tabs: "tabs [15]" - voltage: 120 - amperage: - formula: state / 120 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_refrigerator_energy_produced": - name: Refrigerator Produced Energy - entity_id: sensor.refrigerator_energy_produced - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [15]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_refrigerator_energy_consumed": - name: Refrigerator Consumed Energy - entity_id: sensor.refrigerator_energy_consumed - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [15]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_dishwasher_power": - name: Dishwasher Power - entity_id: sensor.dishwasher_power - formula: state - attributes: - tabs: "tabs [16]" - voltage: 120 - amperage: - formula: state / 120 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_dishwasher_energy_produced": - name: Dishwasher Produced Energy - entity_id: sensor.dishwasher_energy_produced - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [16]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_dishwasher_energy_consumed": - name: Dishwasher Consumed Energy - entity_id: sensor.dishwasher_energy_consumed - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [16]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_washing_machine_power": - name: Washing Machine Power - entity_id: sensor.washing_machine_power - formula: state - attributes: - tabs: "tabs [17]" - voltage: 120 - amperage: - formula: state / 120 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_washing_machine_energy_produced": - name: Washing Machine Produced Energy - entity_id: sensor.washing_machine_energy_produced - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [17]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_washing_machine_energy_consumed": - name: Washing Machine Consumed Energy - entity_id: sensor.washing_machine_energy_consumed - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [17]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_dryer_power": - name: Electric Dryer Power - entity_id: sensor.electric_dryer_power - formula: state - attributes: - tabs: "tabs [18:20]" - voltage: 240 - amperage: - formula: state / 240 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_dryer_energy_produced": - name: Electric Dryer Produced Energy - entity_id: sensor.electric_dryer_energy_produced - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [18:20]" - voltage: 240 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_dryer_energy_consumed": - name: Electric Dryer Consumed Energy - entity_id: sensor.electric_dryer_energy_consumed - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [18:20]" - voltage: 240 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_oven_power": - name: Electric Oven Power - entity_id: sensor.electric_oven_power - formula: state - attributes: - tabs: "tabs [19:21]" - voltage: 240 - amperage: - formula: state / 240 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_oven_energy_produced": - name: Electric Oven Produced Energy - entity_id: sensor.electric_oven_energy_produced - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [19:21]" - voltage: 240 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_oven_energy_consumed": - name: Electric Oven Consumed Energy - entity_id: sensor.electric_oven_energy_consumed - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [19:21]" - voltage: 240 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_microwave_power": - name: Microwave Power - entity_id: sensor.microwave_power - formula: state - attributes: - tabs: "tabs [22]" - voltage: 120 - amperage: - formula: state / 120 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_microwave_energy_produced": - name: Microwave Produced Energy - entity_id: sensor.microwave_energy_produced - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [22]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_microwave_energy_consumed": - name: Microwave Consumed Energy - entity_id: sensor.microwave_energy_consumed - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [22]" - voltage: 120 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_main_hvac_power": - name: Main HVAC Unit Power - entity_id: sensor.main_hvac_unit_power - formula: state - attributes: - tabs: "tabs [23:25]" - voltage: 240 - amperage: - formula: state / 240 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_main_hvac_energy_produced": - name: Main HVAC Unit Produced Energy - entity_id: sensor.main_hvac_unit_energy_produced - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [23:25]" - voltage: 240 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_main_hvac_energy_consumed": - name: Main HVAC Unit Consumed Energy - entity_id: sensor.main_hvac_unit_energy_consumed - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [23:25]" - voltage: 240 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_heat_pump_backup_power": - name: Heat Pump Backup Power - entity_id: sensor.heat_pump_backup_power - formula: state - attributes: - tabs: "tabs [24:26]" - voltage: 240 - amperage: - formula: state / 240 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_heat_pump_backup_energy_produced": - name: Heat Pump Backup Produced Energy - entity_id: sensor.heat_pump_backup_energy_produced - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [24:26]" - voltage: 240 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_heat_pump_backup_energy_consumed": - name: Heat Pump Backup Consumed Energy - entity_id: sensor.heat_pump_backup_energy_consumed - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [24:26]" - voltage: 240 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_ev_charger_garage_power": - name: Garage EV Charger Power - entity_id: sensor.garage_ev_charger_power - formula: state - attributes: - tabs: "tabs [27:29]" - voltage: 240 - amperage: - formula: state / 240 - metadata: - unit_of_measurement: A - device_class: current - suggested_display_precision: 0 - metadata: - unit_of_measurement: W - device_class: power - state_class: measurement - suggested_display_precision: 0 - "span_sp3-simulation-001_ev_charger_garage_energy_produced": - name: Garage EV Charger Produced Energy - entity_id: sensor.garage_ev_charger_energy_produced - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [27:29]" - voltage: 240 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 - "span_sp3-simulation-001_ev_charger_garage_energy_consumed": - name: Garage EV Charger Consumed Energy - entity_id: sensor.garage_ev_charger_energy_consumed - formula: state - alternate_states: - - UNAVAILABLE: - - formula: state if within_grace else UNKNOWN - - UNKNOWN: - - formula: state if within_grace else UNKNOWN - variables: - within_grace: - formula: minutes_between(metadata(state, 'last_changed'), now()) < energy_grace_period_minutes - alternate_states: - UNAVAILABLE: false - UNKNOWN: false - attributes: - tabs: "tabs [27:29]" - voltage: 240 - energy_reporting_status: - formula: '''Live''' - alternate_states: - - UNAVAILABLE: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - - UNKNOWN: - - formula: '''Previous (grace)'' if within_grace else ''Unknown''' - metadata: - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - suggested_display_precision: 2 diff --git a/tests/helpers.py b/tests/helpers.py index b7f4df8e..cfd8827c 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -1,190 +1,14 @@ """Helper functions for testing the Span Panel integration.""" -from collections.abc import Generator -from contextlib import contextmanager import datetime - -# Import from factories.py (the module file, not the package directory) -# Force direct file import to avoid the factories/ directory conflict -import importlib.util -import os -from pathlib import Path from typing import Any -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST, CONF_SCAN_INTERVAL from homeassistant.core import HomeAssistant from homeassistant.util.dt import utcnow from pytest_homeassistant_custom_component.common import MockConfigEntry -from custom_components.span_panel.const import ( - DSM_GRID_UP, - DSM_ON_GRID, - PANEL_ON_GRID, - STORAGE_BATTERY_PERCENTAGE, -) -from custom_components.span_panel.span_panel_data import SpanPanelData -from custom_components.span_panel.span_panel_hardware_status import ( - SpanPanelHardwareStatus, -) - -factories_path = os.path.join(os.path.dirname(__file__), "factories.py") -spec = importlib.util.spec_from_file_location("factories_direct", factories_path) -factories_module = importlib.util.module_from_spec(spec) -spec.loader.exec_module(factories_module) - -SpanPanelApiResponseFactory = factories_module.SpanPanelApiResponseFactory - - -class SimpleMockPanel: - """Simple mock panel that returns actual values.""" - - def __init__(self, panel_data: dict[str, Any]): - """Initialize the mock panel.""" - # Map factory data keys to property names expected by sensors - self.instant_grid_power = panel_data.get("instantGridPowerW", 0.0) - self.feedthrough_power = panel_data.get("feedthroughPowerW", 0.0) - self.current_run_config = panel_data.get("currentRunConfig", PANEL_ON_GRID) - self.dsm_grid_state = panel_data.get("dsmGridState", DSM_GRID_UP) - self.dsm_state = panel_data.get("dsmState", DSM_ON_GRID) - self.main_relay_state = panel_data.get("mainRelayState", "CLOSED") - self.grid_sample_start_ms = panel_data.get("gridSampleStartMs", 0) - self.grid_sample_end_ms = panel_data.get("gridSampleEndMs", 0) - self.main_meter_energy_produced = panel_data.get("mainMeterEnergyWh", {}).get( - "producedEnergyWh", 0.0 - ) - self.main_meter_energy_consumed = panel_data.get("mainMeterEnergyWh", {}).get( - "consumedEnergyWh", 0.0 - ) - self.feedthrough_energy_produced = panel_data.get("feedthroughEnergyWh", {}).get( - "producedEnergyWh", 0.0 - ) - self.feedthrough_energy_consumed = panel_data.get("feedthroughEnergyWh", {}).get( - "consumedEnergyWh", 0.0 - ) - - # Also set the original keys for direct access if needed - for key, value in panel_data.items(): - if not hasattr(self, key): - setattr(self, key, value) - - -class MockSpanPanelStorageBattery: - """Mock storage battery for testing.""" - - def __init__(self, battery_data: dict[str, Any]): - """Initialize the mock storage battery.""" - self.storage_battery_percentage = battery_data.get(STORAGE_BATTERY_PERCENTAGE, 85) - - # Also set any other battery attributes from the data - for key, value in battery_data.items(): - if not hasattr(self, key): - setattr(self, key, value) - - -@contextmanager -def patch_span_panel_dependencies( - mock_api_responses: dict[str, Any] | None = None, - options: dict[str, Any] | None = None, -) -> Generator[tuple[MagicMock, AsyncMock], None, None]: - """Patches common dependencies for setting up the Span Panel integration in tests.""" - - if mock_api_responses is None: - mock_api_responses = SpanPanelApiResponseFactory.create_complete_panel_response() - - # Create mock API instance - mock_api = AsyncMock() - mock_api.get_status_data = AsyncMock(return_value=mock_api_responses["status"]) - mock_api.get_panel_data = AsyncMock(return_value=mock_api_responses["panel"]) - mock_api.get_circuits_data = AsyncMock(return_value=mock_api_responses["circuits"]) - mock_api.get_storage_battery_data = AsyncMock(return_value=mock_api_responses["battery"]) - mock_api.set_relay = AsyncMock() - - # Create mock objects that properly expose the data as attributes - mock_status = SpanPanelHardwareStatus.from_dict(mock_api_responses["status"]) - - # Create real panel data using the actual from_dict method for proper solar calculations - # If options are provided, create a proper Options object - panel_options = None - if options: - from custom_components.span_panel.options import Options - - # Create a mock config entry with the options - mock_entry = MagicMock() - mock_entry.options = options - panel_options = Options(mock_entry) - - mock_panel_data = SpanPanelData.from_dict(mock_api_responses["panel"], panel_options) - - mock_circuits = {} - for circuit_id, circuit_data in mock_api_responses["circuits"].items(): - # Create proper MockSpanPanelCircuit objects instead of MagicMock - mock_circuits[circuit_id] = MockSpanPanelCircuit(circuit_data) - - # Create proper battery mock instead of MagicMock - mock_battery = MockSpanPanelStorageBattery(mock_api_responses["battery"]) - - # Mock the SpanPanel class and ensure update() calls the API methods - mock_span_panel = MagicMock() - mock_span_panel.api = mock_api - mock_span_panel.status = mock_status - mock_span_panel.panel = mock_panel_data - mock_span_panel.circuits = mock_circuits - mock_span_panel.storage_battery = mock_battery - - # Make update() method actually call the API methods and update the data - async def mock_update(): - """Mock update method that calls the API and updates data.""" - # Call the API methods to register the calls for assertion - await mock_api.get_status_data() - await mock_api.get_panel_data() - await mock_api.get_circuits_data() - await mock_api.get_storage_battery_data() - - # Update the mock data (simulate what the real update does) - status_data = await mock_api.get_status_data() - await mock_api.get_panel_data() - await mock_api.get_circuits_data() - battery_data = await mock_api.get_storage_battery_data() - - # Update mock status with a new real status object - mock_span_panel.status = SpanPanelHardwareStatus.from_dict(status_data) - - # Update mock battery with new data - mock_span_panel.storage_battery = MockSpanPanelStorageBattery(battery_data) - - mock_span_panel.update = mock_update - - patches = [ - patch("custom_components.span_panel.SpanPanel", return_value=mock_span_panel), - patch( - "custom_components.span_panel.span_panel.SpanPanel", - return_value=mock_span_panel, - ), - patch( - "custom_components.span_panel.coordinator.SpanPanel", - return_value=mock_span_panel, - ), - patch( - "homeassistant.helpers.httpx_client.get_async_client", - return_value=AsyncMock(), - ), - patch( - "custom_components.span_panel.entity_summary.log_entity_summary", - return_value=None, - ), - # Disable select platform to avoid type checking issues in tests - patch("custom_components.span_panel.select.async_setup_entry", return_value=True), - ] - - try: - for p in patches: - p.start() - yield mock_span_panel, mock_api - finally: - for p in patches: - p.stop() - def make_span_panel_entry( entry_id: str = "test_entry", @@ -249,16 +73,15 @@ async def trigger_coordinator_update(coordinator: Any) -> None: def setup_span_panel_entry( hass: HomeAssistant, - mock_api_responses: dict[str, Any] | None = None, entry_id: str = "test_span_panel", host: str = "192.168.1.100", access_token: str = "test_token", options: dict[str, Any] | None = None, -) -> tuple[MockConfigEntry, dict[str, Any] | None]: - """Create and setup a span panel entry for testing. +) -> MockConfigEntry: + """Create and add a span panel entry for testing. Returns: - tuple: (config_entry, mock_api_responses) + The config entry. """ entry = make_span_panel_entry( @@ -268,41 +91,23 @@ def setup_span_panel_entry( options=options, ) entry.add_to_hass(hass) - - # This will be used in the context manager - return entry, mock_api_responses + return entry def get_circuit_entity_id_from_integration( - hass: HomeAssistant, coordinator: Any, - span_panel: Any, + snapshot: Any, circuit_data: Any, suffix: str, -) -> str: - """Generate expected entity ID for a circuit entity using integration helpers. - - This function uses the same logic as the integration to generate entity IDs, - ensuring tests match the actual behavior. - - Args: - hass: Home Assistant instance - coordinator: The coordinator instance - span_panel: The span panel data - circuit_data: Circuit data object - suffix: Sensor suffix (e.g., "power", "energy_consumed") - - Returns: - Entity ID string that matches what the integration actually generates - - """ +) -> str | None: + """Generate expected entity ID for a circuit entity using integration helpers.""" from custom_components.span_panel.helpers import construct_entity_id, get_circuit_number circuit_number = get_circuit_number(circuit_data) return construct_entity_id( coordinator, - span_panel, + snapshot, "sensor", circuit_data.name, circuit_number, @@ -310,270 +115,6 @@ def get_circuit_entity_id_from_integration( ) -def get_panel_entity_id_from_integration( - hass: HomeAssistant, - coordinator: Any, - span_panel: Any, - suffix: str, -) -> str: - """Generate expected entity ID for a panel-level entity using integration helpers. - - This function uses the same logic as the integration to generate entity IDs, - ensuring tests match the actual behavior. - - Args: - hass: Home Assistant instance - coordinator: The coordinator instance - span_panel: The span panel data - suffix: Sensor suffix (e.g., "current_power", "dsm_state") - - Returns: - Entity ID string that matches what the integration actually generates - - """ - from custom_components.span_panel.helpers import construct_panel_entity_id - - device_name = coordinator.config_entry.title - use_device_prefix = coordinator.config_entry.options.get("use_device_prefix", False) - return construct_panel_entity_id( - coordinator, - span_panel, - "sensor", - suffix, - device_name, - use_device_prefix=use_device_prefix, - ) - - -def find_circuit_entity_by_name_and_suffix( - hass: HomeAssistant, - circuit_name: str, - suffix: str, - platform: str = "sensor", -) -> str | None: - """Find a circuit entity by circuit name and suffix from actual HA states. - - This function searches through the actual Home Assistant entity states - to find entities that match the expected circuit name and suffix pattern. - This works with both native sensors and synthetic sensors. - - Args: - hass: Home Assistant instance - circuit_name: Human-readable circuit name (e.g., "Kitchen Outlets") - suffix: Sensor suffix (e.g., "power", "energy_consumed") - platform: Platform name (default: "sensor") - - Returns: - Entity ID if found, None otherwise - - """ - # Look for entities with the circuit name in the friendly name and suffix in entity ID - expected_friendly_name_part = circuit_name - expected_suffix = suffix - - for state in hass.states.async_all(): - entity_id = state.entity_id - if not entity_id.startswith(f"{platform}."): - continue - - # Check if entity ID contains the suffix - if expected_suffix not in entity_id: - continue - - # Check if friendly name contains the circuit name - friendly_name = state.attributes.get("friendly_name", "") - if expected_friendly_name_part in friendly_name: - return entity_id - - return None - - -def find_panel_entity_by_suffix( - hass: HomeAssistant, - suffix: str, - platform: str = "sensor", -) -> str | None: - """Find a panel entity by suffix from actual HA states. - - This function searches through the actual Home Assistant entity states - to find panel-level entities that match the expected suffix pattern. - - Args: - hass: Home Assistant instance - suffix: Sensor suffix (e.g., "current_power", "dsm_state") - platform: Platform name (default: "sensor") - - Returns: - Entity ID if found, None otherwise - - """ - # Look for entities with the suffix in the entity ID - for state in hass.states.async_all(): - entity_id = state.entity_id - if not entity_id.startswith(f"{platform}."): - continue - - # Check if entity ID contains the suffix - if suffix in entity_id: - return entity_id - - return None - - -class MockSpanPanelCircuit: - """Mock circuit for testing.""" - - def __init__(self, circuit_data: dict[str, Any]): - """Initialize the mock circuit.""" - self.id = circuit_data["id"] - self.name = circuit_data.get("name", "Test Circuit") - self.instant_power = circuit_data.get("instantPowerW", 0.0) - self.consumed_energy = circuit_data.get("consumedEnergyWh", 0.0) - self.produced_energy = circuit_data.get("producedEnergyWh", 0.0) - self.relay_state = circuit_data.get("relayState", "CLOSED") - self.tabs = circuit_data.get("tabs", [1]) - self.priority = circuit_data.get("priority", "NICE_TO_HAVE") - self.is_user_controllable = circuit_data.get("is_user_controllable", True) - self.is_sheddable = circuit_data.get("is_sheddable", True) - self.is_never_backup = circuit_data.get("is_never_backup", False) - - def copy(self): - """Create a copy of this circuit.""" - return MockSpanPanelCircuit( - { - "id": self.id, - "name": self.name, - "instantPowerW": self.instant_power, - "consumedEnergyWh": self.consumed_energy, - "producedEnergyWh": self.produced_energy, - "relayState": self.relay_state, - "tabs": self.tabs, - "priority": self.priority, - "is_user_controllable": self.is_user_controllable, - "is_sheddable": self.is_sheddable, - "is_never_backup": self.is_never_backup, - } - ) - - -async def mock_circuit_relay_operation( - mock_api: AsyncMock, circuit_id: str, new_state: str, mock_circuits: dict[str, Any] -) -> None: - """Mock a circuit relay operation and update the mock data.""" - if circuit_id in mock_circuits: - mock_circuits[circuit_id].relay_state = new_state - # Simulate API call - mock_api.set_relay.return_value = None - - -def cleanup_synthetic_yaml_files(hass: HomeAssistant) -> None: - """Clean up synthetic sensor YAML files to ensure clean test state. - - This removes both the main synthetic sensors YAML and solar synthetic sensors YAML - to ensure tests start with a clean slate and generate YAML from scratch. - """ - # Main synthetic sensors YAML - main_yaml_path = ( - Path(hass.config.config_dir) / "custom_components" / "span_panel" / "span_sensors.yaml" - ) - if main_yaml_path.exists(): - main_yaml_path.unlink() - - # Solar synthetic sensors YAML - solar_yaml_path = ( - Path(hass.config.config_dir) - / "custom_components" - / "span_panel" - / "solar_synthetic_sensors.yaml" - ) - if solar_yaml_path.exists(): - solar_yaml_path.unlink() - - -def reset_span_sensor_manager_static_state() -> None: - """Reset static state in SpanSensorManager to prevent test pollution.""" - from custom_components.span_panel.span_sensor_manager import SpanSensorManager - - SpanSensorManager._static_registered_entities = None - SpanSensorManager._static_entities_generated = False - SpanSensorManager.static_entities_registered = False - - -def cleanup_synthetic_state(hass: HomeAssistant) -> None: - """Clean up both YAML files and static state for test independence.""" - cleanup_synthetic_yaml_files(hass) - reset_span_sensor_manager_static_state() - - -@contextmanager -def clean_synthetic_yaml_test(): - """Context manager that ensures clean YAML state for tests. - - This ensures tests start with no pre-existing YAML files and allows tests - to verify actual YAML generation behavior rather than relying on fixtures. - - Usage: - with clean_synthetic_yaml_test(): - # Test code that generates YAML from mock panel data - pass - # Caller should clean up YAML files in teardown - """ - try: - yield - finally: - # Note: We can't cleanup here directly since we don't have hass - # Tests using this context manager should call cleanup_synthetic_yaml_files - # in their teardown or use the setup_span_panel_entry_with_cleanup helper - pass - - -def setup_span_panel_entry_with_cleanup( - hass: HomeAssistant, - mock_api_responses: dict[str, Any] | None = None, - entry_id: str = "test_span_panel", - host: str = "192.168.1.100", - access_token: str = "test_token", - options: dict[str, Any] | None = None, - cleanup_yaml: bool = True, -) -> tuple[MockConfigEntry, dict[str, Any] | None]: - """Set up a Span Panel config entry with optional YAML cleanup. - - Args: - hass: Home Assistant instance - mock_api_responses: Mock API responses (defaults to complete panel response) - entry_id: Config entry ID - host: Panel host - access_token: Panel access token - options: Config entry options - cleanup_yaml: Whether to clean up existing YAML files before setup - - Returns: - Tuple of (config_entry, mock_api_responses) - - """ - if cleanup_yaml: - cleanup_synthetic_yaml_files(hass) - - return setup_span_panel_entry(hass, mock_api_responses, entry_id, host, access_token, options) - - -async def wait_for_synthetic_sensors(hass: HomeAssistant) -> None: - """Wait for synthetic sensors to be created by yielding to the event loop.""" - # Give synthetic sensors time to be processed by yielding to the event loop multiple times - for _ in range(5): - await hass.async_block_till_done() - - -async def wait_for_entity_state( - hass: HomeAssistant, - entity_id: str, - expected_state: Any, - timeout: float = 5.0, - check_interval: float = 0.1, -) -> None: - """Wait for an entity to reach the expected state with retry logic.""" - # First, yield to the event loop to let synthetic sensors complete - await wait_for_synthetic_sensors(hass) - - # Then do the assertion - assert_entity_state(hass, entity_id, expected_state) +async def mock_circuit_relay_operation(mock_client: AsyncMock) -> None: + """Mock a circuit relay operation.""" + mock_client.set_circuit_relay.return_value = None diff --git a/tests/migration_storage/1_0_10/core.device_registry b/tests/migration_storage/1_0_10/core.device_registry deleted file mode 100644 index 638b6fac..00000000 --- a/tests/migration_storage/1_0_10/core.device_registry +++ /dev/null @@ -1,39 +0,0 @@ -{ - "version": 1, - "minor_version": 11, - "key": "core.device_registry", - "data": { - "devices": [ - {"area_id":null,"config_entries":["01K2JBARFARWNFC92NKX1158S6"],"config_entries_subentries":{"01K2JBARFARWNFC92NKX1158S6":[null]},"configuration_url":"homeassistant://hassio/addon/core_matter_server","connections":[],"created_at":"2025-08-12T20:21:33.095150+00:00","disabled_by":null,"entry_type":"service","hw_version":null,"id":"4ca44f5be7c86da449cc8c12fecdfe2e","identifiers":[["hassio","core_matter_server"]],"labels":[],"manufacturer":"Official add-ons","model":"Home Assistant Add-on","model_id":null,"modified_at":"2025-08-15T19:01:00.238499+00:00","name_by_user":null,"name":"Matter Server","primary_config_entry":"01K2JBARFARWNFC92NKX1158S6","serial_number":null,"sw_version":"8.1.0","via_device_id":null}, - {"area_id":null,"config_entries":["01K2JBARFARWNFC92NKX1158S6"],"config_entries_subentries":{"01K2JBARFARWNFC92NKX1158S6":[null]},"configuration_url":"homeassistant://hassio/addon/cb646a50_get","connections":[],"created_at":"2025-08-12T20:21:33.095317+00:00","disabled_by":null,"entry_type":"service","hw_version":null,"id":"319613801556ba7699ae5096e9ec8005","identifiers":[["hassio","cb646a50_get"]],"labels":[],"manufacturer":"HACS Add-ons Repository","model":"Home Assistant Add-on","model_id":null,"modified_at":"2025-08-13T18:33:35.794534+00:00","name_by_user":null,"name":"Get HACS","primary_config_entry":"01K2JBARFARWNFC92NKX1158S6","serial_number":null,"sw_version":"1.3.1","via_device_id":null}, - {"area_id":null,"config_entries":["01K2JBARFARWNFC92NKX1158S6"],"config_entries_subentries":{"01K2JBARFARWNFC92NKX1158S6":[null]},"configuration_url":"homeassistant://hassio/addon/a0d7b954_ssh","connections":[],"created_at":"2025-08-12T20:21:33.095380+00:00","disabled_by":null,"entry_type":"service","hw_version":null,"id":"ab3731f6cd9cc377acd97036169f3f22","identifiers":[["hassio","a0d7b954_ssh"]],"labels":[],"manufacturer":"Home Assistant Community Add-ons","model":"Home Assistant Add-on","model_id":null,"modified_at":"2025-08-13T18:33:35.794610+00:00","name_by_user":null,"name":"Advanced SSH & Web Terminal","primary_config_entry":"01K2JBARFARWNFC92NKX1158S6","serial_number":null,"sw_version":"21.0.2","via_device_id":null}, - {"area_id":null,"config_entries":["01K2JBARFARWNFC92NKX1158S6"],"config_entries_subentries":{"01K2JBARFARWNFC92NKX1158S6":[null]},"configuration_url":"homeassistant://hassio/addon/core_samba","connections":[],"created_at":"2025-08-12T20:21:33.095433+00:00","disabled_by":null,"entry_type":"service","hw_version":null,"id":"501eb9aa456c4652973565fb732a3b4e","identifiers":[["hassio","core_samba"]],"labels":[],"manufacturer":"Official add-ons","model":"Home Assistant Add-on","model_id":null,"modified_at":"2025-08-13T18:33:35.794671+00:00","name_by_user":null,"name":"Samba share","primary_config_entry":"01K2JBARFARWNFC92NKX1158S6","serial_number":null,"sw_version":"12.5.2","via_device_id":null}, - {"area_id":null,"config_entries":["01K2JBARFARWNFC92NKX1158S6"],"config_entries_subentries":{"01K2JBARFARWNFC92NKX1158S6":[null]},"configuration_url":null,"connections":[],"created_at":"2025-08-12T20:21:33.095479+00:00","disabled_by":null,"entry_type":"service","hw_version":null,"id":"dde50258240cf5c0df0bc7a04fe78cc1","identifiers":[["hassio","core"]],"labels":[],"manufacturer":"Home Assistant","model":"Home Assistant Core","model_id":null,"modified_at":"2025-08-15T18:58:39.403985+00:00","name_by_user":null,"name":"Home Assistant Core","primary_config_entry":"01K2JBARFARWNFC92NKX1158S6","serial_number":null,"sw_version":"2025.8.2","via_device_id":null}, - {"area_id":null,"config_entries":["01K2JBARFARWNFC92NKX1158S6"],"config_entries_subentries":{"01K2JBARFARWNFC92NKX1158S6":[null]},"configuration_url":null,"connections":[],"created_at":"2025-08-12T20:21:33.095521+00:00","disabled_by":null,"entry_type":"service","hw_version":null,"id":"27af8b2f4b7134648570f505ee894f7f","identifiers":[["hassio","supervisor"]],"labels":[],"manufacturer":"Home Assistant","model":"Home Assistant Supervisor","model_id":null,"modified_at":"2025-08-13T18:33:35.794765+00:00","name_by_user":null,"name":"Home Assistant Supervisor","primary_config_entry":"01K2JBARFARWNFC92NKX1158S6","serial_number":null,"sw_version":"2025.08.1","via_device_id":null}, - {"area_id":null,"config_entries":["01K2JBARFARWNFC92NKX1158S6"],"config_entries_subentries":{"01K2JBARFARWNFC92NKX1158S6":[null]},"configuration_url":null,"connections":[],"created_at":"2025-08-12T20:21:33.095561+00:00","disabled_by":null,"entry_type":"service","hw_version":null,"id":"3621bb606c055e76c452a48d73240067","identifiers":[["hassio","host"]],"labels":[],"manufacturer":"Home Assistant","model":"Home Assistant Host","model_id":null,"modified_at":"2025-08-13T18:33:35.794809+00:00","name_by_user":null,"name":"Home Assistant Host","primary_config_entry":"01K2JBARFARWNFC92NKX1158S6","serial_number":null,"sw_version":null,"via_device_id":null}, - {"area_id":null,"config_entries":["01K2JBARFARWNFC92NKX1158S6"],"config_entries_subentries":{"01K2JBARFARWNFC92NKX1158S6":[null]},"configuration_url":null,"connections":[],"created_at":"2025-08-12T20:21:33.095619+00:00","disabled_by":null,"entry_type":"service","hw_version":null,"id":"82ecbe58a0057ea8b761de3de121d194","identifiers":[["hassio","OS"]],"labels":[],"manufacturer":"Home Assistant","model":"Home Assistant Operating System","model_id":null,"modified_at":"2025-08-15T19:01:00.238754+00:00","name_by_user":null,"name":"Home Assistant Operating System","primary_config_entry":"01K2JBARFARWNFC92NKX1158S6","serial_number":null,"sw_version":"16.1","via_device_id":null}, - {"area_id":null,"config_entries":["01K2JBASJA50R982BKFZB254V1"],"config_entries_subentries":{"01K2JBASJA50R982BKFZB254V1":[null]},"configuration_url":"homeassistant://config/backup","connections":[],"created_at":"2025-08-12T20:21:33.383381+00:00","disabled_by":null,"entry_type":"service","hw_version":null,"id":"299c00a506ba6f1ff71281a172def5dd","identifiers":[["backup","backup_manager"]],"labels":[],"manufacturer":"Home Assistant","model":"Home Assistant Backup","model_id":null,"modified_at":"2025-08-15T18:58:41.315237+00:00","name_by_user":null,"name":"Backup","primary_config_entry":"01K2JBASJA50R982BKFZB254V1","serial_number":null,"sw_version":"2025.8.2","via_device_id":null}, - {"area_id":null,"config_entries":["01K2JBAPRJGMHHG5E8GW97HFKG"],"config_entries_subentries":{"01K2JBAPRJGMHHG5E8GW97HFKG":[null]},"configuration_url":null,"connections":[],"created_at":"2025-08-13T18:33:25.525943+00:00","disabled_by":null,"entry_type":"service","hw_version":null,"id":"fe6a0e95694074756aef7b0d8db21451","identifiers":[["sun","01K2JBAPRJGMHHG5E8GW97HFKG"]],"labels":[],"manufacturer":null,"model":null,"model_id":null,"modified_at":"2025-08-13T18:33:25.525970+00:00","name_by_user":null,"name":"Sun","primary_config_entry":"01K2JBAPRJGMHHG5E8GW97HFKG","serial_number":null,"sw_version":null,"via_device_id":null}, - {"area_id":null,"config_entries":["01K2QC172ABPW4AB4Z5A3EAW3D"],"config_entries_subentries":{"01K2QC172ABPW4AB4Z5A3EAW3D":[null]},"configuration_url":"homeassistant://hacs","connections":[],"created_at":"2025-08-12T20:23:47.897933+00:00","disabled_by":null,"entry_type":"service","hw_version":null,"id":"8badde1dee15f240cb614586522bd66f","identifiers":[["hacs","0717a0cd-745c-48fd-9b16-c8534c9704f9-bc944b0f-fd42-4a58-a072-ade38d1444cd"]],"labels":[],"manufacturer":"hacs.xyz","model":"","model_id":null,"modified_at":"2025-08-15T17:21:55.342052+00:00","name_by_user":null,"name":"HACS","primary_config_entry":"01K2QC172ABPW4AB4Z5A3EAW3D","serial_number":null,"sw_version":"2.0.5","via_device_id":null}, - {"area_id":null,"config_entries":["01K2QC172ABPW4AB4Z5A3EAW3D"],"config_entries_subentries":{"01K2QC172ABPW4AB4Z5A3EAW3D":[null]},"configuration_url":"homeassistant://hacs/repository/553010184","connections":[],"created_at":"2025-08-12T20:23:47.898433+00:00","disabled_by":null,"entry_type":"service","hw_version":null,"id":"345a8a4d0aff3290705074aa63b96dc1","identifiers":[["hacs","553010184"]],"labels":[],"manufacturer":"SpanPanel","model":"integration","model_id":null,"modified_at":"2025-08-16T04:45:38.637009+00:00","name_by_user":null,"name":"Span Panel","primary_config_entry":"01K2QC172ABPW4AB4Z5A3EAW3D","serial_number":null,"sw_version":null,"via_device_id":null}, - {"area_id":null,"config_entries":["01K2QC172ABPW4AB4Z5A3EAW3D"],"config_entries_subentries":{"01K2QC172ABPW4AB4Z5A3EAW3D":[null]},"configuration_url":"homeassistant://hacs/repository/665078127","connections":[],"created_at":"2025-08-12T20:34:58.267730+00:00","disabled_by":null,"entry_type":"service","hw_version":null,"id":"bf660ea2e3e81369c8a336033fbe2365","identifiers":[["hacs","665078127"]],"labels":[],"manufacturer":"arturpragacz","model":"integration","model_id":null,"modified_at":"2025-08-15T17:21:55.342715+00:00","name_by_user":null,"name":"Default Config Exclude","primary_config_entry":"01K2QC172ABPW4AB4Z5A3EAW3D","serial_number":null,"sw_version":null,"via_device_id":null}, - {"area_id":null,"config_entries":["01K2QC172ABPW4AB4Z5A3EAW3D"],"config_entries_subentries":{"01K2QC172ABPW4AB4Z5A3EAW3D":[null]},"configuration_url":"homeassistant://hacs/repository/665077422","connections":[],"created_at":"2025-08-12T20:34:07.692060+00:00","disabled_by":null,"entry_type":"service","hw_version":null,"id":"3fcfd35f39a7fd5b6d00ce3ca2fb6f90","identifiers":[["hacs","665077422"]],"labels":[],"manufacturer":"arturpragacz","model":"integration","model_id":null,"modified_at":"2025-08-15T17:21:55.342898+00:00","name_by_user":null,"name":"Early Loader","primary_config_entry":"01K2QC172ABPW4AB4Z5A3EAW3D","serial_number":null,"sw_version":null,"via_device_id":null}, - {"area_id":null,"config_entries":["01K2RK5SERMY18KM0CMQTGAMNY"],"config_entries_subentries":{"01K2RK5SERMY18KM0CMQTGAMNY":[null]},"configuration_url":"http://192.168.65.70","connections":[],"created_at":"2025-08-12T20:29:55.965191+00:00","disabled_by":null,"entry_type":null,"hw_version":null,"id":"a00dc2483acb31f18fb95e5d7a6ee3a4","identifiers":[["span_panel","nj-2316-005k6"]],"labels":[],"manufacturer":"Span","model":"Span Panel (00200)","model_id":null,"modified_at":"2025-08-16T04:46:00.475815+00:00","name_by_user":null,"name":"Span Panel","primary_config_entry":"01K2RK5SERMY18KM0CMQTGAMNY","serial_number":null,"sw_version":"spanos2/r202525/05","via_device_id":null} - ], - "deleted_devices": [ - {"area_id":null,"config_entries":["01K2FZ30F62NF62FJ79KJ5ZXHK"],"config_entries_subentries":{"01K2FZ30F62NF62FJ79KJ5ZXHK":[null]},"connections":[],"created_at":"2025-08-12T20:21:01.544373+00:00","disabled_by":null,"identifiers":[["sun","01K2FZ30F62NF62FJ79KJ5ZXHK"]],"id":"93880f170c481e7b6a76fdb1d006f781","labels":[],"modified_at":"2025-08-12T20:49:00.956658+00:00","name_by_user":null,"orphaned_timestamp":null}, - {"area_id":null,"config_entries":["01K2G0NYH80DCKVGCMD9AS1TTS"],"config_entries_subentries":{"01K2G0NYH80DCKVGCMD9AS1TTS":[null]},"connections":[],"created_at":"2025-08-12T20:48:50.729866+00:00","disabled_by":null,"identifiers":[["sun","01K2G0NYH80DCKVGCMD9AS1TTS"]],"id":"68813cc19c9f0689b32620fea5951106","labels":[],"modified_at":"2025-08-12T21:03:25.826122+00:00","name_by_user":null,"orphaned_timestamp":null}, - {"area_id":null,"config_entries":["01K2G1GB4DMFSVY3M5FETX1M19"],"config_entries_subentries":{"01K2G1GB4DMFSVY3M5FETX1M19":[null]},"connections":[],"created_at":"2025-08-12T21:03:15.598684+00:00","disabled_by":null,"identifiers":[["sun","01K2G1GB4DMFSVY3M5FETX1M19"]],"id":"a4cc9491df1303fde823bda19e2ddf09","labels":[],"modified_at":"2025-08-12T21:27:09.457765+00:00","name_by_user":null,"orphaned_timestamp":null}, - {"area_id":null,"config_entries":["01K2G2VSCAFTC14E0R1NPSX97N"],"config_entries_subentries":{"01K2G2VSCAFTC14E0R1NPSX97N":[null]},"connections":[],"created_at":"2025-08-12T21:26:59.212411+00:00","disabled_by":null,"identifiers":[["sun","01K2G2VSCAFTC14E0R1NPSX97N"]],"id":"ec40ca8756f77a7d5d3c60dc1dbdba08","labels":[],"modified_at":"2025-08-12T21:36:44.167660+00:00","name_by_user":null,"orphaned_timestamp":null}, - {"area_id":null,"config_entries":["01K2G3DAKZMG3KMB8ZX5Z54T3R"],"config_entries_subentries":{"01K2G3DAKZMG3KMB8ZX5Z54T3R":[null]},"connections":[],"created_at":"2025-08-12T21:36:33.921474+00:00","disabled_by":null,"identifiers":[["sun","01K2G3DAKZMG3KMB8ZX5Z54T3R"]],"id":"543d725a0dce8f8882ac863a47723715","labels":[],"modified_at":"2025-08-12T21:44:04.774194+00:00","name_by_user":null,"orphaned_timestamp":null}, - {"area_id":null,"config_entries":["01K2G3TRX0NV4DS8C09G5B4TYT"],"config_entries_subentries":{"01K2G3TRX0NV4DS8C09G5B4TYT":[null]},"connections":[],"created_at":"2025-08-12T21:43:54.531116+00:00","disabled_by":null,"identifiers":[["sun","01K2G3TRX0NV4DS8C09G5B4TYT"]],"id":"1c53f15c4af102c698e0a6a751e9dab1","labels":[],"modified_at":"2025-08-13T05:01:09.287568+00:00","name_by_user":null,"orphaned_timestamp":null}, - {"area_id":null,"config_entries":["01K2GWV2T4WST7QXWQ837WF92S"],"config_entries_subentries":{"01K2GWV2T4WST7QXWQ837WF92S":[null]},"connections":[],"created_at":"2025-08-13T05:00:59.079405+00:00","disabled_by":null,"identifiers":[["sun","01K2GWV2T4WST7QXWQ837WF92S"]],"id":"38077ecc8774a558bce7942dc371d1b3","labels":[],"modified_at":"2025-08-13T05:20:07.934300+00:00","name_by_user":null,"orphaned_timestamp":null}, - {"area_id":null,"config_entries":["01K2GXXTQT3SD7C7BPVK0P6J7W"],"config_entries_subentries":{"01K2GXXTQT3SD7C7BPVK0P6J7W":[null]},"connections":[],"created_at":"2025-08-13T05:19:57.699973+00:00","disabled_by":null,"identifiers":[["sun","01K2GXXTQT3SD7C7BPVK0P6J7W"]],"id":"8bec2ab050585eb1b60608c1674c649c","labels":[],"modified_at":"2025-08-13T05:47:39.871499+00:00","name_by_user":null,"orphaned_timestamp":null}, - {"area_id":null,"config_entries":["01K2GZG7XD3DQQSDY3SR568B10"],"config_entries_subentries":{"01K2GZG7XD3DQQSDY3SR568B10":[null]},"connections":[],"created_at":"2025-08-13T05:47:29.585960+00:00","disabled_by":null,"identifiers":[["sun","01K2GZG7XD3DQQSDY3SR568B10"]],"id":"8fb1f4d2d55ddeb8c6948a5a4d255c9a","labels":[],"modified_at":"2025-08-13T05:57:40.368496+00:00","name_by_user":null,"orphaned_timestamp":null}, - {"area_id":null,"config_entries":["01K2H02JC3PVKEJT3VG6PS4V8R"],"config_entries_subentries":{"01K2H02JC3PVKEJT3VG6PS4V8R":[null]},"connections":[],"created_at":"2025-08-13T05:57:30.116469+00:00","disabled_by":null,"identifiers":[["sun","01K2H02JC3PVKEJT3VG6PS4V8R"]],"id":"e05308c1b44259985b4d2ae0ab322568","labels":[],"modified_at":"2025-08-13T06:17:25.186694+00:00","name_by_user":null,"orphaned_timestamp":null}, - {"area_id":null,"config_entries":["01K2H16QEC8NYDACSS5HKKBK0P"],"config_entries_subentries":{"01K2H16QEC8NYDACSS5HKKBK0P":[null]},"connections":[],"created_at":"2025-08-13T06:17:14.959959+00:00","disabled_by":null,"identifiers":[["sun","01K2H16QEC8NYDACSS5HKKBK0P"]],"id":"ce855d4326fbc2a266ba375b604ea5a3","labels":[],"modified_at":"2025-08-13T06:39:26.301646+00:00","name_by_user":null,"orphaned_timestamp":null}, - {"area_id":null,"config_entries":["01K2H2F1HB23ZT9V5HFE1WFN0H"],"config_entries_subentries":{"01K2H2F1HB23ZT9V5HFE1WFN0H":[null]},"connections":[],"created_at":"2025-08-13T06:39:16.017514+00:00","disabled_by":null,"identifiers":[["sun","01K2H2F1HB23ZT9V5HFE1WFN0H"]],"id":"2a65baa50cf7015a7c17271b41860022","labels":[],"modified_at":"2025-08-13T06:50:57.044756+00:00","name_by_user":null,"orphaned_timestamp":null}, - {"area_id":null,"config_entries":["01K2H3443174B991E3DASPT0NG"],"config_entries_subentries":{"01K2H3443174B991E3DASPT0NG":[null]},"connections":[],"created_at":"2025-08-13T06:50:46.755358+00:00","disabled_by":null,"identifiers":[["sun","01K2H3443174B991E3DASPT0NG"]],"id":"e41583766bef1992877885dd1b136aaf","labels":[],"modified_at":"2025-08-13T18:33:35.794959+00:00","name_by_user":null,"orphaned_timestamp":null} - ] - } -} diff --git a/tests/migration_storage/1_0_10/core.entity_registry b/tests/migration_storage/1_0_10/core.entity_registry deleted file mode 100644 index 9337f5fc..00000000 --- a/tests/migration_storage/1_0_10/core.entity_registry +++ /dev/null @@ -1,522 +0,0 @@ -{ - "version": 1, - "minor_version": 18, - "key": "core.entity_registry", - "data": { - "entities": [ - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2DMG10SVYJV7JCJSP556YYM","config_subentry_id":null,"created_at":"2025-08-11T22:37:24.891397+00:00","device_class":null,"device_id":"98356fad7274b949637495af34010fe0","disabled_by":"integration","entity_category":"diagnostic","entity_id":"binary_sensor.sun_solar_rising","hidden_by":null,"icon":null,"id":"505fb32a1186895ad252ccd2831ba60e","has_entity_name":true,"labels":[],"modified_at":"2025-08-12T17:40:24.498039+00:00","name":null,"options":{},"original_device_class":null,"original_icon":null,"original_name":"Solar rising","platform":"sun","suggested_object_id":"sun_solar_rising","supported_features":0,"translation_key":"solar_rising","unique_id":"01K2DMG10SVYJV7JCJSP556YYM-solar_rising","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2DMG10SVYJV7JCJSP556YYM","config_subentry_id":null,"created_at":"2025-08-11T22:37:24.891781+00:00","device_class":null,"device_id":"98356fad7274b949637495af34010fe0","disabled_by":null,"entity_category":"diagnostic","entity_id":"sensor.sun_next_dawn","hidden_by":null,"icon":null,"id":"5344f00a484e5e7f1cf0d7781a50620a","has_entity_name":true,"labels":[],"modified_at":"2025-08-12T17:40:24.498559+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":"timestamp","original_icon":null,"original_name":"Next dawn","platform":"sun","suggested_object_id":"sun_next_dawn","supported_features":0,"translation_key":"next_dawn","unique_id":"01K2DMG10SVYJV7JCJSP556YYM-next_dawn","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2DMG10SVYJV7JCJSP556YYM","config_subentry_id":null,"created_at":"2025-08-11T22:37:24.892060+00:00","device_class":null,"device_id":"98356fad7274b949637495af34010fe0","disabled_by":null,"entity_category":"diagnostic","entity_id":"sensor.sun_next_dusk","hidden_by":null,"icon":null,"id":"5c8689cb42162ae7b078c2f5d185f5b9","has_entity_name":true,"labels":[],"modified_at":"2025-08-12T17:40:24.498779+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":"timestamp","original_icon":null,"original_name":"Next dusk","platform":"sun","suggested_object_id":"sun_next_dusk","supported_features":0,"translation_key":"next_dusk","unique_id":"01K2DMG10SVYJV7JCJSP556YYM-next_dusk","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2DMG10SVYJV7JCJSP556YYM","config_subentry_id":null,"created_at":"2025-08-11T22:37:24.892224+00:00","device_class":null,"device_id":"98356fad7274b949637495af34010fe0","disabled_by":null,"entity_category":"diagnostic","entity_id":"sensor.sun_next_midnight","hidden_by":null,"icon":null,"id":"be913918d16776a6e2936ed8bc526e15","has_entity_name":true,"labels":[],"modified_at":"2025-08-12T17:40:24.498902+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":"timestamp","original_icon":null,"original_name":"Next midnight","platform":"sun","suggested_object_id":"sun_next_midnight","supported_features":0,"translation_key":"next_midnight","unique_id":"01K2DMG10SVYJV7JCJSP556YYM-next_midnight","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2DMG10SVYJV7JCJSP556YYM","config_subentry_id":null,"created_at":"2025-08-11T22:37:24.892366+00:00","device_class":null,"device_id":"98356fad7274b949637495af34010fe0","disabled_by":null,"entity_category":"diagnostic","entity_id":"sensor.sun_next_noon","hidden_by":null,"icon":null,"id":"930477dda16282f2caba59589f6d60fd","has_entity_name":true,"labels":[],"modified_at":"2025-08-12T17:40:24.499019+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":"timestamp","original_icon":null,"original_name":"Next noon","platform":"sun","suggested_object_id":"sun_next_noon","supported_features":0,"translation_key":"next_noon","unique_id":"01K2DMG10SVYJV7JCJSP556YYM-next_noon","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2DMG10SVYJV7JCJSP556YYM","config_subentry_id":null,"created_at":"2025-08-11T22:37:24.892497+00:00","device_class":null,"device_id":"98356fad7274b949637495af34010fe0","disabled_by":null,"entity_category":"diagnostic","entity_id":"sensor.sun_next_rising","hidden_by":null,"icon":null,"id":"dcd0ad2ad1f1e698b4806639dd148b41","has_entity_name":true,"labels":[],"modified_at":"2025-08-12T17:40:24.499135+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":"timestamp","original_icon":null,"original_name":"Next rising","platform":"sun","suggested_object_id":"sun_next_rising","supported_features":0,"translation_key":"next_rising","unique_id":"01K2DMG10SVYJV7JCJSP556YYM-next_rising","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2DMG10SVYJV7JCJSP556YYM","config_subentry_id":null,"created_at":"2025-08-11T22:37:24.892625+00:00","device_class":null,"device_id":"98356fad7274b949637495af34010fe0","disabled_by":null,"entity_category":"diagnostic","entity_id":"sensor.sun_next_setting","hidden_by":null,"icon":null,"id":"de3e86c1dd89fd280d9bcb4136d694e7","has_entity_name":true,"labels":[],"modified_at":"2025-08-12T17:40:24.499246+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":"timestamp","original_icon":null,"original_name":"Next setting","platform":"sun","suggested_object_id":"sun_next_setting","supported_features":0,"translation_key":"next_setting","unique_id":"01K2DMG10SVYJV7JCJSP556YYM-next_setting","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"measurement"},"config_entry_id":"01K2DMG10SVYJV7JCJSP556YYM","config_subentry_id":null,"created_at":"2025-08-11T22:37:24.892748+00:00","device_class":null,"device_id":"98356fad7274b949637495af34010fe0","disabled_by":"integration","entity_category":"diagnostic","entity_id":"sensor.sun_solar_elevation","hidden_by":null,"icon":null,"id":"6cb4f115b37e560c1936fc2b3d09aa90","has_entity_name":true,"labels":[],"modified_at":"2025-08-12T17:40:24.499344+00:00","name":null,"options":{},"original_device_class":null,"original_icon":null,"original_name":"Solar elevation","platform":"sun","suggested_object_id":"sun_solar_elevation","supported_features":0,"translation_key":"solar_elevation","unique_id":"01K2DMG10SVYJV7JCJSP556YYM-solar_elevation","previous_unique_id":null,"unit_of_measurement":"°"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"measurement"},"config_entry_id":"01K2DMG10SVYJV7JCJSP556YYM","config_subentry_id":null,"created_at":"2025-08-11T22:37:24.892831+00:00","device_class":null,"device_id":"98356fad7274b949637495af34010fe0","disabled_by":"integration","entity_category":"diagnostic","entity_id":"sensor.sun_solar_azimuth","hidden_by":null,"icon":null,"id":"9d95d0ec0a03600aae48b6e9952dd321","has_entity_name":true,"labels":[],"modified_at":"2025-08-12T17:40:24.499403+00:00","name":null,"options":{},"original_device_class":null,"original_icon":null,"original_name":"Solar azimuth","platform":"sun","suggested_object_id":"sun_solar_azimuth","supported_features":0,"translation_key":"solar_azimuth","unique_id":"01K2DMG10SVYJV7JCJSP556YYM-solar_azimuth","previous_unique_id":null,"unit_of_measurement":"°"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2DMG10SVYJV7JCJSP556YYM","config_subentry_id":null,"created_at":"2025-08-11T22:37:24.892949+00:00","device_class":null,"device_id":"98356fad7274b949637495af34010fe0","disabled_by":"integration","entity_category":"diagnostic","entity_id":"sensor.sun_solar_rising","hidden_by":null,"icon":null,"id":"a71cb254fb1854776e0cca531026e429","has_entity_name":true,"labels":[],"modified_at":"2025-08-12T17:40:24.499462+00:00","name":null,"options":{},"original_device_class":null,"original_icon":null,"original_name":"Solar rising","platform":"sun","suggested_object_id":"sun_solar_rising","supported_features":0,"translation_key":"solar_rising","unique_id":"01K2DMG10SVYJV7JCJSP556YYM-solar_rising","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"measurement"},"config_entry_id":"01K2JBARFARWNFC92NKX1158S6","config_subentry_id":null,"created_at":"2025-08-11T22:37:27.324762+00:00","device_class":null,"device_id":"dde50258240cf5c0df0bc7a04fe78cc1","disabled_by":"integration","entity_category":null,"entity_id":"sensor.home_assistant_core_cpu_percent","hidden_by":null,"icon":null,"id":"bd542bc73fb6bd6e2fd985489c45ef1e","has_entity_name":true,"labels":[],"modified_at":"2025-08-13T18:33:27.288140+00:00","name":null,"options":{},"original_device_class":null,"original_icon":null,"original_name":"CPU percent","platform":"hassio","suggested_object_id":null,"supported_features":0,"translation_key":"cpu_percent","unique_id":"home_assistant_core_cpu_percent","previous_unique_id":null,"unit_of_measurement":"%"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"measurement"},"config_entry_id":"01K2JBARFARWNFC92NKX1158S6","config_subentry_id":null,"created_at":"2025-08-11T22:37:27.324961+00:00","device_class":null,"device_id":"dde50258240cf5c0df0bc7a04fe78cc1","disabled_by":"integration","entity_category":null,"entity_id":"sensor.home_assistant_core_memory_percent","hidden_by":null,"icon":null,"id":"6dfdab7fb83d054170986fee47019353","has_entity_name":true,"labels":[],"modified_at":"2025-08-13T18:33:27.288185+00:00","name":null,"options":{},"original_device_class":null,"original_icon":null,"original_name":"Memory percent","platform":"hassio","suggested_object_id":null,"supported_features":0,"translation_key":"memory_percent","unique_id":"home_assistant_core_memory_percent","previous_unique_id":null,"unit_of_measurement":"%"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"measurement"},"config_entry_id":"01K2JBARFARWNFC92NKX1158S6","config_subentry_id":null,"created_at":"2025-08-11T22:37:27.325055+00:00","device_class":null,"device_id":"27af8b2f4b7134648570f505ee894f7f","disabled_by":"integration","entity_category":null,"entity_id":"sensor.home_assistant_supervisor_cpu_percent","hidden_by":null,"icon":null,"id":"4c67f28c9f4bcee9e5a744c6f139c68a","has_entity_name":true,"labels":[],"modified_at":"2025-08-13T18:33:27.288235+00:00","name":null,"options":{},"original_device_class":null,"original_icon":null,"original_name":"CPU percent","platform":"hassio","suggested_object_id":null,"supported_features":0,"translation_key":"cpu_percent","unique_id":"home_assistant_supervisor_cpu_percent","previous_unique_id":null,"unit_of_measurement":"%"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"measurement"},"config_entry_id":"01K2JBARFARWNFC92NKX1158S6","config_subentry_id":null,"created_at":"2025-08-11T22:37:27.325127+00:00","device_class":null,"device_id":"27af8b2f4b7134648570f505ee894f7f","disabled_by":"integration","entity_category":null,"entity_id":"sensor.home_assistant_supervisor_memory_percent","hidden_by":null,"icon":null,"id":"2ecafcc11770edf8eb82732694de4e4c","has_entity_name":true,"labels":[],"modified_at":"2025-08-13T18:33:27.288277+00:00","name":null,"options":{},"original_device_class":null,"original_icon":null,"original_name":"Memory percent","platform":"hassio","suggested_object_id":null,"supported_features":0,"translation_key":"memory_percent","unique_id":"home_assistant_supervisor_memory_percent","previous_unique_id":null,"unit_of_measurement":"%"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2JBARFARWNFC92NKX1158S6","config_subentry_id":null,"created_at":"2025-08-11T22:37:27.325241+00:00","device_class":null,"device_id":"3621bb606c055e76c452a48d73240067","disabled_by":"integration","entity_category":"diagnostic","entity_id":"sensor.home_assistant_host_os_agent_version","hidden_by":null,"icon":null,"id":"fd96b884c479a931c0f80f5d1fd108bc","has_entity_name":true,"labels":[],"modified_at":"2025-08-13T18:33:27.288332+00:00","name":null,"options":{},"original_device_class":null,"original_icon":null,"original_name":"OS Agent version","platform":"hassio","suggested_object_id":null,"supported_features":0,"translation_key":"agent_version","unique_id":"home_assistant_host_agent_version","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2JBARFARWNFC92NKX1158S6","config_subentry_id":null,"created_at":"2025-08-11T22:37:27.325313+00:00","device_class":null,"device_id":"3621bb606c055e76c452a48d73240067","disabled_by":"integration","entity_category":"diagnostic","entity_id":"sensor.home_assistant_host_apparmor_version","hidden_by":null,"icon":null,"id":"2e4d9630a39a96d5914f42442643156a","has_entity_name":true,"labels":[],"modified_at":"2025-08-13T18:33:27.288375+00:00","name":null,"options":{},"original_device_class":null,"original_icon":null,"original_name":"AppArmor version","platform":"hassio","suggested_object_id":null,"supported_features":0,"translation_key":"apparmor_version","unique_id":"home_assistant_host_apparmor_version","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2JBARFARWNFC92NKX1158S6","config_subentry_id":null,"created_at":"2025-08-11T22:37:27.325433+00:00","device_class":null,"device_id":"3621bb606c055e76c452a48d73240067","disabled_by":"integration","entity_category":"diagnostic","entity_id":"sensor.home_assistant_host_disk_total","hidden_by":null,"icon":null,"id":"e7ee092676bfc458d2bbbe256505bc42","has_entity_name":true,"labels":[],"modified_at":"2025-08-13T18:33:27.288427+00:00","name":null,"options":{},"original_device_class":"data_size","original_icon":null,"original_name":"Disk total","platform":"hassio","suggested_object_id":null,"supported_features":0,"translation_key":"disk_total","unique_id":"home_assistant_host_disk_total","previous_unique_id":null,"unit_of_measurement":"GB"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2JBARFARWNFC92NKX1158S6","config_subentry_id":null,"created_at":"2025-08-11T22:37:27.325557+00:00","device_class":null,"device_id":"3621bb606c055e76c452a48d73240067","disabled_by":"integration","entity_category":"diagnostic","entity_id":"sensor.home_assistant_host_disk_used","hidden_by":null,"icon":null,"id":"2ea268ad20f83dcebe9879610ce40e53","has_entity_name":true,"labels":[],"modified_at":"2025-08-13T18:33:27.288474+00:00","name":null,"options":{},"original_device_class":"data_size","original_icon":null,"original_name":"Disk used","platform":"hassio","suggested_object_id":null,"supported_features":0,"translation_key":"disk_used","unique_id":"home_assistant_host_disk_used","previous_unique_id":null,"unit_of_measurement":"GB"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2JBARFARWNFC92NKX1158S6","config_subentry_id":null,"created_at":"2025-08-11T22:37:27.325686+00:00","device_class":null,"device_id":"3621bb606c055e76c452a48d73240067","disabled_by":"integration","entity_category":"diagnostic","entity_id":"sensor.home_assistant_host_disk_free","hidden_by":null,"icon":null,"id":"af5c1c80fd3178a2d341aac638915637","has_entity_name":true,"labels":[],"modified_at":"2025-08-13T18:33:27.288518+00:00","name":null,"options":{},"original_device_class":"data_size","original_icon":null,"original_name":"Disk free","platform":"hassio","suggested_object_id":null,"supported_features":0,"translation_key":"disk_free","unique_id":"home_assistant_host_disk_free","previous_unique_id":null,"unit_of_measurement":"GB"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2JBARFARWNFC92NKX1158S6","config_subentry_id":null,"created_at":"2025-08-11T22:37:27.325807+00:00","device_class":null,"device_id":"82ecbe58a0057ea8b761de3de121d194","disabled_by":"integration","entity_category":null,"entity_id":"sensor.home_assistant_operating_system_version","hidden_by":null,"icon":null,"id":"9c59166dc8fb8b25320cea1a03a18b3a","has_entity_name":true,"labels":[],"modified_at":"2025-08-13T18:33:27.288583+00:00","name":null,"options":{},"original_device_class":null,"original_icon":null,"original_name":"Version","platform":"hassio","suggested_object_id":null,"supported_features":0,"translation_key":"version","unique_id":"home_assistant_os_version","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2JBARFARWNFC92NKX1158S6","config_subentry_id":null,"created_at":"2025-08-11T22:37:27.325956+00:00","device_class":null,"device_id":"82ecbe58a0057ea8b761de3de121d194","disabled_by":"integration","entity_category":null,"entity_id":"sensor.home_assistant_operating_system_newest_version","hidden_by":null,"icon":null,"id":"b01c36a6867982e17119cfc1ce5828c9","has_entity_name":true,"labels":[],"modified_at":"2025-08-13T18:33:27.288636+00:00","name":null,"options":{},"original_device_class":null,"original_icon":null,"original_name":"Newest version","platform":"hassio","suggested_object_id":null,"supported_features":0,"translation_key":"version_latest","unique_id":"home_assistant_os_version_latest","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2JBARFARWNFC92NKX1158S6","config_subentry_id":null,"created_at":"2025-08-11T22:37:27.326928+00:00","device_class":null,"device_id":"27af8b2f4b7134648570f505ee894f7f","disabled_by":null,"entity_category":"config","entity_id":"update.home_assistant_supervisor_update","hidden_by":null,"icon":null,"id":"8222b807c5439892ed0f1c5ec3fd33ef","has_entity_name":true,"labels":[],"modified_at":"2025-08-13T18:33:27.289308+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":null,"original_icon":null,"original_name":"Update","platform":"hassio","suggested_object_id":null,"supported_features":1,"translation_key":"update","unique_id":"home_assistant_supervisor_version_latest","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2JBARFARWNFC92NKX1158S6","config_subentry_id":null,"created_at":"2025-08-11T22:37:27.327314+00:00","device_class":null,"device_id":"dde50258240cf5c0df0bc7a04fe78cc1","disabled_by":null,"entity_category":"config","entity_id":"update.home_assistant_core_update","hidden_by":null,"icon":null,"id":"4a3c281a1e96cab3b0a5604ad85df058","has_entity_name":true,"labels":[],"modified_at":"2025-08-13T18:33:27.289529+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":null,"original_icon":null,"original_name":"Update","platform":"hassio","suggested_object_id":null,"supported_features":11,"translation_key":"update","unique_id":"home_assistant_core_version_latest","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2JBARFARWNFC92NKX1158S6","config_subentry_id":null,"created_at":"2025-08-11T22:37:27.327597+00:00","device_class":null,"device_id":"82ecbe58a0057ea8b761de3de121d194","disabled_by":null,"entity_category":"config","entity_id":"update.home_assistant_operating_system_update","hidden_by":null,"icon":null,"id":"574cba0e211676652c8b24bec0b8fb4f","has_entity_name":true,"labels":[],"modified_at":"2025-08-13T18:33:27.290136+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":null,"original_icon":null,"original_name":"Update","platform":"hassio","suggested_object_id":null,"supported_features":11,"translation_key":"update","unique_id":"home_assistant_os_version_latest","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"event_types":["completed","failed","in_progress"]},"config_entry_id":"01K2JBASJA50R982BKFZB254V1","config_subentry_id":null,"created_at":"2025-08-11T22:37:29.819897+00:00","device_class":null,"device_id":"299c00a506ba6f1ff71281a172def5dd","disabled_by":null,"entity_category":null,"entity_id":"event.backup_automatic_backup","hidden_by":null,"icon":null,"id":"bc5d21b2728e00a78c1969e317e2aef1","has_entity_name":true,"labels":[],"modified_at":"2025-08-13T18:33:28.465850+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":null,"original_icon":null,"original_name":"Automatic backup","platform":"backup","suggested_object_id":null,"supported_features":0,"translation_key":"automatic_backup_event","unique_id":"automatic_backup_event","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"options":["idle","create_backup","blocked","receive_backup","restore_backup"]},"config_entry_id":"01K2JBASJA50R982BKFZB254V1","config_subentry_id":null,"created_at":"2025-08-11T22:37:29.820383+00:00","device_class":null,"device_id":"299c00a506ba6f1ff71281a172def5dd","disabled_by":null,"entity_category":null,"entity_id":"sensor.backup_backup_manager_state","hidden_by":null,"icon":null,"id":"4fe60bdd7f2fe703bd4d8a1462f69611","has_entity_name":true,"labels":[],"modified_at":"2025-08-13T18:33:28.466402+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":"enum","original_icon":null,"original_name":"Backup Manager state","platform":"backup","suggested_object_id":null,"supported_features":0,"translation_key":"backup_manager_state","unique_id":"backup_manager_state","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2JBASJA50R982BKFZB254V1","config_subentry_id":null,"created_at":"2025-08-11T22:37:29.820666+00:00","device_class":null,"device_id":"299c00a506ba6f1ff71281a172def5dd","disabled_by":null,"entity_category":null,"entity_id":"sensor.backup_next_scheduled_automatic_backup","hidden_by":null,"icon":null,"id":"a2da661cde5b861527123a48af8270df","has_entity_name":true,"labels":[],"modified_at":"2025-08-13T18:33:28.466591+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":"timestamp","original_icon":null,"original_name":"Next scheduled automatic backup","platform":"backup","suggested_object_id":null,"supported_features":0,"translation_key":"next_scheduled_automatic_backup","unique_id":"next_scheduled_automatic_backup","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2JBASJA50R982BKFZB254V1","config_subentry_id":null,"created_at":"2025-08-11T22:37:29.820855+00:00","device_class":null,"device_id":"299c00a506ba6f1ff71281a172def5dd","disabled_by":null,"entity_category":null,"entity_id":"sensor.backup_last_successful_automatic_backup","hidden_by":null,"icon":null,"id":"a64cc16b9aa254c8a78c7c8a9a824068","has_entity_name":true,"labels":[],"modified_at":"2025-08-13T18:33:28.466739+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":"timestamp","original_icon":null,"original_name":"Last successful automatic backup","platform":"backup","suggested_object_id":null,"supported_features":0,"translation_key":"last_successful_automatic_backup","unique_id":"last_successful_automatic_backup","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2JBASJA50R982BKFZB254V1","config_subentry_id":null,"created_at":"2025-08-11T22:37:29.821087+00:00","device_class":null,"device_id":"299c00a506ba6f1ff71281a172def5dd","disabled_by":null,"entity_category":null,"entity_id":"sensor.backup_last_attempted_automatic_backup","hidden_by":null,"icon":null,"id":"063919031930e81cf591487d66d2e712","has_entity_name":true,"labels":[],"modified_at":"2025-08-13T18:33:28.466882+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":"timestamp","original_icon":null,"original_name":"Last attempted automatic backup","platform":"backup","suggested_object_id":null,"supported_features":0,"translation_key":"last_attempted_automatic_backup","unique_id":"last_attempted_automatic_backup","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{},"config_entry_id":"01K2DMG5TTSC7HV00CD08JNT9B","config_subentry_id":null,"created_at":"2025-08-11T22:37:30.334260+00:00","device_class":null,"device_id":"a6cd4cd3fc304cee7f7d57571cf48c6e","disabled_by":null,"entity_category":null,"entity_id":"media_player.s95qr_129","hidden_by":null,"icon":null,"id":"aeb9764c6340d3d21d7c10dc0a1d356b","has_entity_name":true,"labels":[],"modified_at":"2025-08-12T20:05:09.813431+00:00","name":null,"options":{"conversation":{"should_expose":true}},"original_device_class":"speaker","original_icon":null,"original_name":null,"platform":"cast","suggested_object_id":null,"supported_features":131968,"translation_key":null,"unique_id":"7700e652-df2b-869e-c330-8f572703c1ef","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2JBARFARWNFC92NKX1158S6","config_subentry_id":null,"created_at":"2025-08-11T22:38:49.420644+00:00","device_class":null,"device_id":"4ca44f5be7c86da449cc8c12fecdfe2e","disabled_by":"integration","entity_category":null,"entity_id":"binary_sensor.matter_server_running","hidden_by":null,"icon":null,"id":"1d8b3073fb1ca4a8639896768ab5902e","has_entity_name":true,"labels":[],"modified_at":"2025-08-13T18:33:27.286628+00:00","name":null,"options":{},"original_device_class":"running","original_icon":null,"original_name":"Running","platform":"hassio","suggested_object_id":null,"supported_features":0,"translation_key":"state","unique_id":"core_matter_server_state","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2JBARFARWNFC92NKX1158S6","config_subentry_id":null,"created_at":"2025-08-11T22:38:49.421620+00:00","device_class":null,"device_id":"4ca44f5be7c86da449cc8c12fecdfe2e","disabled_by":"integration","entity_category":null,"entity_id":"sensor.matter_server_version","hidden_by":null,"icon":null,"id":"a47702c079127d9f84c4eb18e019b77c","has_entity_name":true,"labels":[],"modified_at":"2025-08-13T18:33:27.287246+00:00","name":null,"options":{},"original_device_class":null,"original_icon":null,"original_name":"Version","platform":"hassio","suggested_object_id":null,"supported_features":0,"translation_key":"version","unique_id":"core_matter_server_version","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2JBARFARWNFC92NKX1158S6","config_subentry_id":null,"created_at":"2025-08-11T22:38:49.421844+00:00","device_class":null,"device_id":"4ca44f5be7c86da449cc8c12fecdfe2e","disabled_by":"integration","entity_category":null,"entity_id":"sensor.matter_server_newest_version","hidden_by":null,"icon":null,"id":"1d48f1def5e63bc0585ef9f5f63c5705","has_entity_name":true,"labels":[],"modified_at":"2025-08-13T18:33:27.287319+00:00","name":null,"options":{},"original_device_class":null,"original_icon":null,"original_name":"Newest version","platform":"hassio","suggested_object_id":null,"supported_features":0,"translation_key":"version_latest","unique_id":"core_matter_server_version_latest","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"measurement"},"config_entry_id":"01K2JBARFARWNFC92NKX1158S6","config_subentry_id":null,"created_at":"2025-08-11T22:38:49.421959+00:00","device_class":null,"device_id":"4ca44f5be7c86da449cc8c12fecdfe2e","disabled_by":"integration","entity_category":null,"entity_id":"sensor.matter_server_cpu_percent","hidden_by":null,"icon":null,"id":"f706ae7eef88b84ecec8c0ed9cdfb673","has_entity_name":true,"labels":[],"modified_at":"2025-08-13T18:33:27.287373+00:00","name":null,"options":{},"original_device_class":null,"original_icon":null,"original_name":"CPU percent","platform":"hassio","suggested_object_id":null,"supported_features":0,"translation_key":"cpu_percent","unique_id":"core_matter_server_cpu_percent","previous_unique_id":null,"unit_of_measurement":"%"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"measurement"},"config_entry_id":"01K2JBARFARWNFC92NKX1158S6","config_subentry_id":null,"created_at":"2025-08-11T22:38:49.422114+00:00","device_class":null,"device_id":"4ca44f5be7c86da449cc8c12fecdfe2e","disabled_by":"integration","entity_category":null,"entity_id":"sensor.matter_server_memory_percent","hidden_by":null,"icon":null,"id":"1cd9ae5755e3a8ab27e34e8e62732454","has_entity_name":true,"labels":[],"modified_at":"2025-08-13T18:33:27.287437+00:00","name":null,"options":{},"original_device_class":null,"original_icon":null,"original_name":"Memory percent","platform":"hassio","suggested_object_id":null,"supported_features":0,"translation_key":"memory_percent","unique_id":"core_matter_server_memory_percent","previous_unique_id":null,"unit_of_measurement":"%"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2JBARFARWNFC92NKX1158S6","config_subentry_id":null,"created_at":"2025-08-11T22:38:49.424388+00:00","device_class":null,"device_id":"4ca44f5be7c86da449cc8c12fecdfe2e","disabled_by":null,"entity_category":"config","entity_id":"update.matter_server_update","hidden_by":null,"icon":null,"id":"738e2a23164974880106068fb7d7b01f","has_entity_name":true,"labels":[],"modified_at":"2025-08-13T18:33:27.289661+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":null,"original_icon":null,"original_name":"Update","platform":"hassio","suggested_object_id":null,"supported_features":25,"translation_key":"update","unique_id":"core_matter_server_version_latest","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-11T22:39:06.363902+00:00","device_class":null,"device_id":null,"disabled_by":null,"entity_category":null,"entity_id":"person.bill","hidden_by":null,"icon":null,"id":"27ce97a9b461ec77767f3958539900f8","has_entity_name":false,"labels":[],"modified_at":"2025-08-11T22:39:06.364322+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":null,"original_icon":null,"original_name":"bill","platform":"person","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"bill","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2DMK773VYDC504YGAB6SJ5N","config_subentry_id":null,"created_at":"2025-08-11T22:39:09.651714+00:00","device_class":null,"device_id":null,"disabled_by":null,"entity_category":null,"entity_id":"todo.shopping_list","hidden_by":null,"icon":null,"id":"3d020bdedd48c3a6b254cd1724e1227e","has_entity_name":true,"labels":[],"modified_at":"2025-08-11T22:39:09.651924+00:00","name":null,"options":{"conversation":{"should_expose":true}},"original_device_class":null,"original_icon":null,"original_name":"Shopping List","platform":"shopping_list","suggested_object_id":null,"supported_features":15,"translation_key":"shopping_list","unique_id":"01K2DMK773VYDC504YGAB6SJ5N","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2DMK7788KDKPFBXAPW4NG9W","config_subentry_id":null,"created_at":"2025-08-11T22:39:09.672560+00:00","device_class":null,"device_id":null,"disabled_by":null,"entity_category":null,"entity_id":"tts.google_translate_en_com","hidden_by":null,"icon":null,"id":"1be496a9596ca31bf34db7f2b4fa47f3","has_entity_name":false,"labels":[],"modified_at":"2025-08-11T22:39:09.672770+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":null,"original_icon":null,"original_name":"Google Translate en com","platform":"google_translate","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"01K2DMK7788KDKPFBXAPW4NG9W","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2JBARFARWNFC92NKX1158S6","config_subentry_id":null,"created_at":"2025-08-11T22:48:45.277581+00:00","device_class":null,"device_id":"319613801556ba7699ae5096e9ec8005","disabled_by":"integration","entity_category":null,"entity_id":"binary_sensor.get_hacs_running","hidden_by":null,"icon":null,"id":"a527527813498d5b1f5613bdc5437d04","has_entity_name":true,"labels":[],"modified_at":"2025-08-13T18:33:27.286714+00:00","name":null,"options":{},"original_device_class":"running","original_icon":null,"original_name":"Running","platform":"hassio","suggested_object_id":null,"supported_features":0,"translation_key":"state","unique_id":"cb646a50_get_state","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2JBARFARWNFC92NKX1158S6","config_subentry_id":null,"created_at":"2025-08-11T22:48:45.278400+00:00","device_class":null,"device_id":"319613801556ba7699ae5096e9ec8005","disabled_by":"integration","entity_category":null,"entity_id":"sensor.get_hacs_version","hidden_by":null,"icon":null,"id":"7a0e6fc6a227d104c77186c708f33f87","has_entity_name":true,"labels":[],"modified_at":"2025-08-13T18:33:27.287489+00:00","name":null,"options":{},"original_device_class":null,"original_icon":null,"original_name":"Version","platform":"hassio","suggested_object_id":null,"supported_features":0,"translation_key":"version","unique_id":"cb646a50_get_version","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2JBARFARWNFC92NKX1158S6","config_subentry_id":null,"created_at":"2025-08-11T22:48:45.278515+00:00","device_class":null,"device_id":"319613801556ba7699ae5096e9ec8005","disabled_by":"integration","entity_category":null,"entity_id":"sensor.get_hacs_newest_version","hidden_by":null,"icon":null,"id":"5a97ea35ea2f330732578dba80598aae","has_entity_name":true,"labels":[],"modified_at":"2025-08-13T18:33:27.287540+00:00","name":null,"options":{},"original_device_class":null,"original_icon":null,"original_name":"Newest version","platform":"hassio","suggested_object_id":null,"supported_features":0,"translation_key":"version_latest","unique_id":"cb646a50_get_version_latest","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"measurement"},"config_entry_id":"01K2JBARFARWNFC92NKX1158S6","config_subentry_id":null,"created_at":"2025-08-11T22:48:45.278589+00:00","device_class":null,"device_id":"319613801556ba7699ae5096e9ec8005","disabled_by":"integration","entity_category":null,"entity_id":"sensor.get_hacs_cpu_percent","hidden_by":null,"icon":null,"id":"c7173b1bcf5714b635e0f882ac48eebc","has_entity_name":true,"labels":[],"modified_at":"2025-08-13T18:33:27.287587+00:00","name":null,"options":{},"original_device_class":null,"original_icon":null,"original_name":"CPU percent","platform":"hassio","suggested_object_id":null,"supported_features":0,"translation_key":"cpu_percent","unique_id":"cb646a50_get_cpu_percent","previous_unique_id":null,"unit_of_measurement":"%"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"measurement"},"config_entry_id":"01K2JBARFARWNFC92NKX1158S6","config_subentry_id":null,"created_at":"2025-08-11T22:48:45.278651+00:00","device_class":null,"device_id":"319613801556ba7699ae5096e9ec8005","disabled_by":"integration","entity_category":null,"entity_id":"sensor.get_hacs_memory_percent","hidden_by":null,"icon":null,"id":"c2f1c96d08bbba69ea5ca8e731458bc1","has_entity_name":true,"labels":[],"modified_at":"2025-08-13T18:33:27.287631+00:00","name":null,"options":{},"original_device_class":null,"original_icon":null,"original_name":"Memory percent","platform":"hassio","suggested_object_id":null,"supported_features":0,"translation_key":"memory_percent","unique_id":"cb646a50_get_memory_percent","previous_unique_id":null,"unit_of_measurement":"%"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2JBARFARWNFC92NKX1158S6","config_subentry_id":null,"created_at":"2025-08-11T22:48:45.280735+00:00","device_class":null,"device_id":"319613801556ba7699ae5096e9ec8005","disabled_by":null,"entity_category":"config","entity_id":"update.get_hacs_update","hidden_by":null,"icon":null,"id":"c388d195dc6b88db915ddca67825abe0","has_entity_name":true,"labels":[],"modified_at":"2025-08-13T18:33:27.289788+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":null,"original_icon":null,"original_name":"Update","platform":"hassio","suggested_object_id":null,"supported_features":25,"translation_key":"update","unique_id":"cb646a50_get_version_latest","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2JBARFARWNFC92NKX1158S6","config_subentry_id":null,"created_at":"2025-08-12T00:01:09.002499+00:00","device_class":null,"device_id":"ab3731f6cd9cc377acd97036169f3f22","disabled_by":"integration","entity_category":null,"entity_id":"binary_sensor.advanced_ssh_web_terminal_running","hidden_by":null,"icon":null,"id":"dc49b76fa1cb20c9024e24d741f9c18f","has_entity_name":true,"labels":[],"modified_at":"2025-08-13T18:33:27.286762+00:00","name":null,"options":{},"original_device_class":"running","original_icon":null,"original_name":"Running","platform":"hassio","suggested_object_id":null,"supported_features":0,"translation_key":"state","unique_id":"a0d7b954_ssh_state","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2JBARFARWNFC92NKX1158S6","config_subentry_id":null,"created_at":"2025-08-12T00:01:09.003452+00:00","device_class":null,"device_id":"ab3731f6cd9cc377acd97036169f3f22","disabled_by":"integration","entity_category":null,"entity_id":"sensor.advanced_ssh_web_terminal_version","hidden_by":null,"icon":null,"id":"4dc48de24e208e0f0d2f94e8392a500a","has_entity_name":true,"labels":[],"modified_at":"2025-08-13T18:33:27.287678+00:00","name":null,"options":{},"original_device_class":null,"original_icon":null,"original_name":"Version","platform":"hassio","suggested_object_id":null,"supported_features":0,"translation_key":"version","unique_id":"a0d7b954_ssh_version","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2JBARFARWNFC92NKX1158S6","config_subentry_id":null,"created_at":"2025-08-12T00:01:09.003623+00:00","device_class":null,"device_id":"ab3731f6cd9cc377acd97036169f3f22","disabled_by":"integration","entity_category":null,"entity_id":"sensor.advanced_ssh_web_terminal_newest_version","hidden_by":null,"icon":null,"id":"93983cbb2852e36bc7ec51f79d1d6434","has_entity_name":true,"labels":[],"modified_at":"2025-08-13T18:33:27.287739+00:00","name":null,"options":{},"original_device_class":null,"original_icon":null,"original_name":"Newest version","platform":"hassio","suggested_object_id":null,"supported_features":0,"translation_key":"version_latest","unique_id":"a0d7b954_ssh_version_latest","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"measurement"},"config_entry_id":"01K2JBARFARWNFC92NKX1158S6","config_subentry_id":null,"created_at":"2025-08-12T00:01:09.003743+00:00","device_class":null,"device_id":"ab3731f6cd9cc377acd97036169f3f22","disabled_by":"integration","entity_category":null,"entity_id":"sensor.advanced_ssh_web_terminal_cpu_percent","hidden_by":null,"icon":null,"id":"e94e7cbb4a28f4aa3c070fdbf17cd47b","has_entity_name":true,"labels":[],"modified_at":"2025-08-13T18:33:27.287860+00:00","name":null,"options":{},"original_device_class":null,"original_icon":null,"original_name":"CPU percent","platform":"hassio","suggested_object_id":null,"supported_features":0,"translation_key":"cpu_percent","unique_id":"a0d7b954_ssh_cpu_percent","previous_unique_id":null,"unit_of_measurement":"%"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"measurement"},"config_entry_id":"01K2JBARFARWNFC92NKX1158S6","config_subentry_id":null,"created_at":"2025-08-12T00:01:09.003857+00:00","device_class":null,"device_id":"ab3731f6cd9cc377acd97036169f3f22","disabled_by":"integration","entity_category":null,"entity_id":"sensor.advanced_ssh_web_terminal_memory_percent","hidden_by":null,"icon":null,"id":"c3645e40c43ca58b2490baab89bf0725","has_entity_name":true,"labels":[],"modified_at":"2025-08-13T18:33:27.287909+00:00","name":null,"options":{},"original_device_class":null,"original_icon":null,"original_name":"Memory percent","platform":"hassio","suggested_object_id":null,"supported_features":0,"translation_key":"memory_percent","unique_id":"a0d7b954_ssh_memory_percent","previous_unique_id":null,"unit_of_measurement":"%"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2JBARFARWNFC92NKX1158S6","config_subentry_id":null,"created_at":"2025-08-12T00:01:09.005170+00:00","device_class":null,"device_id":"ab3731f6cd9cc377acd97036169f3f22","disabled_by":null,"entity_category":"config","entity_id":"update.advanced_ssh_web_terminal_update","hidden_by":null,"icon":null,"id":"305bad9b0a37bd2926e5b21233b2ad98","has_entity_name":true,"labels":[],"modified_at":"2025-08-13T18:33:27.289909+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":null,"original_icon":null,"original_name":"Update","platform":"hassio","suggested_object_id":null,"supported_features":25,"translation_key":"update","unique_id":"a0d7b954_ssh_version_latest","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2JBARFARWNFC92NKX1158S6","config_subentry_id":null,"created_at":"2025-08-12T00:21:08.870379+00:00","device_class":null,"device_id":"501eb9aa456c4652973565fb732a3b4e","disabled_by":"integration","entity_category":null,"entity_id":"binary_sensor.samba_share_running","hidden_by":null,"icon":null,"id":"84ccdd5d9d5e17ab55136b561e344202","has_entity_name":true,"labels":[],"modified_at":"2025-08-13T18:33:27.286823+00:00","name":null,"options":{},"original_device_class":"running","original_icon":null,"original_name":"Running","platform":"hassio","suggested_object_id":null,"supported_features":0,"translation_key":"state","unique_id":"core_samba_state","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2JBARFARWNFC92NKX1158S6","config_subentry_id":null,"created_at":"2025-08-12T00:21:08.871101+00:00","device_class":null,"device_id":"501eb9aa456c4652973565fb732a3b4e","disabled_by":"integration","entity_category":null,"entity_id":"sensor.samba_share_version","hidden_by":null,"icon":null,"id":"e6d8ad0c91a71d9818d89a03411281d1","has_entity_name":true,"labels":[],"modified_at":"2025-08-13T18:33:27.287953+00:00","name":null,"options":{},"original_device_class":null,"original_icon":null,"original_name":"Version","platform":"hassio","suggested_object_id":null,"supported_features":0,"translation_key":"version","unique_id":"core_samba_version","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2JBARFARWNFC92NKX1158S6","config_subentry_id":null,"created_at":"2025-08-12T00:21:08.871214+00:00","device_class":null,"device_id":"501eb9aa456c4652973565fb732a3b4e","disabled_by":"integration","entity_category":null,"entity_id":"sensor.samba_share_newest_version","hidden_by":null,"icon":null,"id":"3456c08c5547e1458231782e634d4598","has_entity_name":true,"labels":[],"modified_at":"2025-08-13T18:33:27.288001+00:00","name":null,"options":{},"original_device_class":null,"original_icon":null,"original_name":"Newest version","platform":"hassio","suggested_object_id":null,"supported_features":0,"translation_key":"version_latest","unique_id":"core_samba_version_latest","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"measurement"},"config_entry_id":"01K2JBARFARWNFC92NKX1158S6","config_subentry_id":null,"created_at":"2025-08-12T00:21:08.871288+00:00","device_class":null,"device_id":"501eb9aa456c4652973565fb732a3b4e","disabled_by":"integration","entity_category":null,"entity_id":"sensor.samba_share_cpu_percent","hidden_by":null,"icon":null,"id":"6b913bf86c8b36e365d59ae87281ba36","has_entity_name":true,"labels":[],"modified_at":"2025-08-13T18:33:27.288043+00:00","name":null,"options":{},"original_device_class":null,"original_icon":null,"original_name":"CPU percent","platform":"hassio","suggested_object_id":null,"supported_features":0,"translation_key":"cpu_percent","unique_id":"core_samba_cpu_percent","previous_unique_id":null,"unit_of_measurement":"%"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"measurement"},"config_entry_id":"01K2JBARFARWNFC92NKX1158S6","config_subentry_id":null,"created_at":"2025-08-12T00:21:08.871358+00:00","device_class":null,"device_id":"501eb9aa456c4652973565fb732a3b4e","disabled_by":"integration","entity_category":null,"entity_id":"sensor.samba_share_memory_percent","hidden_by":null,"icon":null,"id":"851556bbce1066ff8c8e477d7815204b","has_entity_name":true,"labels":[],"modified_at":"2025-08-13T18:33:27.288086+00:00","name":null,"options":{},"original_device_class":null,"original_icon":null,"original_name":"Memory percent","platform":"hassio","suggested_object_id":null,"supported_features":0,"translation_key":"memory_percent","unique_id":"core_samba_memory_percent","previous_unique_id":null,"unit_of_measurement":"%"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2JBARFARWNFC92NKX1158S6","config_subentry_id":null,"created_at":"2025-08-12T00:21:08.872505+00:00","device_class":null,"device_id":"501eb9aa456c4652973565fb732a3b4e","disabled_by":null,"entity_category":"config","entity_id":"update.samba_share_update","hidden_by":null,"icon":null,"id":"552e94d0827335a3488721e65888f85f","has_entity_name":true,"labels":[],"modified_at":"2025-08-13T18:33:27.290031+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":null,"original_icon":null,"original_name":"Update","platform":"hassio","suggested_object_id":null,"supported_features":25,"translation_key":"update","unique_id":"core_samba_version_latest","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2FY66Y1AN0G099D140MFDVM","config_subentry_id":null,"created_at":"2025-08-12T20:05:17.890797+00:00","device_class":null,"device_id":"469a3227d19a5115c26f30ed1e3388bb","disabled_by":"integration","entity_category":"diagnostic","entity_id":"binary_sensor.sun_solar_rising_2","hidden_by":null,"icon":null,"id":"87e0d716949b33a12d360be20db63981","has_entity_name":true,"labels":[],"modified_at":"2025-08-12T20:05:17.890855+00:00","name":null,"options":{},"original_device_class":null,"original_icon":null,"original_name":"Solar rising","platform":"sun","suggested_object_id":"sun_solar_rising","supported_features":0,"translation_key":"solar_rising","unique_id":"01K2FY66Y1AN0G099D140MFDVM-solar_rising","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2FY66Y1AN0G099D140MFDVM","config_subentry_id":null,"created_at":"2025-08-12T20:05:17.891144+00:00","device_class":null,"device_id":"469a3227d19a5115c26f30ed1e3388bb","disabled_by":null,"entity_category":"diagnostic","entity_id":"sensor.sun_next_dawn_2","hidden_by":null,"icon":null,"id":"9fe45ec0c31c55950ba7088bcea086fa","has_entity_name":true,"labels":[],"modified_at":"2025-08-12T20:05:47.904946+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":"timestamp","original_icon":null,"original_name":"Next dawn","platform":"sun","suggested_object_id":"sun_next_dawn","supported_features":0,"translation_key":"next_dawn","unique_id":"01K2FY66Y1AN0G099D140MFDVM-next_dawn","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2FY66Y1AN0G099D140MFDVM","config_subentry_id":null,"created_at":"2025-08-12T20:05:17.891704+00:00","device_class":null,"device_id":"469a3227d19a5115c26f30ed1e3388bb","disabled_by":null,"entity_category":"diagnostic","entity_id":"sensor.sun_next_dusk_2","hidden_by":null,"icon":null,"id":"db43f2363e79c1a8d3ca61cee45bcf20","has_entity_name":true,"labels":[],"modified_at":"2025-08-12T20:05:47.905205+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":"timestamp","original_icon":null,"original_name":"Next dusk","platform":"sun","suggested_object_id":"sun_next_dusk","supported_features":0,"translation_key":"next_dusk","unique_id":"01K2FY66Y1AN0G099D140MFDVM-next_dusk","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2FY66Y1AN0G099D140MFDVM","config_subentry_id":null,"created_at":"2025-08-12T20:05:17.891913+00:00","device_class":null,"device_id":"469a3227d19a5115c26f30ed1e3388bb","disabled_by":null,"entity_category":"diagnostic","entity_id":"sensor.sun_next_midnight_2","hidden_by":null,"icon":null,"id":"c94565bfd6e5282cb736f333e10843bd","has_entity_name":true,"labels":[],"modified_at":"2025-08-12T20:05:47.905283+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":"timestamp","original_icon":null,"original_name":"Next midnight","platform":"sun","suggested_object_id":"sun_next_midnight","supported_features":0,"translation_key":"next_midnight","unique_id":"01K2FY66Y1AN0G099D140MFDVM-next_midnight","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2FY66Y1AN0G099D140MFDVM","config_subentry_id":null,"created_at":"2025-08-12T20:05:17.892058+00:00","device_class":null,"device_id":"469a3227d19a5115c26f30ed1e3388bb","disabled_by":null,"entity_category":"diagnostic","entity_id":"sensor.sun_next_noon_2","hidden_by":null,"icon":null,"id":"584146f49e44f99dc6ade0c4b1d446f5","has_entity_name":true,"labels":[],"modified_at":"2025-08-12T20:05:47.905349+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":"timestamp","original_icon":null,"original_name":"Next noon","platform":"sun","suggested_object_id":"sun_next_noon","supported_features":0,"translation_key":"next_noon","unique_id":"01K2FY66Y1AN0G099D140MFDVM-next_noon","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2FY66Y1AN0G099D140MFDVM","config_subentry_id":null,"created_at":"2025-08-12T20:05:17.892176+00:00","device_class":null,"device_id":"469a3227d19a5115c26f30ed1e3388bb","disabled_by":null,"entity_category":"diagnostic","entity_id":"sensor.sun_next_rising_2","hidden_by":null,"icon":null,"id":"690d7b650d5641aa570cf88d0a2e3d95","has_entity_name":true,"labels":[],"modified_at":"2025-08-12T20:05:47.905404+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":"timestamp","original_icon":null,"original_name":"Next rising","platform":"sun","suggested_object_id":"sun_next_rising","supported_features":0,"translation_key":"next_rising","unique_id":"01K2FY66Y1AN0G099D140MFDVM-next_rising","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2FY66Y1AN0G099D140MFDVM","config_subentry_id":null,"created_at":"2025-08-12T20:05:17.892420+00:00","device_class":null,"device_id":"469a3227d19a5115c26f30ed1e3388bb","disabled_by":null,"entity_category":"diagnostic","entity_id":"sensor.sun_next_setting_2","hidden_by":null,"icon":null,"id":"eb5eca780da1bbaa73056658fc7180ad","has_entity_name":true,"labels":[],"modified_at":"2025-08-12T20:05:47.905455+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":"timestamp","original_icon":null,"original_name":"Next setting","platform":"sun","suggested_object_id":"sun_next_setting","supported_features":0,"translation_key":"next_setting","unique_id":"01K2FY66Y1AN0G099D140MFDVM-next_setting","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"measurement"},"config_entry_id":"01K2FY66Y1AN0G099D140MFDVM","config_subentry_id":null,"created_at":"2025-08-12T20:05:17.892591+00:00","device_class":null,"device_id":"469a3227d19a5115c26f30ed1e3388bb","disabled_by":"integration","entity_category":"diagnostic","entity_id":"sensor.sun_solar_elevation_2","hidden_by":null,"icon":null,"id":"160ec769f531c511c3bc3f2f607b0301","has_entity_name":true,"labels":[],"modified_at":"2025-08-12T20:05:17.892615+00:00","name":null,"options":{},"original_device_class":null,"original_icon":null,"original_name":"Solar elevation","platform":"sun","suggested_object_id":"sun_solar_elevation","supported_features":0,"translation_key":"solar_elevation","unique_id":"01K2FY66Y1AN0G099D140MFDVM-solar_elevation","previous_unique_id":null,"unit_of_measurement":"°"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"measurement"},"config_entry_id":"01K2FY66Y1AN0G099D140MFDVM","config_subentry_id":null,"created_at":"2025-08-12T20:05:17.892666+00:00","device_class":null,"device_id":"469a3227d19a5115c26f30ed1e3388bb","disabled_by":"integration","entity_category":"diagnostic","entity_id":"sensor.sun_solar_azimuth_2","hidden_by":null,"icon":null,"id":"5b37d92565b4fefa05d1e758b5be9ce8","has_entity_name":true,"labels":[],"modified_at":"2025-08-12T20:05:17.892686+00:00","name":null,"options":{},"original_device_class":null,"original_icon":null,"original_name":"Solar azimuth","platform":"sun","suggested_object_id":"sun_solar_azimuth","supported_features":0,"translation_key":"solar_azimuth","unique_id":"01K2FY66Y1AN0G099D140MFDVM-solar_azimuth","previous_unique_id":null,"unit_of_measurement":"°"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2FY66Y1AN0G099D140MFDVM","config_subentry_id":null,"created_at":"2025-08-12T20:05:17.892727+00:00","device_class":null,"device_id":"469a3227d19a5115c26f30ed1e3388bb","disabled_by":"integration","entity_category":"diagnostic","entity_id":"sensor.sun_solar_rising_2","hidden_by":null,"icon":null,"id":"6682744b2a19aaff207ece777a42ae85","has_entity_name":true,"labels":[],"modified_at":"2025-08-12T20:05:17.892745+00:00","name":null,"options":{},"original_device_class":null,"original_icon":null,"original_name":"Solar rising","platform":"sun","suggested_object_id":"sun_solar_rising","supported_features":0,"translation_key":"solar_rising","unique_id":"01K2FY66Y1AN0G099D140MFDVM-solar_rising","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2JBAPRJGMHHG5E8GW97HFKG","config_subentry_id":null,"created_at":"2025-08-13T18:33:25.526099+00:00","device_class":null,"device_id":"fe6a0e95694074756aef7b0d8db21451","disabled_by":"integration","entity_category":"diagnostic","entity_id":"binary_sensor.sun_solar_rising_4","hidden_by":null,"icon":null,"id":"50aa231f10ed46a212be8c259dfdb439","has_entity_name":true,"labels":[],"modified_at":"2025-08-13T18:33:25.526161+00:00","name":null,"options":{},"original_device_class":null,"original_icon":null,"original_name":"Solar rising","platform":"sun","suggested_object_id":"sun_solar_rising","supported_features":0,"translation_key":"solar_rising","unique_id":"01K2JBAPRJGMHHG5E8GW97HFKG-solar_rising","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2JBAPRJGMHHG5E8GW97HFKG","config_subentry_id":null,"created_at":"2025-08-13T18:33:25.526902+00:00","device_class":null,"device_id":"fe6a0e95694074756aef7b0d8db21451","disabled_by":null,"entity_category":"diagnostic","entity_id":"sensor.sun_next_dawn_4","hidden_by":null,"icon":null,"id":"38563389f203e5bf950709a48cf9b20b","has_entity_name":true,"labels":[],"modified_at":"2025-08-13T18:33:25.795585+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":"timestamp","original_icon":null,"original_name":"Next dawn","platform":"sun","suggested_object_id":"sun_next_dawn","supported_features":0,"translation_key":"next_dawn","unique_id":"01K2JBAPRJGMHHG5E8GW97HFKG-next_dawn","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2JBAPRJGMHHG5E8GW97HFKG","config_subentry_id":null,"created_at":"2025-08-13T18:33:25.527212+00:00","device_class":null,"device_id":"fe6a0e95694074756aef7b0d8db21451","disabled_by":null,"entity_category":"diagnostic","entity_id":"sensor.sun_next_dusk_4","hidden_by":null,"icon":null,"id":"e4506f669d35a6e69f650e200fcaec0e","has_entity_name":true,"labels":[],"modified_at":"2025-08-13T18:33:25.795754+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":"timestamp","original_icon":null,"original_name":"Next dusk","platform":"sun","suggested_object_id":"sun_next_dusk","supported_features":0,"translation_key":"next_dusk","unique_id":"01K2JBAPRJGMHHG5E8GW97HFKG-next_dusk","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2JBAPRJGMHHG5E8GW97HFKG","config_subentry_id":null,"created_at":"2025-08-13T18:33:25.527364+00:00","device_class":null,"device_id":"fe6a0e95694074756aef7b0d8db21451","disabled_by":null,"entity_category":"diagnostic","entity_id":"sensor.sun_next_midnight_4","hidden_by":null,"icon":null,"id":"e97f8a266e30bc1a0419f9b479b980d2","has_entity_name":true,"labels":[],"modified_at":"2025-08-13T18:33:25.795821+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":"timestamp","original_icon":null,"original_name":"Next midnight","platform":"sun","suggested_object_id":"sun_next_midnight","supported_features":0,"translation_key":"next_midnight","unique_id":"01K2JBAPRJGMHHG5E8GW97HFKG-next_midnight","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2JBAPRJGMHHG5E8GW97HFKG","config_subentry_id":null,"created_at":"2025-08-13T18:33:25.527494+00:00","device_class":null,"device_id":"fe6a0e95694074756aef7b0d8db21451","disabled_by":null,"entity_category":"diagnostic","entity_id":"sensor.sun_next_noon_4","hidden_by":null,"icon":null,"id":"b3ee63a21bf1d27f1aff298c83c0cc84","has_entity_name":true,"labels":[],"modified_at":"2025-08-13T18:33:25.795870+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":"timestamp","original_icon":null,"original_name":"Next noon","platform":"sun","suggested_object_id":"sun_next_noon","supported_features":0,"translation_key":"next_noon","unique_id":"01K2JBAPRJGMHHG5E8GW97HFKG-next_noon","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2JBAPRJGMHHG5E8GW97HFKG","config_subentry_id":null,"created_at":"2025-08-13T18:33:25.527614+00:00","device_class":null,"device_id":"fe6a0e95694074756aef7b0d8db21451","disabled_by":null,"entity_category":"diagnostic","entity_id":"sensor.sun_next_rising_4","hidden_by":null,"icon":null,"id":"f87603d4463566013bbc9061be6c0295","has_entity_name":true,"labels":[],"modified_at":"2025-08-13T18:33:25.795914+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":"timestamp","original_icon":null,"original_name":"Next rising","platform":"sun","suggested_object_id":"sun_next_rising","supported_features":0,"translation_key":"next_rising","unique_id":"01K2JBAPRJGMHHG5E8GW97HFKG-next_rising","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2JBAPRJGMHHG5E8GW97HFKG","config_subentry_id":null,"created_at":"2025-08-13T18:33:25.527725+00:00","device_class":null,"device_id":"fe6a0e95694074756aef7b0d8db21451","disabled_by":null,"entity_category":"diagnostic","entity_id":"sensor.sun_next_setting_4","hidden_by":null,"icon":null,"id":"f170bd71fe262549a526c7ed49db2876","has_entity_name":true,"labels":[],"modified_at":"2025-08-13T18:33:25.795955+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":"timestamp","original_icon":null,"original_name":"Next setting","platform":"sun","suggested_object_id":"sun_next_setting","supported_features":0,"translation_key":"next_setting","unique_id":"01K2JBAPRJGMHHG5E8GW97HFKG-next_setting","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"measurement"},"config_entry_id":"01K2JBAPRJGMHHG5E8GW97HFKG","config_subentry_id":null,"created_at":"2025-08-13T18:33:25.527851+00:00","device_class":null,"device_id":"fe6a0e95694074756aef7b0d8db21451","disabled_by":"integration","entity_category":"diagnostic","entity_id":"sensor.sun_solar_elevation_4","hidden_by":null,"icon":null,"id":"e7804664a543e2553357f7dee3b71eb8","has_entity_name":true,"labels":[],"modified_at":"2025-08-13T18:33:25.527879+00:00","name":null,"options":{},"original_device_class":null,"original_icon":null,"original_name":"Solar elevation","platform":"sun","suggested_object_id":"sun_solar_elevation","supported_features":0,"translation_key":"solar_elevation","unique_id":"01K2JBAPRJGMHHG5E8GW97HFKG-solar_elevation","previous_unique_id":null,"unit_of_measurement":"°"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"measurement"},"config_entry_id":"01K2JBAPRJGMHHG5E8GW97HFKG","config_subentry_id":null,"created_at":"2025-08-13T18:33:25.527966+00:00","device_class":null,"device_id":"fe6a0e95694074756aef7b0d8db21451","disabled_by":"integration","entity_category":"diagnostic","entity_id":"sensor.sun_solar_azimuth_4","hidden_by":null,"icon":null,"id":"949cfea72f2460c58e7e7362be9f1e31","has_entity_name":true,"labels":[],"modified_at":"2025-08-13T18:33:25.527986+00:00","name":null,"options":{},"original_device_class":null,"original_icon":null,"original_name":"Solar azimuth","platform":"sun","suggested_object_id":"sun_solar_azimuth","supported_features":0,"translation_key":"solar_azimuth","unique_id":"01K2JBAPRJGMHHG5E8GW97HFKG-solar_azimuth","previous_unique_id":null,"unit_of_measurement":"°"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2JBAPRJGMHHG5E8GW97HFKG","config_subentry_id":null,"created_at":"2025-08-13T18:33:25.528056+00:00","device_class":null,"device_id":"fe6a0e95694074756aef7b0d8db21451","disabled_by":"integration","entity_category":"diagnostic","entity_id":"sensor.sun_solar_rising_4","hidden_by":null,"icon":null,"id":"4d3693e27ef879cf450a6150638dc62b","has_entity_name":true,"labels":[],"modified_at":"2025-08-13T18:33:25.528075+00:00","name":null,"options":{},"original_device_class":null,"original_icon":null,"original_name":"Solar rising","platform":"sun","suggested_object_id":"sun_solar_rising","supported_features":0,"translation_key":"solar_rising","unique_id":"01K2JBAPRJGMHHG5E8GW97HFKG-solar_rising","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2QC172ABPW4AB4Z5A3EAW3D","config_subentry_id":null,"created_at":"2025-08-11T22:55:57.265048+00:00","device_class":null,"device_id":"8badde1dee15f240cb614586522bd66f","disabled_by":"integration","entity_category":"diagnostic","entity_id":"switch.hacs_pre_release","hidden_by":null,"icon":null,"id":"b858b5346eb418fe7917ef53cba0fc8b","has_entity_name":true,"labels":[],"modified_at":"2025-08-15T17:21:55.342283+00:00","name":null,"options":{},"original_device_class":null,"original_icon":null,"original_name":"Pre-release","platform":"hacs","suggested_object_id":null,"supported_features":0,"translation_key":"pre-release","unique_id":"172733314","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2QC172ABPW4AB4Z5A3EAW3D","config_subentry_id":null,"created_at":"2025-08-11T22:55:57.265326+00:00","device_class":null,"device_id":"345a8a4d0aff3290705074aa63b96dc1","disabled_by":"integration","entity_category":"diagnostic","entity_id":"switch.span_panel_pre_release","hidden_by":null,"icon":null,"id":"91c1d18f86ffbf9e4e744a9d4f15cf27","has_entity_name":true,"labels":[],"modified_at":"2025-08-15T17:21:55.342607+00:00","name":null,"options":{},"original_device_class":null,"original_icon":null,"original_name":"Pre-release","platform":"hacs","suggested_object_id":null,"supported_features":0,"translation_key":"pre-release","unique_id":"553010184","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2QC172ABPW4AB4Z5A3EAW3D","config_subentry_id":null,"created_at":"2025-08-12T20:35:50.284487+00:00","device_class":null,"device_id":"bf660ea2e3e81369c8a336033fbe2365","disabled_by":"integration","entity_category":"diagnostic","entity_id":"switch.default_config_exclude_pre_release","hidden_by":null,"icon":null,"id":"eb9148390d2d0c52a62f89e760af9611","has_entity_name":true,"labels":[],"modified_at":"2025-08-15T17:21:55.342792+00:00","name":null,"options":{},"original_device_class":null,"original_icon":null,"original_name":"Pre-release","platform":"hacs","suggested_object_id":null,"supported_features":0,"translation_key":"pre-release","unique_id":"665078127","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2QC172ABPW4AB4Z5A3EAW3D","config_subentry_id":null,"created_at":"2025-08-12T20:35:50.284344+00:00","device_class":null,"device_id":"3fcfd35f39a7fd5b6d00ce3ca2fb6f90","disabled_by":"integration","entity_category":"diagnostic","entity_id":"switch.early_loader_pre_release","hidden_by":null,"icon":null,"id":"ea07a3dac5c7bac459ea172e7c95d52d","has_entity_name":true,"labels":[],"modified_at":"2025-08-15T17:21:55.342953+00:00","name":null,"options":{},"original_device_class":null,"original_icon":null,"original_name":"Pre-release","platform":"hacs","suggested_object_id":null,"supported_features":0,"translation_key":"pre-release","unique_id":"665077422","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2QC172ABPW4AB4Z5A3EAW3D","config_subentry_id":null,"created_at":"2025-08-11T22:54:55.550648+00:00","device_class":null,"device_id":"8badde1dee15f240cb614586522bd66f","disabled_by":null,"entity_category":"config","entity_id":"update.hacs_update","hidden_by":null,"icon":null,"id":"5d771397e0b4451b1e08200189d03408","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:21:55.343982+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":null,"original_icon":null,"original_name":"HACS update","platform":"hacs","suggested_object_id":null,"supported_features":23,"translation_key":null,"unique_id":"172733314","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2QC172ABPW4AB4Z5A3EAW3D","config_subentry_id":null,"created_at":"2025-08-11T22:55:36.175734+00:00","device_class":null,"device_id":"345a8a4d0aff3290705074aa63b96dc1","disabled_by":null,"entity_category":"config","entity_id":"update.span_panel_update","hidden_by":null,"icon":null,"id":"4eb77fcfe2c789dc25da7072132cd409","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:21:55.344373+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":null,"original_icon":null,"original_name":"Span Panel update","platform":"hacs","suggested_object_id":null,"supported_features":23,"translation_key":null,"unique_id":"553010184","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2QC172ABPW4AB4Z5A3EAW3D","config_subentry_id":null,"created_at":"2025-08-12T20:34:58.267895+00:00","device_class":null,"device_id":"bf660ea2e3e81369c8a336033fbe2365","disabled_by":null,"entity_category":"config","entity_id":"update.default_config_exclude_update","hidden_by":null,"icon":null,"id":"285e2c9a7d6f391f87818e41b5388316","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:21:55.344552+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":null,"original_icon":null,"original_name":"Default Config Exclude update","platform":"hacs","suggested_object_id":null,"supported_features":23,"translation_key":null,"unique_id":"665078127","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2QC172ABPW4AB4Z5A3EAW3D","config_subentry_id":null,"created_at":"2025-08-12T20:34:07.692226+00:00","device_class":null,"device_id":"3fcfd35f39a7fd5b6d00ce3ca2fb6f90","disabled_by":null,"entity_category":"config","entity_id":"update.early_loader_update","hidden_by":null,"icon":null,"id":"e9f5b6d2e591d27b2cd8bf0642c7958d","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:21:55.344688+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":null,"original_icon":null,"original_name":"Early Loader update","platform":"hacs","suggested_object_id":null,"supported_features":23,"translation_key":null,"unique_id":"665077422","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-11T22:56:33.787324+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"binary_sensor.span_panel_door_state","hidden_by":null,"icon":null,"id":"ae06b3c5b9f5fb4c995056f80cc50ebe","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T04:46:00.476023+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":"tamper","original_icon":"mdi:flash","original_name":"Span Panel Door State","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_doorState","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-11T22:56:33.787679+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"binary_sensor.span_panel_ethernet_link","hidden_by":null,"icon":null,"id":"6c267cef4c337d848c9f42066801f2ec","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T04:46:00.476309+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":"connectivity","original_icon":"mdi:flash","original_name":"Span Panel Ethernet Link","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_eth0Link","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-11T22:56:33.787922+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"binary_sensor.span_panel_wi_fi_link","hidden_by":null,"icon":null,"id":"7b41bd7118fe9e92131cebffac76b7aa","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T04:46:00.476477+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":"connectivity","original_icon":"mdi:flash","original_name":"Span Panel Wi-Fi Link","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_wlanLink","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-11T22:56:33.790397+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"binary_sensor.span_panel_cellular_link","hidden_by":null,"icon":null,"id":"e7077f65e323c58a2d30f99f0efce90f","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T04:46:00.476635+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":"connectivity","original_icon":"mdi:flash","original_name":"Span Panel Cellular Link","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_wwanLink","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"options":["Must Have","Nice To Have","Non-Essential"]},"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-11T22:56:33.790991+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"select.span_panel_lights_dining_room_priority","hidden_by":null,"icon":null,"id":"8f414dbb1b82831edd33f7c0dc2c6cef","has_entity_name":true,"labels":[],"modified_at":"2025-08-16T04:46:00.478730+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":null,"original_icon":"mdi:chevron-down","original_name":"Lights Dining Room Circuit Priority","platform":"span_panel","suggested_object_id":"span_panel_circuit_2_priority","supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_select_0dad2f16cd514812ae1807b0457d473e","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"options":["Must Have","Nice To Have","Non-Essential"]},"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-11T22:56:33.791443+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"select.span_panel_lights_outlets_bedroom_priority","hidden_by":null,"icon":null,"id":"dff271a7420cc427b67760ebb1430336","has_entity_name":true,"labels":[],"modified_at":"2025-08-16T04:46:00.478936+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":null,"original_icon":"mdi:chevron-down","original_name":"Lights-Outlets Bedroom Circuit Priority","platform":"span_panel","suggested_object_id":"span_panel_circuit_4_priority","supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_select_11a47a0f69d54e12b7200f730c2ffda1","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"options":["Must Have","Nice To Have","Non-Essential"]},"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-11T22:56:33.791711+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"select.span_panel_dining_room_wine_fridge_priority","hidden_by":null,"icon":null,"id":"4912a9322aa664f2a112007672890f92","has_entity_name":true,"labels":[],"modified_at":"2025-08-16T04:46:00.479151+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":null,"original_icon":"mdi:chevron-down","original_name":"Dining Room Wine Fridge Circuit Priority","platform":"span_panel","suggested_object_id":"span_panel_circuit_14_priority","supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_select_12ce227695cd44338864b0ef2ec4168b","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"options":["Must Have","Nice To Have","Non-Essential"]},"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-11T22:56:33.792090+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"select.span_panel_dryer_priority","hidden_by":null,"icon":null,"id":"6a7172a7a2e4d5edc3bf9cff48d82eba","has_entity_name":true,"labels":[],"modified_at":"2025-08-16T04:46:00.479591+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":null,"original_icon":"mdi:chevron-down","original_name":"Dryer Circuit Priority","platform":"span_panel","suggested_object_id":"span_panel_circuit_24_priority","supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_select_1ad73b0eb44e4022bb6270c76baed0c1","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"options":["Must Have","Nice To Have","Non-Essential"]},"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-11T22:56:33.792239+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"select.span_panel_circuit_16_priority","hidden_by":null,"icon":null,"id":"f743cddb5c0b670c5bda7427bea643ab","has_entity_name":true,"labels":[],"modified_at":"2025-08-16T04:46:00.479785+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":null,"original_icon":"mdi:chevron-down","original_name":"Outlets / Kitchen Circuit Priority","platform":"span_panel","suggested_object_id":"span_panel_circuit_16_priority","supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_select_31b36cde0fc642b39eec515267707a6f","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"options":["Must Have","Nice To Have","Non-Essential"]},"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-11T22:56:33.792378+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"select.span_panel_circuit_19_priority","hidden_by":null,"icon":null,"id":"22bfca9599c79dfe45da594e2414870a","has_entity_name":true,"labels":[],"modified_at":"2025-08-16T04:46:00.479920+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":null,"original_icon":"mdi:chevron-down","original_name":"Microwave & Oven Circuit Priority","platform":"span_panel","suggested_object_id":"span_panel_circuit_19_priority","supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_select_3a847fe6eb374ec3bf92cee1ffaf2eda","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"options":["Must Have","Nice To Have","Non-Essential"]},"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-11T22:56:33.792548+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"select.span_panel_dishwasher_priority","hidden_by":null,"icon":null,"id":"69aeaf03f69554ec38b661f3ad3c9a79","has_entity_name":true,"labels":[],"modified_at":"2025-08-16T04:46:00.480051+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":null,"original_icon":"mdi:chevron-down","original_name":"Dishwasher Circuit Priority","platform":"span_panel","suggested_object_id":"span_panel_circuit_12_priority","supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_select_497ae7ebb2844772bf926f2f094c81bc","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"options":["Must Have","Nice To Have","Non-Essential"]},"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-11T22:56:33.793332+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"select.span_panel_spa_priority","hidden_by":null,"icon":null,"id":"46a81e97f5d36c2896c267520679ceb5","has_entity_name":true,"labels":[],"modified_at":"2025-08-16T04:46:00.480189+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":null,"original_icon":"mdi:chevron-down","original_name":"Spa Circuit Priority","platform":"span_panel","suggested_object_id":"span_panel_circuit_29_priority","supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_select_617059df47bb49bd8545a36a6b6b23d2","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"options":["Must Have","Nice To Have","Non-Essential"]},"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-11T22:56:33.793518+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"select.span_panel_kitchen_disposal_priority","hidden_by":null,"icon":null,"id":"5d820a9d696f5c3127daa774e34084d4","has_entity_name":true,"labels":[],"modified_at":"2025-08-16T04:46:00.480298+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":null,"original_icon":"mdi:chevron-down","original_name":"Kitchen Disposal Circuit Priority","platform":"span_panel","suggested_object_id":"span_panel_circuit_10_priority","supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_select_6ad65cf2ad6443448e33973f26f7df3e","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"options":["Must Have","Nice To Have","Non-Essential"]},"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-11T22:56:33.793701+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"select.span_panel_fountain_priority","hidden_by":null,"icon":null,"id":"29386a779f9cf34103027d6cc6fc943e","has_entity_name":true,"labels":[],"modified_at":"2025-08-16T04:46:00.480411+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":null,"original_icon":"mdi:chevron-down","original_name":"Fountain Circuit Priority","platform":"span_panel","suggested_object_id":"span_panel_circuit_25_priority","supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_select_795e8eddb4f448af9625130332a41df8","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"options":["Must Have","Nice To Have","Non-Essential"]},"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-11T22:56:33.793857+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"select.span_panel_large_garage_ev_priority","hidden_by":null,"icon":null,"id":"c6d74933d30f9f164126e40fb177b44b","has_entity_name":true,"labels":[],"modified_at":"2025-08-16T04:46:00.480553+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":null,"original_icon":"mdi:chevron-down","original_name":"Large Garage EV Circuit Priority","platform":"span_panel","suggested_object_id":"span_panel_circuit_18_priority","supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_select_8a2ffda9dbd24bada9a01b880e910612","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"options":["Must Have","Nice To Have","Non-Essential"]},"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-11T22:56:33.794384+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"select.span_panel_other_outlets_priority","hidden_by":null,"icon":null,"id":"14bd913697842358f143fafdcf42a824","has_entity_name":true,"labels":[],"modified_at":"2025-08-16T04:46:00.481686+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":null,"original_icon":"mdi:chevron-down","original_name":"Other Outlets Circuit Priority","platform":"span_panel","suggested_object_id":"span_panel_circuit_9_priority","supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_select_914943d4798c462cadf5e5144989dd5b","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"options":["Must Have","Nice To Have","Non-Essential"]},"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-11T22:56:33.794598+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"select.span_panel_master_bedroom_priority","hidden_by":null,"icon":null,"id":"02524e293e039df5e46e603a81df0340","has_entity_name":true,"labels":[],"modified_at":"2025-08-16T04:46:00.481929+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":null,"original_icon":"mdi:chevron-down","original_name":"Master bedroom Circuit Priority","platform":"span_panel","suggested_object_id":"span_panel_circuit_1_priority","supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_select_926441605113492189f9aa13dac356cd","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"options":["Must Have","Nice To Have","Non-Essential"]},"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-11T22:56:33.794733+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"select.span_panel_air_conditioner_priority","hidden_by":null,"icon":null,"id":"d6653fcb2b588d1c81c7d914a74b3992","has_entity_name":true,"labels":[],"modified_at":"2025-08-16T04:46:00.482091+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":null,"original_icon":"mdi:chevron-down","original_name":"Air Conditioner Circuit Priority","platform":"span_panel","suggested_object_id":"span_panel_circuit_15_priority","supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_select_941d6a8b41ab4c57a6b8be14b5981fe6","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"options":["Must Have","Nice To Have","Non-Essential"]},"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-11T22:56:33.794875+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"select.span_panel_garage_outlets_priority","hidden_by":null,"icon":null,"id":"36ceecc4e2cdd6385988c55f85ee34ab","has_entity_name":true,"labels":[],"modified_at":"2025-08-16T04:46:00.482220+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":null,"original_icon":"mdi:chevron-down","original_name":"Garage Outlets Circuit Priority","platform":"span_panel","suggested_object_id":"span_panel_circuit_7_priority","supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_select_988af7bb1fc04aac8bb3b45c660d920a","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"options":["Must Have","Nice To Have","Non-Essential"]},"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-11T22:56:33.795036+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"select.span_panel_furnace_priority","hidden_by":null,"icon":null,"id":"244e7d25c7614271d62ec6f2dccbd88f","has_entity_name":true,"labels":[],"modified_at":"2025-08-16T04:46:00.482344+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":null,"original_icon":"mdi:chevron-down","original_name":"Furnace Circuit Priority","platform":"span_panel","suggested_object_id":"span_panel_circuit_6_priority","supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_select_cbe98d7307ca42658983b9f673211e14","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"options":["Must Have","Nice To Have","Non-Essential"]},"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-11T22:56:33.795187+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"select.span_panel_small_garage_ev_priority","hidden_by":null,"icon":null,"id":"8d91bbe1e7165cec44b834773040febf","has_entity_name":true,"labels":[],"modified_at":"2025-08-16T04:46:00.482465+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":null,"original_icon":"mdi:chevron-down","original_name":"Small Garage EV Circuit Priority","platform":"span_panel","suggested_object_id":"span_panel_circuit_11_priority","supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_select_d88c0c0e7c584472b2ec7e4d3c53c3b8","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"options":["Must Have","Nice To Have","Non-Essential"]},"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-11T22:56:33.795345+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"select.span_panel_washing_machine_area_priority","hidden_by":null,"icon":null,"id":"aa3a62b39263a26c5dc85ce0efea6059","has_entity_name":true,"labels":[],"modified_at":"2025-08-16T04:46:00.482593+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":null,"original_icon":"mdi:chevron-down","original_name":"Washing Machine Area Circuit Priority","platform":"span_panel","suggested_object_id":"span_panel_circuit_8_priority","supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_select_e3cb78d38dae41cdb50faee7bfa4ab80","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"options":["Must Have","Nice To Have","Non-Essential"]},"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-11T22:56:33.795566+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"select.span_panel_kitchen_and_master_bath_priority","hidden_by":null,"icon":null,"id":"b214997912394795f6a87fa9f7963df3","has_entity_name":true,"labels":[],"modified_at":"2025-08-16T04:46:00.483083+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":null,"original_icon":"mdi:chevron-down","original_name":"Kitchen and Master Bath Circuit Priority","platform":"span_panel","suggested_object_id":"span_panel_circuit_3_priority","supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_select_ece352c3dc5c417e80757b716f24ba90","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"options":["Must Have","Nice To Have","Non-Essential"]},"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-11T22:56:33.795742+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"select.span_panel_lights_kitchen_master_bathroom_priority","hidden_by":null,"icon":null,"id":"cdeb56ce12044310aac5ce62b004088b","has_entity_name":true,"labels":[],"modified_at":"2025-08-16T04:46:00.483251+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":null,"original_icon":"mdi:chevron-down","original_name":"Lights Kitchen Master bathroom Circuit Priority","platform":"span_panel","suggested_object_id":"span_panel_circuit_5_priority","supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_select_f19d909abcc4421f9ec630f82458c7ac","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"measurement"},"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.106583+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_current_power","hidden_by":null,"icon":null,"id":"dec780cbf5bb61110203cc80e0788664","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T04:46:00.485853+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"power","original_icon":"mdi:flash","original_name":"Span Panel Current Power","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_instantGridPowerW","previous_unique_id":null,"unit_of_measurement":"W"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"measurement"},"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.107326+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_feed_through_power","hidden_by":null,"icon":null,"id":"0cdcf2963f73490d7121c199699d9269","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T04:46:00.486176+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"power","original_icon":"mdi:flash","original_name":"Span Panel Feed Through Power","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_feedthroughPowerW","previous_unique_id":null,"unit_of_measurement":"W"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"total_increasing"},"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.107683+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_main_meter_produced_energy","hidden_by":null,"icon":null,"id":"61dbb9274d45cb461fdc039f7c3fd3f7","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T04:46:00.486352+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"energy","original_icon":"mdi:flash","original_name":"Span Panel Main Meter Produced Energy","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_mainMeterEnergy.producedEnergyWh","previous_unique_id":null,"unit_of_measurement":"Wh"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"total_increasing"},"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.107974+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_main_meter_consumed_energy","hidden_by":null,"icon":null,"id":"491d4787cd1e217fd4c14cd8b03f96e5","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T04:46:00.486506+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"energy","original_icon":"mdi:flash","original_name":"Span Panel Main Meter Consumed Energy","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_mainMeterEnergy.consumedEnergyWh","previous_unique_id":null,"unit_of_measurement":"Wh"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"total_increasing"},"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.108312+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_feed_through_produced_energy","hidden_by":null,"icon":null,"id":"0c800057ccdbe26636ba4f42b254eba5","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T04:46:00.486697+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"energy","original_icon":"mdi:flash","original_name":"Span Panel Feed Through Produced Energy","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_feedthroughEnergy.producedEnergyWh","previous_unique_id":null,"unit_of_measurement":"Wh"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"total_increasing"},"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.109043+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_feed_through_consumed_energy","hidden_by":null,"icon":null,"id":"0e97ce558aa0e9fc99a09249943fe961","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T04:46:00.488431+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"energy","original_icon":"mdi:flash","original_name":"Span Panel Feed Through Consumed Energy","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_feedthroughEnergy.consumedEnergyWh","previous_unique_id":null,"unit_of_measurement":"Wh"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.109391+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_current_run_config","hidden_by":null,"icon":null,"id":"16661d9e4c5bc1a92c7ce86e1accc18f","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T04:46:00.488697+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":null,"original_icon":"mdi:flash","original_name":"Span Panel Current Run Config","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_currentRunConfig","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.109613+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_dsm_grid_state","hidden_by":null,"icon":null,"id":"da48cbd532eab3a839cc33bbe008032d","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T04:46:00.488904+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":null,"original_icon":"mdi:flash","original_name":"Span Panel DSM Grid State","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_dsmGridState","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.109808+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_dsm_state","hidden_by":null,"icon":null,"id":"9591fe259c66fcbd2af98e5c6f4d93db","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T04:46:00.489097+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":null,"original_icon":"mdi:flash","original_name":"Span Panel DSM State","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_dsmState","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.110016+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_main_relay_state","hidden_by":null,"icon":null,"id":"c26c378aa8688697dd7f0dce0097cf8e","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T04:46:00.489244+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":null,"original_icon":"mdi:flash","original_name":"Span Panel Main Relay State","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_mainRelayState","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.110300+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_software_version","hidden_by":null,"icon":null,"id":"06d09884f67cb2ee7af2a90720d83acb","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T04:46:00.489390+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":null,"original_icon":"mdi:flash","original_name":"Span Panel Software Version","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_softwareVer","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"measurement"},"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.110545+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_2_power","hidden_by":null,"icon":null,"id":"19ce1481e73263250f245517b1594b7a","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T04:46:00.489534+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"power","original_icon":"mdi:flash","original_name":"Span Panel Lights Dining Room Power","platform":"span_panel","suggested_object_id":"span_panel_circuit_2_power","supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_0dad2f16cd514812ae1807b0457d473e_instantPowerW","previous_unique_id":null,"unit_of_measurement":"W"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"measurement"},"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.110789+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_4_power","hidden_by":null,"icon":null,"id":"2aa1dadbdf083af026c65ac057aa46ca","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T04:46:00.489681+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"power","original_icon":"mdi:flash","original_name":"Span Panel Lights-Outlets Bedroom Power","platform":"span_panel","suggested_object_id":"span_panel_circuit_4_power","supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_11a47a0f69d54e12b7200f730c2ffda1_instantPowerW","previous_unique_id":null,"unit_of_measurement":"W"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"measurement"},"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.111032+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_14_power","hidden_by":null,"icon":null,"id":"079314109b4d52969ba81217f5eaaeac","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T04:46:00.489811+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"power","original_icon":"mdi:flash","original_name":"Span Panel Dining Room Wine Fridge Power","platform":"span_panel","suggested_object_id":"span_panel_circuit_14_power","supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_12ce227695cd44338864b0ef2ec4168b_instantPowerW","previous_unique_id":null,"unit_of_measurement":"W"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"measurement"},"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.111255+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_24_power","hidden_by":null,"icon":null,"id":"d3371e2671b02a83e31d967e0c221934","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T04:46:00.489925+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"power","original_icon":"mdi:flash","original_name":"Span Panel Dryer Power","platform":"span_panel","suggested_object_id":"span_panel_circuit_24_power","supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_1ad73b0eb44e4022bb6270c76baed0c1_instantPowerW","previous_unique_id":null,"unit_of_measurement":"W"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"measurement"},"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.111470+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_16_power","hidden_by":null,"icon":null,"id":"03684c7ceb0fb1ac0aeb987cee72d48c","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T04:46:00.490041+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"power","original_icon":"mdi:flash","original_name":"Span Panel Outlets / Kitchen Power","platform":"span_panel","suggested_object_id":"span_panel_circuit_16_power","supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_31b36cde0fc642b39eec515267707a6f_instantPowerW","previous_unique_id":null,"unit_of_measurement":"W"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"measurement"},"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.111686+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_19_power","hidden_by":null,"icon":null,"id":"41581c1c69d4290a011b92dc69fff58a","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T04:46:00.490172+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"power","original_icon":"mdi:flash","original_name":"Span Panel Microwave & Oven Power","platform":"span_panel","suggested_object_id":"span_panel_circuit_19_power","supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_3a847fe6eb374ec3bf92cee1ffaf2eda_instantPowerW","previous_unique_id":null,"unit_of_measurement":"W"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"measurement"},"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.111913+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_12_power","hidden_by":null,"icon":null,"id":"ce785758a74bcce7766b55b8f1008925","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T04:46:00.490309+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"power","original_icon":"mdi:flash","original_name":"Span Panel Dishwasher Power","platform":"span_panel","suggested_object_id":"span_panel_circuit_12_power","supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_497ae7ebb2844772bf926f2f094c81bc_instantPowerW","previous_unique_id":null,"unit_of_measurement":"W"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"measurement"},"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.112164+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_29_power","hidden_by":null,"icon":null,"id":"e610287d2ec55ddd59209c98dc619256","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T04:46:00.490450+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"power","original_icon":"mdi:flash","original_name":"Span Panel Spa Power","platform":"span_panel","suggested_object_id":"span_panel_circuit_29_power","supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_617059df47bb49bd8545a36a6b6b23d2_instantPowerW","previous_unique_id":null,"unit_of_measurement":"W"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"measurement"},"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.112388+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_10_power","hidden_by":null,"icon":null,"id":"534e3f460519b59ddd7d3cdcd45dddf5","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T04:46:00.490597+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"power","original_icon":"mdi:flash","original_name":"Span Panel Kitchen Disposal Power","platform":"span_panel","suggested_object_id":"span_panel_circuit_10_power","supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_6ad65cf2ad6443448e33973f26f7df3e_instantPowerW","previous_unique_id":null,"unit_of_measurement":"W"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"measurement"},"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.112595+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_25_power","hidden_by":null,"icon":null,"id":"de13ee63a56b46b5b0501d1ba1fe8b8f","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T04:46:00.490777+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"power","original_icon":"mdi:flash","original_name":"Span Panel Fountain Power","platform":"span_panel","suggested_object_id":"span_panel_circuit_25_power","supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_795e8eddb4f448af9625130332a41df8_instantPowerW","previous_unique_id":null,"unit_of_measurement":"W"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"measurement"},"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.112825+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_23_power","hidden_by":null,"icon":null,"id":"13f2a10f28cf0e105c5c2b3ba74ad43b","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T04:46:00.490905+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"power","original_icon":"mdi:flash","original_name":"Span Panel Internet Living room Power","platform":"span_panel","suggested_object_id":"span_panel_circuit_23_power","supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_82a0888dc072416598d99f8b74ee3d8d_instantPowerW","previous_unique_id":null,"unit_of_measurement":"W"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"measurement"},"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.113051+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_18_power","hidden_by":null,"icon":null,"id":"040a4e000a3c14e4bea763acc9fd6fbd","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T04:46:00.491071+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"power","original_icon":"mdi:flash","original_name":"Span Panel Large Garage EV Power","platform":"span_panel","suggested_object_id":"span_panel_circuit_18_power","supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_8a2ffda9dbd24bada9a01b880e910612_instantPowerW","previous_unique_id":null,"unit_of_measurement":"W"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"measurement"},"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.113273+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_9_power","hidden_by":null,"icon":null,"id":"b8947837aea8f7688380f729cfef621b","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T04:46:00.491207+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"power","original_icon":"mdi:flash","original_name":"Span Panel Other Outlets Power","platform":"span_panel","suggested_object_id":"span_panel_circuit_9_power","supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_914943d4798c462cadf5e5144989dd5b_instantPowerW","previous_unique_id":null,"unit_of_measurement":"W"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"measurement"},"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.113487+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_1_power","hidden_by":null,"icon":null,"id":"ae1bb3a4cede098b817a2b52919e64da","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T04:46:00.491351+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"power","original_icon":"mdi:flash","original_name":"Span Panel Master bedroom Power","platform":"span_panel","suggested_object_id":"span_panel_circuit_1_power","supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_926441605113492189f9aa13dac356cd_instantPowerW","previous_unique_id":null,"unit_of_measurement":"W"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"measurement"},"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.113689+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_15_power","hidden_by":null,"icon":null,"id":"a81855265386cc73800948dbde1783e2","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T04:46:00.491470+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"power","original_icon":"mdi:flash","original_name":"Span Panel Air Conditioner Power","platform":"span_panel","suggested_object_id":"span_panel_circuit_15_power","supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_941d6a8b41ab4c57a6b8be14b5981fe6_instantPowerW","previous_unique_id":null,"unit_of_measurement":"W"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"measurement"},"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.114436+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_7_power","hidden_by":null,"icon":null,"id":"b887c66922fee144930612369df071c3","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T04:46:00.491585+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"power","original_icon":"mdi:flash","original_name":"Span Panel Garage Outlets Power","platform":"span_panel","suggested_object_id":"span_panel_circuit_7_power","supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_988af7bb1fc04aac8bb3b45c660d920a_instantPowerW","previous_unique_id":null,"unit_of_measurement":"W"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"measurement"},"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.114776+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_22_power","hidden_by":null,"icon":null,"id":"4e35db0ef0e25f93c29976eb39a7c79a","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T04:46:00.491740+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"power","original_icon":"mdi:flash","original_name":"Span Panel Refrigerator Power","platform":"span_panel","suggested_object_id":"span_panel_circuit_22_power","supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_9941b417bfa046ef908d97c998c54c50_instantPowerW","previous_unique_id":null,"unit_of_measurement":"W"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"measurement"},"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.115081+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_6_power","hidden_by":null,"icon":null,"id":"06ebe61ee8546ba6c839d9dd6afbf7d0","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T04:46:00.491850+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"power","original_icon":"mdi:flash","original_name":"Span Panel Furnace Power","platform":"span_panel","suggested_object_id":"span_panel_circuit_6_power","supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_cbe98d7307ca42658983b9f673211e14_instantPowerW","previous_unique_id":null,"unit_of_measurement":"W"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"measurement"},"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.115419+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_11_power","hidden_by":null,"icon":null,"id":"9c8cf277b249b682c096ca98174e8b60","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T04:46:00.491952+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"power","original_icon":"mdi:flash","original_name":"Span Panel Small Garage EV Power","platform":"span_panel","suggested_object_id":"span_panel_circuit_11_power","supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_d88c0c0e7c584472b2ec7e4d3c53c3b8_instantPowerW","previous_unique_id":null,"unit_of_measurement":"W"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"measurement"},"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.115690+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_8_power","hidden_by":null,"icon":null,"id":"f7ad137494690574b493e6bf912f1362","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T04:46:00.492063+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"power","original_icon":"mdi:flash","original_name":"Span Panel Washing Machine Area Power","platform":"span_panel","suggested_object_id":"span_panel_circuit_8_power","supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_e3cb78d38dae41cdb50faee7bfa4ab80_instantPowerW","previous_unique_id":null,"unit_of_measurement":"W"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"measurement"},"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.115935+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_3_power","hidden_by":null,"icon":null,"id":"d17d1520eca33612789df2441bd9b5f3","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T04:46:00.492202+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"power","original_icon":"mdi:flash","original_name":"Span Panel Kitchen and Master Bath Power","platform":"span_panel","suggested_object_id":"span_panel_circuit_3_power","supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_ece352c3dc5c417e80757b716f24ba90_instantPowerW","previous_unique_id":null,"unit_of_measurement":"W"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"measurement"},"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.116184+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_5_power","hidden_by":null,"icon":null,"id":"bac4726564781c97216dff05229204fd","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T04:46:00.492334+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"power","original_icon":"mdi:flash","original_name":"Span Panel Lights Kitchen Master bathroom Power","platform":"span_panel","suggested_object_id":"span_panel_circuit_5_power","supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_f19d909abcc4421f9ec630f82458c7ac_instantPowerW","previous_unique_id":null,"unit_of_measurement":"W"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"total_increasing"},"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.116443+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_2_energy_produced","hidden_by":null,"icon":null,"id":"6ee0c0a95a9fd854eed5134b5ef222d5","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T04:46:00.492726+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"energy","original_icon":"mdi:flash","original_name":"Span Panel Lights Dining Room Produced Energy","platform":"span_panel","suggested_object_id":"span_panel_circuit_2_energy_produced","supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_0dad2f16cd514812ae1807b0457d473e_producedEnergyWh","previous_unique_id":null,"unit_of_measurement":"Wh"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"total_increasing"},"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.116681+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_4_energy_produced","hidden_by":null,"icon":null,"id":"21ce13c37eb3fe88e4299c1f50a42c92","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T04:46:00.492922+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"energy","original_icon":"mdi:flash","original_name":"Span Panel Lights-Outlets Bedroom Produced Energy","platform":"span_panel","suggested_object_id":"span_panel_circuit_4_energy_produced","supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_11a47a0f69d54e12b7200f730c2ffda1_producedEnergyWh","previous_unique_id":null,"unit_of_measurement":"Wh"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"total_increasing"},"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.117098+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_14_energy_produced","hidden_by":null,"icon":null,"id":"5050bb89ccfec0f01d2bf3075d3dbb30","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T04:46:00.493248+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"energy","original_icon":"mdi:flash","original_name":"Span Panel Dining Room Wine Fridge Produced Energy","platform":"span_panel","suggested_object_id":"span_panel_circuit_14_energy_produced","supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_12ce227695cd44338864b0ef2ec4168b_producedEnergyWh","previous_unique_id":null,"unit_of_measurement":"Wh"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"total_increasing"},"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.117355+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_24_energy_produced","hidden_by":null,"icon":null,"id":"d9d98ea725a273fca21bf77fecc02899","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T04:46:00.494866+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"energy","original_icon":"mdi:flash","original_name":"Span Panel Dryer Produced Energy","platform":"span_panel","suggested_object_id":"span_panel_circuit_24_energy_produced","supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_1ad73b0eb44e4022bb6270c76baed0c1_producedEnergyWh","previous_unique_id":null,"unit_of_measurement":"Wh"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"total_increasing"},"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.117589+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_16_energy_produced","hidden_by":null,"icon":null,"id":"7b9f2578deb4e93845a551fd44675caa","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T04:46:00.495030+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"energy","original_icon":"mdi:flash","original_name":"Span Panel Outlets / Kitchen Produced Energy","platform":"span_panel","suggested_object_id":"span_panel_circuit_16_energy_produced","supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_31b36cde0fc642b39eec515267707a6f_producedEnergyWh","previous_unique_id":null,"unit_of_measurement":"Wh"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"total_increasing"},"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.117830+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_19_energy_produced","hidden_by":null,"icon":null,"id":"507ff0aa373d736511fcfc8327094844","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T04:46:00.495197+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"energy","original_icon":"mdi:flash","original_name":"Span Panel Microwave & Oven Produced Energy","platform":"span_panel","suggested_object_id":"span_panel_circuit_19_energy_produced","supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_3a847fe6eb374ec3bf92cee1ffaf2eda_producedEnergyWh","previous_unique_id":null,"unit_of_measurement":"Wh"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"total_increasing"},"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.118064+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_12_energy_produced","hidden_by":null,"icon":null,"id":"d2a61e8de0449693c9b270242ab8cba8","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T04:46:00.495319+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"energy","original_icon":"mdi:flash","original_name":"Span Panel Dishwasher Produced Energy","platform":"span_panel","suggested_object_id":"span_panel_circuit_12_energy_produced","supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_497ae7ebb2844772bf926f2f094c81bc_producedEnergyWh","previous_unique_id":null,"unit_of_measurement":"Wh"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"total_increasing"},"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.118340+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_29_energy_produced","hidden_by":null,"icon":null,"id":"1dbf521b7ac8014151602b35d481b3c6","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T04:46:00.495426+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"energy","original_icon":"mdi:flash","original_name":"Span Panel Spa Produced Energy","platform":"span_panel","suggested_object_id":"span_panel_circuit_29_energy_produced","supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_617059df47bb49bd8545a36a6b6b23d2_producedEnergyWh","previous_unique_id":null,"unit_of_measurement":"Wh"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"total_increasing"},"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.118594+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_10_energy_produced","hidden_by":null,"icon":null,"id":"b50f7a56afdd296c7a58f54610dd126f","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T04:46:00.495533+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"energy","original_icon":"mdi:flash","original_name":"Span Panel Kitchen Disposal Produced Energy","platform":"span_panel","suggested_object_id":"span_panel_circuit_10_energy_produced","supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_6ad65cf2ad6443448e33973f26f7df3e_producedEnergyWh","previous_unique_id":null,"unit_of_measurement":"Wh"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"total_increasing"},"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.118821+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_25_energy_produced","hidden_by":null,"icon":null,"id":"39c8f492b3044cc06b48bf781f4ecb76","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T04:46:00.495708+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"energy","original_icon":"mdi:flash","original_name":"Span Panel Fountain Produced Energy","platform":"span_panel","suggested_object_id":"span_panel_circuit_25_energy_produced","supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_795e8eddb4f448af9625130332a41df8_producedEnergyWh","previous_unique_id":null,"unit_of_measurement":"Wh"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"total_increasing"},"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.119063+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_23_energy_produced","hidden_by":null,"icon":null,"id":"00b3e55750dce4e0566a34b3e6b1ed4f","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T04:46:00.495853+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"energy","original_icon":"mdi:flash","original_name":"Span Panel Internet Living room Produced Energy","platform":"span_panel","suggested_object_id":"span_panel_circuit_23_energy_produced","supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_82a0888dc072416598d99f8b74ee3d8d_producedEnergyWh","previous_unique_id":null,"unit_of_measurement":"Wh"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"total_increasing"},"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.119284+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_18_energy_produced","hidden_by":null,"icon":null,"id":"3ff1d7114ff5ebe7f64cf4db57396b4f","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T04:46:00.495975+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"energy","original_icon":"mdi:flash","original_name":"Span Panel Large Garage EV Produced Energy","platform":"span_panel","suggested_object_id":"span_panel_circuit_18_energy_produced","supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_8a2ffda9dbd24bada9a01b880e910612_producedEnergyWh","previous_unique_id":null,"unit_of_measurement":"Wh"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"total_increasing"},"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.119495+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_9_energy_produced","hidden_by":null,"icon":null,"id":"c2aa1d5f7027c8977a9a1857432397c3","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T04:46:00.496099+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"energy","original_icon":"mdi:flash","original_name":"Span Panel Other Outlets Produced Energy","platform":"span_panel","suggested_object_id":"span_panel_circuit_9_energy_produced","supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_914943d4798c462cadf5e5144989dd5b_producedEnergyWh","previous_unique_id":null,"unit_of_measurement":"Wh"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"total_increasing"},"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.120713+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_1_energy_produced","hidden_by":null,"icon":null,"id":"871676b8c26ea522ab0f6e8c12a0ace5","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T04:46:00.496226+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"energy","original_icon":"mdi:flash","original_name":"Span Panel Master bedroom Produced Energy","platform":"span_panel","suggested_object_id":"span_panel_circuit_1_energy_produced","supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_926441605113492189f9aa13dac356cd_producedEnergyWh","previous_unique_id":null,"unit_of_measurement":"Wh"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"total_increasing"},"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.121027+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_15_energy_produced","hidden_by":null,"icon":null,"id":"95168bc734415b17f9c63fafefbc079b","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T04:46:00.496343+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"energy","original_icon":"mdi:flash","original_name":"Span Panel Air Conditioner Produced Energy","platform":"span_panel","suggested_object_id":"span_panel_circuit_15_energy_produced","supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_941d6a8b41ab4c57a6b8be14b5981fe6_producedEnergyWh","previous_unique_id":null,"unit_of_measurement":"Wh"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"total_increasing"},"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.121280+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_7_energy_produced","hidden_by":null,"icon":null,"id":"49fd529ed03f857f58b832bbcd753327","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T04:46:00.496461+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"energy","original_icon":"mdi:flash","original_name":"Span Panel Garage Outlets Produced Energy","platform":"span_panel","suggested_object_id":"span_panel_circuit_7_energy_produced","supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_988af7bb1fc04aac8bb3b45c660d920a_producedEnergyWh","previous_unique_id":null,"unit_of_measurement":"Wh"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"total_increasing"},"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.121510+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_22_energy_produced","hidden_by":null,"icon":null,"id":"9b5b30e5f76ff2a5ad008943d392ebf8","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T04:46:00.496604+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"energy","original_icon":"mdi:flash","original_name":"Span Panel Refrigerator Produced Energy","platform":"span_panel","suggested_object_id":"span_panel_circuit_22_energy_produced","supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_9941b417bfa046ef908d97c998c54c50_producedEnergyWh","previous_unique_id":null,"unit_of_measurement":"Wh"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"total_increasing"},"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.121726+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_6_energy_produced","hidden_by":null,"icon":null,"id":"7522f5b6cf4dc53a157fafe5f58d3a79","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T04:46:00.496736+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"energy","original_icon":"mdi:flash","original_name":"Span Panel Furnace Produced Energy","platform":"span_panel","suggested_object_id":"span_panel_circuit_6_energy_produced","supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_cbe98d7307ca42658983b9f673211e14_producedEnergyWh","previous_unique_id":null,"unit_of_measurement":"Wh"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"total_increasing"},"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.121944+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_11_energy_produced","hidden_by":null,"icon":null,"id":"82f7dadc7b6f595044fb35060f19df13","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T04:46:00.496854+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"energy","original_icon":"mdi:flash","original_name":"Span Panel Small Garage EV Produced Energy","platform":"span_panel","suggested_object_id":"span_panel_circuit_11_energy_produced","supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_d88c0c0e7c584472b2ec7e4d3c53c3b8_producedEnergyWh","previous_unique_id":null,"unit_of_measurement":"Wh"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"total_increasing"},"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.122201+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_8_energy_produced","hidden_by":null,"icon":null,"id":"71cec55ed46dd6516e4e25ca3cec3c31","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T04:46:00.496968+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"energy","original_icon":"mdi:flash","original_name":"Span Panel Washing Machine Area Produced Energy","platform":"span_panel","suggested_object_id":"span_panel_circuit_8_energy_produced","supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_e3cb78d38dae41cdb50faee7bfa4ab80_producedEnergyWh","previous_unique_id":null,"unit_of_measurement":"Wh"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"total_increasing"},"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.122430+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_3_energy_produced","hidden_by":null,"icon":null,"id":"7c8ea792832e60b416415529e17d4f46","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T04:46:00.497094+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"energy","original_icon":"mdi:flash","original_name":"Span Panel Kitchen and Master Bath Produced Energy","platform":"span_panel","suggested_object_id":"span_panel_circuit_3_energy_produced","supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_ece352c3dc5c417e80757b716f24ba90_producedEnergyWh","previous_unique_id":null,"unit_of_measurement":"Wh"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"total_increasing"},"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.122655+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_5_energy_produced","hidden_by":null,"icon":null,"id":"daf3d85442d1398e0474ecbe60162295","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T04:46:00.497208+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"energy","original_icon":"mdi:flash","original_name":"Span Panel Lights Kitchen Master bathroom Produced Energy","platform":"span_panel","suggested_object_id":"span_panel_circuit_5_energy_produced","supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_f19d909abcc4421f9ec630f82458c7ac_producedEnergyWh","previous_unique_id":null,"unit_of_measurement":"Wh"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"total_increasing"},"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.122862+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_2_energy_consumed","hidden_by":null,"icon":null,"id":"4ed4a17c2fd701e29b972bded6caa501","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T04:46:00.497331+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"energy","original_icon":"mdi:flash","original_name":"Span Panel Lights Dining Room Consumed Energy","platform":"span_panel","suggested_object_id":"span_panel_circuit_2_energy_consumed","supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_0dad2f16cd514812ae1807b0457d473e_consumedEnergyWh","previous_unique_id":null,"unit_of_measurement":"Wh"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"total_increasing"},"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.123114+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_4_energy_consumed","hidden_by":null,"icon":null,"id":"ffaefebdeabec1b0d6f1c804f6c41c2f","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T04:46:00.497458+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"energy","original_icon":"mdi:flash","original_name":"Span Panel Lights-Outlets Bedroom Consumed Energy","platform":"span_panel","suggested_object_id":"span_panel_circuit_4_energy_consumed","supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_11a47a0f69d54e12b7200f730c2ffda1_consumedEnergyWh","previous_unique_id":null,"unit_of_measurement":"Wh"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"total_increasing"},"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.123330+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_14_energy_consumed","hidden_by":null,"icon":null,"id":"695a574927eac5558556df4669ad84aa","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T04:46:00.497576+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"energy","original_icon":"mdi:flash","original_name":"Span Panel Dining Room Wine Fridge Consumed Energy","platform":"span_panel","suggested_object_id":"span_panel_circuit_14_energy_consumed","supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_12ce227695cd44338864b0ef2ec4168b_consumedEnergyWh","previous_unique_id":null,"unit_of_measurement":"Wh"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"total_increasing"},"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.123534+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_24_energy_consumed","hidden_by":null,"icon":null,"id":"2ea34b03bfb6d02e964cdbbb195e93d7","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T04:46:00.497706+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"energy","original_icon":"mdi:flash","original_name":"Span Panel Dryer Consumed Energy","platform":"span_panel","suggested_object_id":"span_panel_circuit_24_energy_consumed","supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_1ad73b0eb44e4022bb6270c76baed0c1_consumedEnergyWh","previous_unique_id":null,"unit_of_measurement":"Wh"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"total_increasing"},"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.123772+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_16_energy_consumed","hidden_by":null,"icon":null,"id":"f5f3ca9f0f9e4b305817c523ca7b729d","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T04:46:00.497828+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"energy","original_icon":"mdi:flash","original_name":"Span Panel Outlets / Kitchen Consumed Energy","platform":"span_panel","suggested_object_id":"span_panel_circuit_16_energy_consumed","supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_31b36cde0fc642b39eec515267707a6f_consumedEnergyWh","previous_unique_id":null,"unit_of_measurement":"Wh"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"total_increasing"},"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.124040+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_19_energy_consumed","hidden_by":null,"icon":null,"id":"cbe29e8c154804c998583b92ab1f8b50","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T04:46:00.497949+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"energy","original_icon":"mdi:flash","original_name":"Span Panel Microwave & Oven Consumed Energy","platform":"span_panel","suggested_object_id":"span_panel_circuit_19_energy_consumed","supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_3a847fe6eb374ec3bf92cee1ffaf2eda_consumedEnergyWh","previous_unique_id":null,"unit_of_measurement":"Wh"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"total_increasing"},"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.124334+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_12_energy_consumed","hidden_by":null,"icon":null,"id":"92a66a0142dd430ecc90fdb35704c99f","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T04:46:00.498062+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"energy","original_icon":"mdi:flash","original_name":"Span Panel Dishwasher Consumed Energy","platform":"span_panel","suggested_object_id":"span_panel_circuit_12_energy_consumed","supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_497ae7ebb2844772bf926f2f094c81bc_consumedEnergyWh","previous_unique_id":null,"unit_of_measurement":"Wh"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"total_increasing"},"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.124555+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_29_energy_consumed","hidden_by":null,"icon":null,"id":"e72ed5302015f8c0df61879e41b7d892","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T04:46:00.498187+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"energy","original_icon":"mdi:flash","original_name":"Span Panel Spa Consumed Energy","platform":"span_panel","suggested_object_id":"span_panel_circuit_29_energy_consumed","supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_617059df47bb49bd8545a36a6b6b23d2_consumedEnergyWh","previous_unique_id":null,"unit_of_measurement":"Wh"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"total_increasing"},"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.124821+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_10_energy_consumed","hidden_by":null,"icon":null,"id":"4a2a32c8f06df1eed9bd1f2eaf60f2ff","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T04:46:00.498300+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"energy","original_icon":"mdi:flash","original_name":"Span Panel Kitchen Disposal Consumed Energy","platform":"span_panel","suggested_object_id":"span_panel_circuit_10_energy_consumed","supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_6ad65cf2ad6443448e33973f26f7df3e_consumedEnergyWh","previous_unique_id":null,"unit_of_measurement":"Wh"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"total_increasing"},"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.125066+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_25_energy_consumed","hidden_by":null,"icon":null,"id":"6ebc7934c3074a5328ea3c0e050edbe6","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T04:46:00.498428+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"energy","original_icon":"mdi:flash","original_name":"Span Panel Fountain Consumed Energy","platform":"span_panel","suggested_object_id":"span_panel_circuit_25_energy_consumed","supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_795e8eddb4f448af9625130332a41df8_consumedEnergyWh","previous_unique_id":null,"unit_of_measurement":"Wh"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"total_increasing"},"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.125316+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_23_energy_consumed","hidden_by":null,"icon":null,"id":"2902b357e59ec0244b7fd4bc9eba791d","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T04:46:00.498630+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"energy","original_icon":"mdi:flash","original_name":"Span Panel Internet Living room Consumed Energy","platform":"span_panel","suggested_object_id":"span_panel_circuit_23_energy_consumed","supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_82a0888dc072416598d99f8b74ee3d8d_consumedEnergyWh","previous_unique_id":null,"unit_of_measurement":"Wh"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"total_increasing"},"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.125535+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_18_energy_consumed","hidden_by":null,"icon":null,"id":"4b7fe86270fc6abb6241280f2d577d45","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T04:46:00.498786+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"energy","original_icon":"mdi:flash","original_name":"Span Panel Large Garage EV Consumed Energy","platform":"span_panel","suggested_object_id":"span_panel_circuit_18_energy_consumed","supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_8a2ffda9dbd24bada9a01b880e910612_consumedEnergyWh","previous_unique_id":null,"unit_of_measurement":"Wh"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"total_increasing"},"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.129208+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_9_energy_consumed","hidden_by":null,"icon":null,"id":"896e69f2f580703cc777ad4d59365a66","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T04:46:00.498908+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"energy","original_icon":"mdi:flash","original_name":"Span Panel Other Outlets Consumed Energy","platform":"span_panel","suggested_object_id":"span_panel_circuit_9_energy_consumed","supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_914943d4798c462cadf5e5144989dd5b_consumedEnergyWh","previous_unique_id":null,"unit_of_measurement":"Wh"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"total_increasing"},"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.129570+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_1_energy_consumed","hidden_by":null,"icon":null,"id":"89070e495b27cf0640b353c864e82a81","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T04:46:00.499036+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"energy","original_icon":"mdi:flash","original_name":"Span Panel Master bedroom Consumed Energy","platform":"span_panel","suggested_object_id":"span_panel_circuit_1_energy_consumed","supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_926441605113492189f9aa13dac356cd_consumedEnergyWh","previous_unique_id":null,"unit_of_measurement":"Wh"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"total_increasing"},"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.129928+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_15_energy_consumed","hidden_by":null,"icon":null,"id":"f777f9ad2278baf14d412d95685e944b","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T04:46:00.499203+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"energy","original_icon":"mdi:flash","original_name":"Span Panel Air Conditioner Consumed Energy","platform":"span_panel","suggested_object_id":"span_panel_circuit_15_energy_consumed","supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_941d6a8b41ab4c57a6b8be14b5981fe6_consumedEnergyWh","previous_unique_id":null,"unit_of_measurement":"Wh"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"total_increasing"},"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.130202+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_7_energy_consumed","hidden_by":null,"icon":null,"id":"8920426e8b671af4900dc5a6074c385d","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T04:46:00.499350+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"energy","original_icon":"mdi:flash","original_name":"Span Panel Garage Outlets Consumed Energy","platform":"span_panel","suggested_object_id":"span_panel_circuit_7_energy_consumed","supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_988af7bb1fc04aac8bb3b45c660d920a_consumedEnergyWh","previous_unique_id":null,"unit_of_measurement":"Wh"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"total_increasing"},"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.130469+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_22_energy_consumed","hidden_by":null,"icon":null,"id":"6b09fc81690875658dee9ece3c68f415","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T04:46:00.499474+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"energy","original_icon":"mdi:flash","original_name":"Span Panel Refrigerator Consumed Energy","platform":"span_panel","suggested_object_id":"span_panel_circuit_22_energy_consumed","supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_9941b417bfa046ef908d97c998c54c50_consumedEnergyWh","previous_unique_id":null,"unit_of_measurement":"Wh"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"total_increasing"},"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.130771+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_6_energy_consumed","hidden_by":null,"icon":null,"id":"ad28024d7650a3e486e57d2485e471da","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T04:46:00.499611+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"energy","original_icon":"mdi:flash","original_name":"Span Panel Furnace Consumed Energy","platform":"span_panel","suggested_object_id":"span_panel_circuit_6_energy_consumed","supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_cbe98d7307ca42658983b9f673211e14_consumedEnergyWh","previous_unique_id":null,"unit_of_measurement":"Wh"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"total_increasing"},"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.131036+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_11_energy_consumed","hidden_by":null,"icon":null,"id":"87a13560f5a4091ada85becca9998c07","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T04:46:00.499778+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"energy","original_icon":"mdi:flash","original_name":"Span Panel Small Garage EV Consumed Energy","platform":"span_panel","suggested_object_id":"span_panel_circuit_11_energy_consumed","supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_d88c0c0e7c584472b2ec7e4d3c53c3b8_consumedEnergyWh","previous_unique_id":null,"unit_of_measurement":"Wh"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"total_increasing"},"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.131296+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_8_energy_consumed","hidden_by":null,"icon":null,"id":"35328f1b6994513f9103f3dee7fdb955","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T04:46:00.500218+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"energy","original_icon":"mdi:flash","original_name":"Span Panel Washing Machine Area Consumed Energy","platform":"span_panel","suggested_object_id":"span_panel_circuit_8_energy_consumed","supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_e3cb78d38dae41cdb50faee7bfa4ab80_consumedEnergyWh","previous_unique_id":null,"unit_of_measurement":"Wh"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"total_increasing"},"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.131593+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_3_energy_consumed","hidden_by":null,"icon":null,"id":"657a5a8b0743caf26ca89263fdf0cea6","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T04:46:00.500417+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"energy","original_icon":"mdi:flash","original_name":"Span Panel Kitchen and Master Bath Consumed Energy","platform":"span_panel","suggested_object_id":"span_panel_circuit_3_energy_consumed","supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_ece352c3dc5c417e80757b716f24ba90_consumedEnergyWh","previous_unique_id":null,"unit_of_measurement":"Wh"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"total_increasing"},"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.131858+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_5_energy_consumed","hidden_by":null,"icon":null,"id":"1f9d81acff8b1dcb6c7adbd37f2d29dc","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T04:46:00.500703+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"energy","original_icon":"mdi:flash","original_name":"Span Panel Lights Kitchen Master bathroom Consumed Energy","platform":"span_panel","suggested_object_id":"span_panel_circuit_5_energy_consumed","supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_f19d909abcc4421f9ec630f82458c7ac_consumedEnergyWh","previous_unique_id":null,"unit_of_measurement":"Wh"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-11T22:56:33.834254+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"switch.span_panel_lights_dining_room_breaker","hidden_by":null,"icon":null,"id":"abf75c314604d60956cc2227db59b89c","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T04:46:00.507623+00:00","name":null,"options":{"conversation":{"should_expose":true}},"original_device_class":null,"original_icon":"mdi:toggle-switch","original_name":"Lights Dining Room Breaker","platform":"span_panel","suggested_object_id":"span_panel_circuit_2_breaker","supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_relay_0dad2f16cd514812ae1807b0457d473e","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-11T22:56:33.834459+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"switch.span_panel_lights_outlets_bedroom_breaker","hidden_by":null,"icon":null,"id":"6c3ef18902e7d4838f095ff553f2623f","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T04:46:00.507961+00:00","name":null,"options":{"conversation":{"should_expose":true}},"original_device_class":null,"original_icon":"mdi:toggle-switch","original_name":"Lights-Outlets Bedroom Breaker","platform":"span_panel","suggested_object_id":"span_panel_circuit_4_breaker","supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_relay_11a47a0f69d54e12b7200f730c2ffda1","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-11T22:56:33.834613+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"switch.span_panel_dining_room_wine_fridge_breaker","hidden_by":null,"icon":null,"id":"22012431ed7713a0d9f042ea8b61f310","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T04:46:00.508087+00:00","name":null,"options":{"conversation":{"should_expose":true}},"original_device_class":null,"original_icon":"mdi:toggle-switch","original_name":"Dining Room Wine Fridge Breaker","platform":"span_panel","suggested_object_id":"span_panel_circuit_14_breaker","supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_relay_12ce227695cd44338864b0ef2ec4168b","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-11T22:56:33.834758+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"switch.span_panel_dryer_breaker","hidden_by":null,"icon":null,"id":"e4d046f5770a49b011116e2e498b67a3","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T04:46:00.508200+00:00","name":null,"options":{"conversation":{"should_expose":true}},"original_device_class":null,"original_icon":"mdi:toggle-switch","original_name":"Dryer Breaker","platform":"span_panel","suggested_object_id":"span_panel_circuit_24_breaker","supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_relay_1ad73b0eb44e4022bb6270c76baed0c1","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-11T22:56:33.834887+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"switch.span_panel_circuit_16_breaker","hidden_by":null,"icon":null,"id":"8202c353497db18a59f4adeb731e58af","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T04:46:00.508300+00:00","name":null,"options":{"conversation":{"should_expose":true}},"original_device_class":null,"original_icon":"mdi:toggle-switch","original_name":"Outlets / Kitchen Breaker","platform":"span_panel","suggested_object_id":"span_panel_circuit_16_breaker","supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_relay_31b36cde0fc642b39eec515267707a6f","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-11T22:56:33.835022+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"switch.span_panel_circuit_19_breaker","hidden_by":null,"icon":null,"id":"3cee4a96dcf07e2ded224f35d7ff172c","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T04:46:00.508402+00:00","name":null,"options":{"conversation":{"should_expose":true}},"original_device_class":null,"original_icon":"mdi:toggle-switch","original_name":"Microwave & Oven Breaker","platform":"span_panel","suggested_object_id":"span_panel_circuit_19_breaker","supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_relay_3a847fe6eb374ec3bf92cee1ffaf2eda","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-11T22:56:33.835244+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"switch.span_panel_dishwasher_breaker","hidden_by":null,"icon":null,"id":"2bfa5ec9f6000a428c1010c32f802cf6","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T04:46:00.508500+00:00","name":null,"options":{"conversation":{"should_expose":true}},"original_device_class":null,"original_icon":"mdi:toggle-switch","original_name":"Dishwasher Breaker","platform":"span_panel","suggested_object_id":"span_panel_circuit_12_breaker","supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_relay_497ae7ebb2844772bf926f2f094c81bc","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-11T22:56:33.835374+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"switch.span_panel_spa_breaker","hidden_by":null,"icon":null,"id":"8b8a34f22d729f06f87020cb853376e7","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T04:46:00.508614+00:00","name":null,"options":{"conversation":{"should_expose":true}},"original_device_class":null,"original_icon":"mdi:toggle-switch","original_name":"Spa Breaker","platform":"span_panel","suggested_object_id":"span_panel_circuit_29_breaker","supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_relay_617059df47bb49bd8545a36a6b6b23d2","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-11T22:56:33.835496+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"switch.span_panel_kitchen_disposal_breaker","hidden_by":null,"icon":null,"id":"653266580714ee7b92caab4462760402","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T04:46:00.509195+00:00","name":null,"options":{"conversation":{"should_expose":true}},"original_device_class":null,"original_icon":"mdi:toggle-switch","original_name":"Kitchen Disposal Breaker","platform":"span_panel","suggested_object_id":"span_panel_circuit_10_breaker","supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_relay_6ad65cf2ad6443448e33973f26f7df3e","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-11T22:56:33.835616+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"switch.span_panel_fountain_breaker","hidden_by":null,"icon":null,"id":"62e1dc596a8a0a34844df17d715adc01","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T04:46:00.509443+00:00","name":null,"options":{"conversation":{"should_expose":true}},"original_device_class":null,"original_icon":"mdi:toggle-switch","original_name":"Fountain Breaker","platform":"span_panel","suggested_object_id":"span_panel_circuit_25_breaker","supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_relay_795e8eddb4f448af9625130332a41df8","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-11T22:56:33.835736+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"switch.span_panel_large_garage_ev_breaker","hidden_by":null,"icon":null,"id":"b5fa556532e8e0a0933a6d9247c96810","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T04:46:00.509684+00:00","name":null,"options":{"conversation":{"should_expose":true}},"original_device_class":null,"original_icon":"mdi:toggle-switch","original_name":"Large Garage EV Breaker","platform":"span_panel","suggested_object_id":"span_panel_circuit_18_breaker","supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_relay_8a2ffda9dbd24bada9a01b880e910612","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-11T22:56:33.835858+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"switch.span_panel_other_outlets_breaker","hidden_by":null,"icon":null,"id":"7dc771b4bd4a64ab45c40abf758d95ae","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T04:46:00.509823+00:00","name":null,"options":{"conversation":{"should_expose":true}},"original_device_class":null,"original_icon":"mdi:toggle-switch","original_name":"Other Outlets Breaker","platform":"span_panel","suggested_object_id":"span_panel_circuit_9_breaker","supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_relay_914943d4798c462cadf5e5144989dd5b","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-11T22:56:33.835974+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"switch.span_panel_master_bedroom_breaker","hidden_by":null,"icon":null,"id":"4b508d23de583de1bb74b78ba1999bf2","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T04:46:00.509927+00:00","name":null,"options":{"conversation":{"should_expose":true}},"original_device_class":null,"original_icon":"mdi:toggle-switch","original_name":"Master bedroom Breaker","platform":"span_panel","suggested_object_id":"span_panel_circuit_1_breaker","supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_relay_926441605113492189f9aa13dac356cd","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-11T22:56:33.836104+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"switch.span_panel_air_conditioner_breaker","hidden_by":null,"icon":null,"id":"bd59e8ad90665772b1a5075d71f00ec7","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T04:46:00.510024+00:00","name":null,"options":{"conversation":{"should_expose":true}},"original_device_class":null,"original_icon":"mdi:toggle-switch","original_name":"Air Conditioner Breaker","platform":"span_panel","suggested_object_id":"span_panel_circuit_15_breaker","supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_relay_941d6a8b41ab4c57a6b8be14b5981fe6","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-11T22:56:33.836228+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"switch.span_panel_garage_outlets_breaker","hidden_by":null,"icon":null,"id":"eb299c0c60b4d88e35a7b8229c647550","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T04:46:00.510152+00:00","name":null,"options":{"conversation":{"should_expose":true}},"original_device_class":null,"original_icon":"mdi:toggle-switch","original_name":"Garage Outlets Breaker","platform":"span_panel","suggested_object_id":"span_panel_circuit_7_breaker","supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_relay_988af7bb1fc04aac8bb3b45c660d920a","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-11T22:56:33.838517+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"switch.span_panel_furnace_breaker","hidden_by":null,"icon":null,"id":"50c9dcba436a5431985f450045ef8af2","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T04:46:00.510278+00:00","name":null,"options":{"conversation":{"should_expose":true}},"original_device_class":null,"original_icon":"mdi:toggle-switch","original_name":"Furnace Breaker","platform":"span_panel","suggested_object_id":"span_panel_circuit_6_breaker","supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_relay_cbe98d7307ca42658983b9f673211e14","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-11T22:56:33.838676+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"switch.span_panel_small_garage_ev_breaker","hidden_by":null,"icon":null,"id":"f34ce27d35c79d38133f05ce2bb2e77a","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T04:46:00.510376+00:00","name":null,"options":{"conversation":{"should_expose":true}},"original_device_class":null,"original_icon":"mdi:toggle-switch","original_name":"Small Garage EV Breaker","platform":"span_panel","suggested_object_id":"span_panel_circuit_11_breaker","supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_relay_d88c0c0e7c584472b2ec7e4d3c53c3b8","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-11T22:56:33.838819+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"switch.span_panel_washing_machine_area_breaker","hidden_by":null,"icon":null,"id":"505168539e65f1b166ed75b9176964e1","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T04:46:00.510467+00:00","name":null,"options":{"conversation":{"should_expose":true}},"original_device_class":null,"original_icon":"mdi:toggle-switch","original_name":"Washing Machine Area Breaker","platform":"span_panel","suggested_object_id":"span_panel_circuit_8_breaker","supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_relay_e3cb78d38dae41cdb50faee7bfa4ab80","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-11T22:56:33.838946+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"switch.span_panel_kitchen_and_master_bath_breaker","hidden_by":null,"icon":null,"id":"5ef055afc70668b2a83494559e69989f","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T04:46:00.510554+00:00","name":null,"options":{"conversation":{"should_expose":true}},"original_device_class":null,"original_icon":"mdi:toggle-switch","original_name":"Kitchen and Master Bath Breaker","platform":"span_panel","suggested_object_id":"span_panel_circuit_3_breaker","supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_relay_ece352c3dc5c417e80757b716f24ba90","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-11T22:56:33.839146+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"switch.span_panel_lights_kitchen_master_bathroom_breaker","hidden_by":null,"icon":null,"id":"0d39a51931c34b31205dcef3cfb785f3","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T04:46:00.510655+00:00","name":null,"options":{"conversation":{"should_expose":true}},"original_device_class":null,"original_icon":"mdi:toggle-switch","original_name":"Lights Kitchen Master bathroom Breaker","platform":"span_panel","suggested_object_id":"span_panel_circuit_5_breaker","supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_relay_f19d909abcc4421f9ec630f82458c7ac","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"measurement"},"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-12T04:16:23.168620+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.solar_inverter_instant_power","hidden_by":null,"icon":null,"id":"bbbf0b8e433395ddaecdd05c6915ebf3","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T04:52:41.152312+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"power","original_icon":"mdi:flash","original_name":"Solar Inverter Instant Power","platform":"span_panel","suggested_object_id":"span_panel_circuit_30_32_instant_power","supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_synthetic_30_32_solar_inverter_instant_power","previous_unique_id":null,"unit_of_measurement":"W"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"total_increasing"},"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-12T04:16:23.168989+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.solar_inverter_energy_produced","hidden_by":null,"icon":null,"id":"35cb5d091a68b0ef168b9e403b4828f2","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T04:52:41.152760+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"energy","original_icon":"mdi:flash","original_name":"Solar Inverter Energy Produced","platform":"span_panel","suggested_object_id":"span_panel_circuit_30_32_energy_produced","supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_synthetic_30_32_solar_inverter_energy_produced","previous_unique_id":null,"unit_of_measurement":"Wh"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"total_increasing"},"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-12T04:16:23.169246+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.solar_inverter_energy_consumed","hidden_by":null,"icon":null,"id":"8a414d91c061798fc15d13c213930a7f","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T04:52:41.152942+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"energy","original_icon":"mdi:flash","original_name":"Solar Inverter Energy Consumed","platform":"span_panel","suggested_object_id":"span_panel_circuit_30_32_energy_consumed","supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_synthetic_30_32_solar_inverter_energy_consumed","previous_unique_id":null,"unit_of_measurement":"Wh"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"measurement"},"config_entry_id":"01K2RK5SERMY18KM0CMQTGAMNY","config_subentry_id":null,"created_at":"2025-08-13T18:47:05.951473+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_span_storage_battery_percentage","hidden_by":null,"icon":null,"id":"e9d1e9107609826104e27186528e1ba6","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T04:52:41.158390+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":"battery","original_icon":"mdi:flash","original_name":"Span Panel SPAN Storage Battery Percentage","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_batteryPercentage","previous_unique_id":null,"unit_of_measurement":"%"} - ], - "deleted_entities": [ - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.848789+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_2_power","hidden_by":null,"icon":null,"id":"808cbd5c6138e389ef0af19c89450fb4","labels":[],"modified_at":"2025-08-13T18:33:35.796773+00:00","name":null,"options":{"conversation":{"should_expose":false},"sensor":{"suggested_display_precision":0}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_0dad2f16cd514812ae1807b0457d473e_power"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.849038+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_4_power","hidden_by":null,"icon":null,"id":"46bb736639b15e9a3d83feeda164d353","labels":[],"modified_at":"2025-08-13T18:33:35.796800+00:00","name":null,"options":{"conversation":{"should_expose":false},"sensor":{"suggested_display_precision":0}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_11a47a0f69d54e12b7200f730c2ffda1_power"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.849275+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_14_power","hidden_by":null,"icon":null,"id":"4c1a95ce6da6c8f8a0a7e322462cfa71","labels":[],"modified_at":"2025-08-13T18:33:35.796827+00:00","name":null,"options":{"conversation":{"should_expose":false},"sensor":{"suggested_display_precision":0}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_12ce227695cd44338864b0ef2ec4168b_power"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.850201+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_24_power","hidden_by":null,"icon":null,"id":"08d1fce03c6fce3a73fcd510c109e750","labels":[],"modified_at":"2025-08-13T18:33:35.796851+00:00","name":null,"options":{"conversation":{"should_expose":false},"sensor":{"suggested_display_precision":0}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_1ad73b0eb44e4022bb6270c76baed0c1_power"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.850595+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_16_power","hidden_by":null,"icon":null,"id":"a4e06e925cc86af49c0dac4531bd9ffe","labels":[],"modified_at":"2025-08-13T18:33:35.796879+00:00","name":null,"options":{"conversation":{"should_expose":false},"sensor":{"suggested_display_precision":0}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_31b36cde0fc642b39eec515267707a6f_power"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.850851+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_19_power","hidden_by":null,"icon":null,"id":"7c67df022b0ab1d2203bd030a9108114","labels":[],"modified_at":"2025-08-13T18:33:35.796907+00:00","name":null,"options":{"conversation":{"should_expose":false},"sensor":{"suggested_display_precision":0}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_3a847fe6eb374ec3bf92cee1ffaf2eda_power"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.851162+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_12_power","hidden_by":null,"icon":null,"id":"a2edd951e6b91d057b4339351d89a896","labels":[],"modified_at":"2025-08-13T18:33:35.796934+00:00","name":null,"options":{"conversation":{"should_expose":false},"sensor":{"suggested_display_precision":0}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_497ae7ebb2844772bf926f2f094c81bc_power"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.851387+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_29_power","hidden_by":null,"icon":null,"id":"6d253f88fdb2c0bf2d76ec2dc1caf87a","labels":[],"modified_at":"2025-08-13T18:33:35.796960+00:00","name":null,"options":{"conversation":{"should_expose":false},"sensor":{"suggested_display_precision":0}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_617059df47bb49bd8545a36a6b6b23d2_power"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.851625+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_10_power","hidden_by":null,"icon":null,"id":"544e891765d44c5768796c475754fc8d","labels":[],"modified_at":"2025-08-13T18:33:35.796988+00:00","name":null,"options":{"conversation":{"should_expose":false},"sensor":{"suggested_display_precision":0}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_6ad65cf2ad6443448e33973f26f7df3e_power"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.851838+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_25_power","hidden_by":null,"icon":null,"id":"81251feabd0ce07e232feef278857d82","labels":[],"modified_at":"2025-08-13T18:33:35.797024+00:00","name":null,"options":{"conversation":{"should_expose":false},"sensor":{"suggested_display_precision":0}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_795e8eddb4f448af9625130332a41df8_power"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.852103+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_23_power","hidden_by":null,"icon":null,"id":"7061756b76400dbb4cadaf795dd51bca","labels":[],"modified_at":"2025-08-13T18:33:35.797052+00:00","name":null,"options":{"conversation":{"should_expose":false},"sensor":{"suggested_display_precision":0}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_82a0888dc072416598d99f8b74ee3d8d_power"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.852378+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_18_power","hidden_by":null,"icon":null,"id":"b1e68b4ea9eca8cc9feab4a118febc88","labels":[],"modified_at":"2025-08-13T18:33:35.797078+00:00","name":null,"options":{"conversation":{"should_expose":false},"sensor":{"suggested_display_precision":0}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_8a2ffda9dbd24bada9a01b880e910612_power"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.852592+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_9_power","hidden_by":null,"icon":null,"id":"1001fa7cc8cf6db7c40c9a4a11ca2f75","labels":[],"modified_at":"2025-08-13T18:33:35.797105+00:00","name":null,"options":{"conversation":{"should_expose":false},"sensor":{"suggested_display_precision":0}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_914943d4798c462cadf5e5144989dd5b_power"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.852800+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_1_power","hidden_by":null,"icon":null,"id":"a8127dad86761b405d2882223b22e75b","labels":[],"modified_at":"2025-08-13T18:33:35.797129+00:00","name":null,"options":{"conversation":{"should_expose":false},"sensor":{"suggested_display_precision":0}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_926441605113492189f9aa13dac356cd_power"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.853409+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_15_power","hidden_by":null,"icon":null,"id":"31e77b72e1286df7e7a22d3ae708c728","labels":[],"modified_at":"2025-08-13T18:33:35.797158+00:00","name":null,"options":{"conversation":{"should_expose":false},"sensor":{"suggested_display_precision":0}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_941d6a8b41ab4c57a6b8be14b5981fe6_power"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.853683+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_7_power","hidden_by":null,"icon":null,"id":"9b34ed1127961604c9674876992b6af1","labels":[],"modified_at":"2025-08-13T18:33:35.797182+00:00","name":null,"options":{"conversation":{"should_expose":false},"sensor":{"suggested_display_precision":0}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_988af7bb1fc04aac8bb3b45c660d920a_power"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.853947+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_22_power","hidden_by":null,"icon":null,"id":"9ccfd2a6ad3cf45528738544e866aa8a","labels":[],"modified_at":"2025-08-13T18:33:35.797210+00:00","name":null,"options":{"conversation":{"should_expose":false},"sensor":{"suggested_display_precision":0}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_9941b417bfa046ef908d97c998c54c50_power"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.854268+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_6_power","hidden_by":null,"icon":null,"id":"f01a1b09a0b8767e1c651baa086a82bf","labels":[],"modified_at":"2025-08-13T18:33:35.797246+00:00","name":null,"options":{"conversation":{"should_expose":false},"sensor":{"suggested_display_precision":0}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_cbe98d7307ca42658983b9f673211e14_power"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.854506+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_11_power","hidden_by":null,"icon":null,"id":"0b0ec77e24c008b39d13839762a7245e","labels":[],"modified_at":"2025-08-13T18:33:35.797277+00:00","name":null,"options":{"conversation":{"should_expose":false},"sensor":{"suggested_display_precision":0}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_d88c0c0e7c584472b2ec7e4d3c53c3b8_power"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.854784+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_8_power","hidden_by":null,"icon":null,"id":"3ffe5f5eea79b511979eb2c41d639a3a","labels":[],"modified_at":"2025-08-13T18:33:35.797306+00:00","name":null,"options":{"conversation":{"should_expose":false},"sensor":{"suggested_display_precision":0}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_e3cb78d38dae41cdb50faee7bfa4ab80_power"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.855111+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_3_power","hidden_by":null,"icon":null,"id":"16a42513be41d433858e97f70aebe650","labels":[],"modified_at":"2025-08-13T18:33:35.797337+00:00","name":null,"options":{"conversation":{"should_expose":false},"sensor":{"suggested_display_precision":0}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_ece352c3dc5c417e80757b716f24ba90_power"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.855721+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_5_power","hidden_by":null,"icon":null,"id":"04e738f071279ac2ae07d61bc6715e5f","labels":[],"modified_at":"2025-08-13T18:33:35.797365+00:00","name":null,"options":{"conversation":{"should_expose":false},"sensor":{"suggested_display_precision":0}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_f19d909abcc4421f9ec630f82458c7ac_power"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2FZ30F62NF62FJ79KJ5ZXHK","config_subentry_id":null,"created_at":"2025-08-12T20:21:01.544626+00:00","device_class":null,"disabled_by":"integration","entity_id":"binary_sensor.sun_solar_rising_3","hidden_by":null,"icon":null,"id":"bbe4113be77060eafb43ec8518793f00","labels":[],"modified_at":"2025-08-12T20:49:00.956736+00:00","name":null,"options":{},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2FZ30F62NF62FJ79KJ5ZXHK-solar_rising"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2FZ30F62NF62FJ79KJ5ZXHK","config_subentry_id":null,"created_at":"2025-08-12T20:21:01.545280+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_dawn_3","hidden_by":null,"icon":null,"id":"55f6a91678b3459e4cae6040ec753401","labels":[],"modified_at":"2025-08-12T20:49:00.956869+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2FZ30F62NF62FJ79KJ5ZXHK-next_dawn"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2FZ30F62NF62FJ79KJ5ZXHK","config_subentry_id":null,"created_at":"2025-08-12T20:21:01.545515+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_dusk_3","hidden_by":null,"icon":null,"id":"e5905681bd77dcc1d4fa2ce856600d46","labels":[],"modified_at":"2025-08-12T20:49:00.956952+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2FZ30F62NF62FJ79KJ5ZXHK-next_dusk"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2FZ30F62NF62FJ79KJ5ZXHK","config_subentry_id":null,"created_at":"2025-08-12T20:21:01.545939+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_midnight_3","hidden_by":null,"icon":null,"id":"d69c42bb7be140251ae233402795cc20","labels":[],"modified_at":"2025-08-12T20:49:00.956991+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2FZ30F62NF62FJ79KJ5ZXHK-next_midnight"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2FZ30F62NF62FJ79KJ5ZXHK","config_subentry_id":null,"created_at":"2025-08-12T20:21:01.546178+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_noon_3","hidden_by":null,"icon":null,"id":"75e1512e025bf6fd79661f5a2fd3e35f","labels":[],"modified_at":"2025-08-12T20:49:00.957066+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2FZ30F62NF62FJ79KJ5ZXHK-next_noon"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2FZ30F62NF62FJ79KJ5ZXHK","config_subentry_id":null,"created_at":"2025-08-12T20:21:01.546328+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_rising_3","hidden_by":null,"icon":null,"id":"b1655977ea34852f5bb7abf947ba0365","labels":[],"modified_at":"2025-08-12T20:49:00.957095+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2FZ30F62NF62FJ79KJ5ZXHK-next_rising"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2FZ30F62NF62FJ79KJ5ZXHK","config_subentry_id":null,"created_at":"2025-08-12T20:21:01.546442+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_setting_3","hidden_by":null,"icon":null,"id":"62730a8e0bd730e400ecbb74bc806dcb","labels":[],"modified_at":"2025-08-12T20:49:00.957122+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2FZ30F62NF62FJ79KJ5ZXHK-next_setting"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2FZ30F62NF62FJ79KJ5ZXHK","config_subentry_id":null,"created_at":"2025-08-12T20:21:01.546541+00:00","device_class":null,"disabled_by":"integration","entity_id":"sensor.sun_solar_elevation_3","hidden_by":null,"icon":null,"id":"136e4a6f2c7639255e89866a9aff6cfd","labels":[],"modified_at":"2025-08-12T20:49:00.957147+00:00","name":null,"options":{},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2FZ30F62NF62FJ79KJ5ZXHK-solar_elevation"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2FZ30F62NF62FJ79KJ5ZXHK","config_subentry_id":null,"created_at":"2025-08-12T20:21:01.546599+00:00","device_class":null,"disabled_by":"integration","entity_id":"sensor.sun_solar_azimuth_3","hidden_by":null,"icon":null,"id":"60b33e14ac5406effc757c750b4f3c56","labels":[],"modified_at":"2025-08-12T20:49:00.957167+00:00","name":null,"options":{},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2FZ30F62NF62FJ79KJ5ZXHK-solar_azimuth"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2FZ30F62NF62FJ79KJ5ZXHK","config_subentry_id":null,"created_at":"2025-08-12T20:21:01.546649+00:00","device_class":null,"disabled_by":"integration","entity_id":"sensor.sun_solar_rising_3","hidden_by":null,"icon":null,"id":"3b90254bfc80421e1b0619ff78f2e5ed","labels":[],"modified_at":"2025-08-12T20:49:00.957190+00:00","name":null,"options":{},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2FZ30F62NF62FJ79KJ5ZXHK-solar_rising"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2G0NYH80DCKVGCMD9AS1TTS","config_subentry_id":null,"created_at":"2025-08-12T20:48:50.729971+00:00","device_class":null,"disabled_by":"integration","entity_id":"binary_sensor.sun_solar_rising_4","hidden_by":null,"icon":null,"id":"4a78bb88e4d02103c19171414e7bb6d5","labels":[],"modified_at":"2025-08-12T21:03:25.826235+00:00","name":null,"options":{},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2G0NYH80DCKVGCMD9AS1TTS-solar_rising"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2G0NYH80DCKVGCMD9AS1TTS","config_subentry_id":null,"created_at":"2025-08-12T20:48:50.730481+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_dawn_4","hidden_by":null,"icon":null,"id":"5f51661d9af9833d8ab2e97896eeae8c","labels":[],"modified_at":"2025-08-12T21:03:25.826399+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2G0NYH80DCKVGCMD9AS1TTS-next_dawn"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2G0NYH80DCKVGCMD9AS1TTS","config_subentry_id":null,"created_at":"2025-08-12T20:48:50.730684+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_dusk_4","hidden_by":null,"icon":null,"id":"4cbaa44cd430215ae208349f2b353a8a","labels":[],"modified_at":"2025-08-12T21:03:25.826491+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2G0NYH80DCKVGCMD9AS1TTS-next_dusk"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2G0NYH80DCKVGCMD9AS1TTS","config_subentry_id":null,"created_at":"2025-08-12T20:48:50.730806+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_midnight_4","hidden_by":null,"icon":null,"id":"7bac2228d74421a0ccea5eaec5035073","labels":[],"modified_at":"2025-08-12T21:03:25.826533+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2G0NYH80DCKVGCMD9AS1TTS-next_midnight"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2G0NYH80DCKVGCMD9AS1TTS","config_subentry_id":null,"created_at":"2025-08-12T20:48:50.730909+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_noon_4","hidden_by":null,"icon":null,"id":"e48d755ea0e0643621b23872f84297bf","labels":[],"modified_at":"2025-08-12T21:03:25.826570+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2G0NYH80DCKVGCMD9AS1TTS-next_noon"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2G0NYH80DCKVGCMD9AS1TTS","config_subentry_id":null,"created_at":"2025-08-12T20:48:50.731013+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_rising_4","hidden_by":null,"icon":null,"id":"fd07e450559468a027639d53ccc9bc6a","labels":[],"modified_at":"2025-08-12T21:03:25.826605+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2G0NYH80DCKVGCMD9AS1TTS-next_rising"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2G0NYH80DCKVGCMD9AS1TTS","config_subentry_id":null,"created_at":"2025-08-12T20:48:50.731130+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_setting_4","hidden_by":null,"icon":null,"id":"f47902b0280403b5f0e94c8c3024c50f","labels":[],"modified_at":"2025-08-12T21:03:25.826641+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2G0NYH80DCKVGCMD9AS1TTS-next_setting"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2G0NYH80DCKVGCMD9AS1TTS","config_subentry_id":null,"created_at":"2025-08-12T20:48:50.731242+00:00","device_class":null,"disabled_by":"integration","entity_id":"sensor.sun_solar_elevation_4","hidden_by":null,"icon":null,"id":"45310e2fb4635e4bae111090e20a1ace","labels":[],"modified_at":"2025-08-12T21:03:25.826674+00:00","name":null,"options":{},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2G0NYH80DCKVGCMD9AS1TTS-solar_elevation"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2G0NYH80DCKVGCMD9AS1TTS","config_subentry_id":null,"created_at":"2025-08-12T20:48:50.731307+00:00","device_class":null,"disabled_by":"integration","entity_id":"sensor.sun_solar_azimuth_4","hidden_by":null,"icon":null,"id":"336e819bf7d6789704abbd789f79040b","labels":[],"modified_at":"2025-08-12T21:03:25.826700+00:00","name":null,"options":{},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2G0NYH80DCKVGCMD9AS1TTS-solar_azimuth"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2G0NYH80DCKVGCMD9AS1TTS","config_subentry_id":null,"created_at":"2025-08-12T20:48:50.731359+00:00","device_class":null,"disabled_by":"integration","entity_id":"sensor.sun_solar_rising_4","hidden_by":null,"icon":null,"id":"a3bd32eda3845cf54516a975cb5cce09","labels":[],"modified_at":"2025-08-12T21:03:25.826724+00:00","name":null,"options":{},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2G0NYH80DCKVGCMD9AS1TTS-solar_rising"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.846080+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_current_power","hidden_by":null,"icon":null,"id":"444d9a4d8e0f4acc5b63044d0db4194c","labels":[],"modified_at":"2025-08-13T18:33:35.796474+00:00","name":null,"options":{"conversation":{"should_expose":false},"sensor":{"suggested_display_precision":0}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_current_power"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.846478+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_feed_through_power","hidden_by":null,"icon":null,"id":"7ae32c94f4a624ed9364aaf3e925251d","labels":[],"modified_at":"2025-08-13T18:33:35.796503+00:00","name":null,"options":{"conversation":{"should_expose":false},"sensor":{"suggested_display_precision":0}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_feed_through_power"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2G1GB4DMFSVY3M5FETX1M19","config_subentry_id":null,"created_at":"2025-08-12T21:03:15.598833+00:00","device_class":null,"disabled_by":"integration","entity_id":"binary_sensor.sun_solar_rising_3","hidden_by":null,"icon":null,"id":"bb9876bd58a8f8c291dcba452410d4b1","labels":[],"modified_at":"2025-08-12T21:27:09.457808+00:00","name":null,"options":{},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2G1GB4DMFSVY3M5FETX1M19-solar_rising"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2G1GB4DMFSVY3M5FETX1M19","config_subentry_id":null,"created_at":"2025-08-12T21:03:15.599238+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_dawn_3","hidden_by":null,"icon":null,"id":"fb6325652926e58235d853b7a8c7c514","labels":[],"modified_at":"2025-08-12T21:27:09.457870+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2G1GB4DMFSVY3M5FETX1M19-next_dawn"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2G1GB4DMFSVY3M5FETX1M19","config_subentry_id":null,"created_at":"2025-08-12T21:03:15.599482+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_dusk_3","hidden_by":null,"icon":null,"id":"fb6eda8a86c7bdb6636a9cd636d9d9d1","labels":[],"modified_at":"2025-08-12T21:27:09.457932+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2G1GB4DMFSVY3M5FETX1M19-next_dusk"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2G1GB4DMFSVY3M5FETX1M19","config_subentry_id":null,"created_at":"2025-08-12T21:03:15.599642+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_midnight_3","hidden_by":null,"icon":null,"id":"c489480c63b490d237664e65b92988cd","labels":[],"modified_at":"2025-08-12T21:27:09.457964+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2G1GB4DMFSVY3M5FETX1M19-next_midnight"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2G1GB4DMFSVY3M5FETX1M19","config_subentry_id":null,"created_at":"2025-08-12T21:03:15.599784+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_noon_3","hidden_by":null,"icon":null,"id":"f21f5b2b38c8761c19962297646fe8fb","labels":[],"modified_at":"2025-08-12T21:27:09.457991+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2G1GB4DMFSVY3M5FETX1M19-next_noon"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2G1GB4DMFSVY3M5FETX1M19","config_subentry_id":null,"created_at":"2025-08-12T21:03:15.599923+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_rising_3","hidden_by":null,"icon":null,"id":"e4f3ac5ad411e5906fa7bb9938090639","labels":[],"modified_at":"2025-08-12T21:27:09.458061+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2G1GB4DMFSVY3M5FETX1M19-next_rising"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2G1GB4DMFSVY3M5FETX1M19","config_subentry_id":null,"created_at":"2025-08-12T21:03:15.600266+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_setting_3","hidden_by":null,"icon":null,"id":"52d4f2329ee041ddc1e00e23c4813e42","labels":[],"modified_at":"2025-08-12T21:27:09.458090+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2G1GB4DMFSVY3M5FETX1M19-next_setting"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2G1GB4DMFSVY3M5FETX1M19","config_subentry_id":null,"created_at":"2025-08-12T21:03:15.600448+00:00","device_class":null,"disabled_by":"integration","entity_id":"sensor.sun_solar_elevation_3","hidden_by":null,"icon":null,"id":"45fe4f3258240d7db77198b0cfbe538d","labels":[],"modified_at":"2025-08-12T21:27:09.458116+00:00","name":null,"options":{},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2G1GB4DMFSVY3M5FETX1M19-solar_elevation"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2G1GB4DMFSVY3M5FETX1M19","config_subentry_id":null,"created_at":"2025-08-12T21:03:15.600550+00:00","device_class":null,"disabled_by":"integration","entity_id":"sensor.sun_solar_azimuth_3","hidden_by":null,"icon":null,"id":"f9b9eaedf5c19bbb09dbb15a4eb65e19","labels":[],"modified_at":"2025-08-12T21:27:09.458136+00:00","name":null,"options":{},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2G1GB4DMFSVY3M5FETX1M19-solar_azimuth"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2G1GB4DMFSVY3M5FETX1M19","config_subentry_id":null,"created_at":"2025-08-12T21:03:15.600631+00:00","device_class":null,"disabled_by":"integration","entity_id":"sensor.sun_solar_rising_3","hidden_by":null,"icon":null,"id":"22de19030595f3af919adf8870163516","labels":[],"modified_at":"2025-08-12T21:27:09.458155+00:00","name":null,"options":{},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2G1GB4DMFSVY3M5FETX1M19-solar_rising"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2G2VSCAFTC14E0R1NPSX97N","config_subentry_id":null,"created_at":"2025-08-12T21:26:59.212526+00:00","device_class":null,"disabled_by":"integration","entity_id":"binary_sensor.sun_solar_rising_4","hidden_by":null,"icon":null,"id":"f34eec7fa1cca27a98309c159d3fc53b","labels":[],"modified_at":"2025-08-12T21:36:44.167709+00:00","name":null,"options":{},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2G2VSCAFTC14E0R1NPSX97N-solar_rising"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2G2VSCAFTC14E0R1NPSX97N","config_subentry_id":null,"created_at":"2025-08-12T21:26:59.212924+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_dawn_4","hidden_by":null,"icon":null,"id":"260b86a3672af689ab395a740d9b0267","labels":[],"modified_at":"2025-08-12T21:36:44.167791+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2G2VSCAFTC14E0R1NPSX97N-next_dawn"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2G2VSCAFTC14E0R1NPSX97N","config_subentry_id":null,"created_at":"2025-08-12T21:26:59.213270+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_dusk_4","hidden_by":null,"icon":null,"id":"ea991eda8c4698f00c39ed1f00699fd0","labels":[],"modified_at":"2025-08-12T21:36:44.167862+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2G2VSCAFTC14E0R1NPSX97N-next_dusk"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2G2VSCAFTC14E0R1NPSX97N","config_subentry_id":null,"created_at":"2025-08-12T21:26:59.213649+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_midnight_4","hidden_by":null,"icon":null,"id":"46b4f2146e5bdc5c81e7968028992d40","labels":[],"modified_at":"2025-08-12T21:36:44.167896+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2G2VSCAFTC14E0R1NPSX97N-next_midnight"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2G2VSCAFTC14E0R1NPSX97N","config_subentry_id":null,"created_at":"2025-08-12T21:26:59.213814+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_noon_4","hidden_by":null,"icon":null,"id":"0e36c32cd00e16318e0cdde52081dbfa","labels":[],"modified_at":"2025-08-12T21:36:44.167928+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2G2VSCAFTC14E0R1NPSX97N-next_noon"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2G2VSCAFTC14E0R1NPSX97N","config_subentry_id":null,"created_at":"2025-08-12T21:26:59.213971+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_rising_4","hidden_by":null,"icon":null,"id":"07afd6aaf4e22a01a640996215b80925","labels":[],"modified_at":"2025-08-12T21:36:44.167958+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2G2VSCAFTC14E0R1NPSX97N-next_rising"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2G2VSCAFTC14E0R1NPSX97N","config_subentry_id":null,"created_at":"2025-08-12T21:26:59.214219+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_setting_4","hidden_by":null,"icon":null,"id":"9c33ea7bc2d915be6876bb5d75325823","labels":[],"modified_at":"2025-08-12T21:36:44.167992+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2G2VSCAFTC14E0R1NPSX97N-next_setting"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2G2VSCAFTC14E0R1NPSX97N","config_subentry_id":null,"created_at":"2025-08-12T21:26:59.214471+00:00","device_class":null,"disabled_by":"integration","entity_id":"sensor.sun_solar_elevation_4","hidden_by":null,"icon":null,"id":"67015cb27bade0b4d2a8f7ea6dcaa3f3","labels":[],"modified_at":"2025-08-12T21:36:44.168034+00:00","name":null,"options":{},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2G2VSCAFTC14E0R1NPSX97N-solar_elevation"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2G2VSCAFTC14E0R1NPSX97N","config_subentry_id":null,"created_at":"2025-08-12T21:26:59.214567+00:00","device_class":null,"disabled_by":"integration","entity_id":"sensor.sun_solar_azimuth_4","hidden_by":null,"icon":null,"id":"10c2de5be03b1d697c0f4856f27e3f53","labels":[],"modified_at":"2025-08-12T21:36:44.168055+00:00","name":null,"options":{},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2G2VSCAFTC14E0R1NPSX97N-solar_azimuth"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2G2VSCAFTC14E0R1NPSX97N","config_subentry_id":null,"created_at":"2025-08-12T21:26:59.214656+00:00","device_class":null,"disabled_by":"integration","entity_id":"sensor.sun_solar_rising_4","hidden_by":null,"icon":null,"id":"05f30e0ad071ad946197a29449280bdc","labels":[],"modified_at":"2025-08-12T21:36:44.168076+00:00","name":null,"options":{},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2G2VSCAFTC14E0R1NPSX97N-solar_rising"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2G3DAKZMG3KMB8ZX5Z54T3R","config_subentry_id":null,"created_at":"2025-08-12T21:36:33.921587+00:00","device_class":null,"disabled_by":"integration","entity_id":"binary_sensor.sun_solar_rising_3","hidden_by":null,"icon":null,"id":"174a2af606c3714448f7e673ed028dff","labels":[],"modified_at":"2025-08-12T21:44:04.774237+00:00","name":null,"options":{},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2G3DAKZMG3KMB8ZX5Z54T3R-solar_rising"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2G3DAKZMG3KMB8ZX5Z54T3R","config_subentry_id":null,"created_at":"2025-08-12T21:36:33.921925+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_dawn_3","hidden_by":null,"icon":null,"id":"39a64aae71c20d2d18328fe749ec67df","labels":[],"modified_at":"2025-08-12T21:44:04.774295+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2G3DAKZMG3KMB8ZX5Z54T3R-next_dawn"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2G3DAKZMG3KMB8ZX5Z54T3R","config_subentry_id":null,"created_at":"2025-08-12T21:36:33.922375+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_dusk_3","hidden_by":null,"icon":null,"id":"ad166779b42a89079e20526dbee58d65","labels":[],"modified_at":"2025-08-12T21:44:04.774348+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2G3DAKZMG3KMB8ZX5Z54T3R-next_dusk"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2G3DAKZMG3KMB8ZX5Z54T3R","config_subentry_id":null,"created_at":"2025-08-12T21:36:33.922545+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_midnight_3","hidden_by":null,"icon":null,"id":"2478ce86e9a0bf20e0284b6c96c57e0c","labels":[],"modified_at":"2025-08-12T21:44:04.774379+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2G3DAKZMG3KMB8ZX5Z54T3R-next_midnight"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2G3DAKZMG3KMB8ZX5Z54T3R","config_subentry_id":null,"created_at":"2025-08-12T21:36:33.922683+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_noon_3","hidden_by":null,"icon":null,"id":"09f8dedc8c10f6a96e4d35c579848092","labels":[],"modified_at":"2025-08-12T21:44:04.774409+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2G3DAKZMG3KMB8ZX5Z54T3R-next_noon"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2G3DAKZMG3KMB8ZX5Z54T3R","config_subentry_id":null,"created_at":"2025-08-12T21:36:33.922799+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_rising_3","hidden_by":null,"icon":null,"id":"cb1f2e9ecd3621b07522913b01783a71","labels":[],"modified_at":"2025-08-12T21:44:04.774441+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2G3DAKZMG3KMB8ZX5Z54T3R-next_rising"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2G3DAKZMG3KMB8ZX5Z54T3R","config_subentry_id":null,"created_at":"2025-08-12T21:36:33.923135+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_setting_3","hidden_by":null,"icon":null,"id":"d4aa898f5a60e230d1c06baf16c19a8b","labels":[],"modified_at":"2025-08-12T21:44:04.774494+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2G3DAKZMG3KMB8ZX5Z54T3R-next_setting"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2G3DAKZMG3KMB8ZX5Z54T3R","config_subentry_id":null,"created_at":"2025-08-12T21:36:33.923354+00:00","device_class":null,"disabled_by":"integration","entity_id":"sensor.sun_solar_elevation_3","hidden_by":null,"icon":null,"id":"938e0d3df9cfce11cb7033ea0b706a22","labels":[],"modified_at":"2025-08-12T21:44:04.774536+00:00","name":null,"options":{},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2G3DAKZMG3KMB8ZX5Z54T3R-solar_elevation"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2G3DAKZMG3KMB8ZX5Z54T3R","config_subentry_id":null,"created_at":"2025-08-12T21:36:33.923445+00:00","device_class":null,"disabled_by":"integration","entity_id":"sensor.sun_solar_azimuth_3","hidden_by":null,"icon":null,"id":"750aaec1493d553b262b70d9b8539072","labels":[],"modified_at":"2025-08-12T21:44:04.774567+00:00","name":null,"options":{},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2G3DAKZMG3KMB8ZX5Z54T3R-solar_azimuth"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2G3DAKZMG3KMB8ZX5Z54T3R","config_subentry_id":null,"created_at":"2025-08-12T21:36:33.923528+00:00","device_class":null,"disabled_by":"integration","entity_id":"sensor.sun_solar_rising_3","hidden_by":null,"icon":null,"id":"b6998015054ef5b64dd1225326cbf164","labels":[],"modified_at":"2025-08-12T21:44:04.774602+00:00","name":null,"options":{},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2G3DAKZMG3KMB8ZX5Z54T3R-solar_rising"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2G3TRX0NV4DS8C09G5B4TYT","config_subentry_id":null,"created_at":"2025-08-12T21:43:54.531232+00:00","device_class":null,"disabled_by":"integration","entity_id":"binary_sensor.sun_solar_rising_4","hidden_by":null,"icon":null,"id":"a78ff0aa891059820df9e6d055e3c8a6","labels":[],"modified_at":"2025-08-13T05:01:09.287613+00:00","name":null,"options":{},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2G3TRX0NV4DS8C09G5B4TYT-solar_rising"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2G3TRX0NV4DS8C09G5B4TYT","config_subentry_id":null,"created_at":"2025-08-12T21:43:54.531859+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_dawn_4","hidden_by":null,"icon":null,"id":"5680aa6b8824fff96dbf36c467fd079b","labels":[],"modified_at":"2025-08-13T05:01:09.287671+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2G3TRX0NV4DS8C09G5B4TYT-next_dawn"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2G3TRX0NV4DS8C09G5B4TYT","config_subentry_id":null,"created_at":"2025-08-12T21:43:54.532173+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_dusk_4","hidden_by":null,"icon":null,"id":"274e3184f58490030d118d4fd32b6649","labels":[],"modified_at":"2025-08-13T05:01:09.287724+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2G3TRX0NV4DS8C09G5B4TYT-next_dusk"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2G3TRX0NV4DS8C09G5B4TYT","config_subentry_id":null,"created_at":"2025-08-12T21:43:54.533321+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_midnight_4","hidden_by":null,"icon":null,"id":"0709fad4501d4cfeb21767fac40d4945","labels":[],"modified_at":"2025-08-13T05:01:09.287753+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2G3TRX0NV4DS8C09G5B4TYT-next_midnight"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2G3TRX0NV4DS8C09G5B4TYT","config_subentry_id":null,"created_at":"2025-08-12T21:43:54.533551+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_noon_4","hidden_by":null,"icon":null,"id":"d0d997ed057e3d4101b211de7e259104","labels":[],"modified_at":"2025-08-13T05:01:09.287778+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2G3TRX0NV4DS8C09G5B4TYT-next_noon"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2G3TRX0NV4DS8C09G5B4TYT","config_subentry_id":null,"created_at":"2025-08-12T21:43:54.533748+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_rising_4","hidden_by":null,"icon":null,"id":"0d70671f807592367b26ad774fed32f1","labels":[],"modified_at":"2025-08-13T05:01:09.287802+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2G3TRX0NV4DS8C09G5B4TYT-next_rising"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2G3TRX0NV4DS8C09G5B4TYT","config_subentry_id":null,"created_at":"2025-08-12T21:43:54.533927+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_setting_4","hidden_by":null,"icon":null,"id":"7ffb310bd02e4823ca0ec8101b72f49e","labels":[],"modified_at":"2025-08-13T05:01:09.287827+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2G3TRX0NV4DS8C09G5B4TYT-next_setting"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2G3TRX0NV4DS8C09G5B4TYT","config_subentry_id":null,"created_at":"2025-08-12T21:43:54.535135+00:00","device_class":null,"disabled_by":"integration","entity_id":"sensor.sun_solar_elevation_4","hidden_by":null,"icon":null,"id":"da36bf42c53ebc5502b977234f86579f","labels":[],"modified_at":"2025-08-13T05:01:09.287851+00:00","name":null,"options":{},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2G3TRX0NV4DS8C09G5B4TYT-solar_elevation"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2G3TRX0NV4DS8C09G5B4TYT","config_subentry_id":null,"created_at":"2025-08-12T21:43:54.535252+00:00","device_class":null,"disabled_by":"integration","entity_id":"sensor.sun_solar_azimuth_4","hidden_by":null,"icon":null,"id":"7bc36f6a2137e7e1cb43dc9dfb98aadc","labels":[],"modified_at":"2025-08-13T05:01:09.287869+00:00","name":null,"options":{},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2G3TRX0NV4DS8C09G5B4TYT-solar_azimuth"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2G3TRX0NV4DS8C09G5B4TYT","config_subentry_id":null,"created_at":"2025-08-12T21:43:54.535333+00:00","device_class":null,"disabled_by":"integration","entity_id":"sensor.sun_solar_rising_4","hidden_by":null,"icon":null,"id":"1b5d07868c9ea67a5199b9bfb18d1ac1","labels":[],"modified_at":"2025-08-13T05:01:09.287886+00:00","name":null,"options":{},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2G3TRX0NV4DS8C09G5B4TYT-solar_rising"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.846769+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_main_meter_produced_energy","hidden_by":null,"icon":null,"id":"39a4a90bd2bc78e45a68b1f915ea2858","labels":[],"modified_at":"2025-08-13T18:33:35.796531+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_mainmeterenergyproducedenergywh"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.847076+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_main_meter_consumed_energy","hidden_by":null,"icon":null,"id":"e7d443c6389fbf5a9111c27f7f9c8c37","labels":[],"modified_at":"2025-08-13T18:33:35.796559+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_mainmeterenergyconsumedenergywh"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.847311+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_feed_through_produced_energy","hidden_by":null,"icon":null,"id":"e3730461ab714aa2c6b0e12d487335f2","labels":[],"modified_at":"2025-08-13T18:33:35.796586+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_feedthroughenergyproducedenergywh"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.847532+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_feed_through_consumed_energy","hidden_by":null,"icon":null,"id":"56ad42fa0fb2d963e6fc8546ddc0336a","labels":[],"modified_at":"2025-08-13T18:33:35.796612+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_feedthroughenergyconsumedenergywh"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.354911+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_2_power","hidden_by":null,"icon":null,"id":"6f278368ec1c65ceab4d2f81d2455718","labels":[],"modified_at":"2025-08-13T06:16:22.369294+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_0dad2f16cd514812ae1807b0457d473e_instantpowerw"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.355206+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_4_power","hidden_by":null,"icon":null,"id":"d8d70af78fef60a31a7194cca82439fd","labels":[],"modified_at":"2025-08-13T06:16:22.369385+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_11a47a0f69d54e12b7200f730c2ffda1_instantpowerw"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.355466+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_14_power","hidden_by":null,"icon":null,"id":"13c0531bb8b8aeb88b961387f3f190d8","labels":[],"modified_at":"2025-08-13T06:16:22.369487+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_12ce227695cd44338864b0ef2ec4168b_instantpowerw"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.355729+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_24_power","hidden_by":null,"icon":null,"id":"24fc30f14c03697eca9552b225665e63","labels":[],"modified_at":"2025-08-13T06:16:22.369583+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_1ad73b0eb44e4022bb6270c76baed0c1_instantpowerw"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.355988+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_16_power","hidden_by":null,"icon":null,"id":"d06931ba75b8a8075b7c143da682350d","labels":[],"modified_at":"2025-08-13T06:16:22.369673+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_31b36cde0fc642b39eec515267707a6f_instantpowerw"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.356269+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_19_power","hidden_by":null,"icon":null,"id":"4522ded5403991beadc3157b53faca86","labels":[],"modified_at":"2025-08-13T06:16:22.369805+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_3a847fe6eb374ec3bf92cee1ffaf2eda_instantpowerw"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.356554+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_12_power","hidden_by":null,"icon":null,"id":"a1a640db10973c5d773a24fd62a480e2","labels":[],"modified_at":"2025-08-13T06:16:22.369949+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_497ae7ebb2844772bf926f2f094c81bc_instantpowerw"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.356785+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_29_power","hidden_by":null,"icon":null,"id":"3c578284a30b9e90a8d72e15e7d1b9a9","labels":[],"modified_at":"2025-08-13T06:16:22.370059+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_617059df47bb49bd8545a36a6b6b23d2_instantpowerw"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.357078+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_10_power","hidden_by":null,"icon":null,"id":"0392db851759db3ddd9afdbc02941db9","labels":[],"modified_at":"2025-08-13T06:16:22.370148+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_6ad65cf2ad6443448e33973f26f7df3e_instantpowerw"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.357307+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_25_power","hidden_by":null,"icon":null,"id":"ed91fc78b30748f31356944823897f8f","labels":[],"modified_at":"2025-08-13T06:16:22.370242+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_795e8eddb4f448af9625130332a41df8_instantpowerw"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.357514+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_23_power","hidden_by":null,"icon":null,"id":"4954c38af3dc31574703e35144415c1b","labels":[],"modified_at":"2025-08-13T06:16:22.373452+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_82a0888dc072416598d99f8b74ee3d8d_instantpowerw"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.358006+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_18_power","hidden_by":null,"icon":null,"id":"0961871276509b049a27f717f2e0a808","labels":[],"modified_at":"2025-08-13T06:16:22.373582+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_8a2ffda9dbd24bada9a01b880e910612_instantpowerw"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.358312+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_9_power","hidden_by":null,"icon":null,"id":"aba8a8eae1f2e543867dfde7dd52572c","labels":[],"modified_at":"2025-08-13T06:16:22.373673+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_914943d4798c462cadf5e5144989dd5b_instantpowerw"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.358581+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_1_power","hidden_by":null,"icon":null,"id":"f391da13f909f7fe1827eaa4b1bfa5a1","labels":[],"modified_at":"2025-08-13T06:16:22.373769+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_926441605113492189f9aa13dac356cd_instantpowerw"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.358826+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_15_power","hidden_by":null,"icon":null,"id":"fd2818fd83aba0a63332bf9416c382eb","labels":[],"modified_at":"2025-08-13T06:16:22.373855+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_941d6a8b41ab4c57a6b8be14b5981fe6_instantpowerw"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.359089+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_7_power","hidden_by":null,"icon":null,"id":"3f9ff80bfddb44a71ecd09602bf32757","labels":[],"modified_at":"2025-08-13T06:16:22.373940+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_988af7bb1fc04aac8bb3b45c660d920a_instantpowerw"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.359311+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_22_power","hidden_by":null,"icon":null,"id":"728d75768ccbca441d795d6bfe10e41c","labels":[],"modified_at":"2025-08-13T06:16:22.374085+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_9941b417bfa046ef908d97c998c54c50_instantpowerw"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.359789+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_6_power","hidden_by":null,"icon":null,"id":"436061a4fe1b35efecadc9aa33ed4587","labels":[],"modified_at":"2025-08-13T06:16:22.374195+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_cbe98d7307ca42658983b9f673211e14_instantpowerw"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.360052+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_11_power","hidden_by":null,"icon":null,"id":"deaa3bb9073d9df70065b37d6fa8c497","labels":[],"modified_at":"2025-08-13T06:16:22.374288+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_d88c0c0e7c584472b2ec7e4d3c53c3b8_instantpowerw"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.360265+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_8_power","hidden_by":null,"icon":null,"id":"8cc68d51daa1b73860c6517e5da1b0ab","labels":[],"modified_at":"2025-08-13T06:16:22.374376+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_e3cb78d38dae41cdb50faee7bfa4ab80_instantpowerw"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.360535+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_3_power","hidden_by":null,"icon":null,"id":"2bf252b57574fd4a8e1372f4017081db","labels":[],"modified_at":"2025-08-13T06:16:22.374459+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_ece352c3dc5c417e80757b716f24ba90_instantpowerw"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.360777+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_5_power","hidden_by":null,"icon":null,"id":"6a1851a8679cb2aa6511f5e3fbf478bc","labels":[],"modified_at":"2025-08-13T06:16:22.374545+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_f19d909abcc4421f9ec630f82458c7ac_instantpowerw"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.361042+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_2_energy_produced","hidden_by":null,"icon":null,"id":"32e51f8353d038d83037b6876c49d661","labels":[],"modified_at":"2025-08-13T06:16:22.374631+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_0dad2f16cd514812ae1807b0457d473e_producedenergywh"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.361368+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_4_energy_produced","hidden_by":null,"icon":null,"id":"09bfa7246fd7d7b695fb4fbb45e41eeb","labels":[],"modified_at":"2025-08-13T06:16:22.374723+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_11a47a0f69d54e12b7200f730c2ffda1_producedenergywh"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.361893+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_14_energy_produced","hidden_by":null,"icon":null,"id":"177b7dd615e2e9361ccee0fa89a25088","labels":[],"modified_at":"2025-08-13T06:16:22.374819+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_12ce227695cd44338864b0ef2ec4168b_producedenergywh"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.362344+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_24_energy_produced","hidden_by":null,"icon":null,"id":"a834930cd6c69ecca9cdfec9a8136bec","labels":[],"modified_at":"2025-08-13T06:16:22.374926+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_1ad73b0eb44e4022bb6270c76baed0c1_producedenergywh"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.362707+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_16_energy_produced","hidden_by":null,"icon":null,"id":"243774b93edcdf31b3176524abb69e12","labels":[],"modified_at":"2025-08-13T06:16:22.375058+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_31b36cde0fc642b39eec515267707a6f_producedenergywh"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.364162+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_19_energy_produced","hidden_by":null,"icon":null,"id":"dbf6ad3acc745386ae5f07b1c07ba686","labels":[],"modified_at":"2025-08-13T06:16:22.375151+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_3a847fe6eb374ec3bf92cee1ffaf2eda_producedenergywh"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.364539+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_12_energy_produced","hidden_by":null,"icon":null,"id":"1f23cefb3721adfbe60dd906e9f4e15d","labels":[],"modified_at":"2025-08-13T06:16:22.375233+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_497ae7ebb2844772bf926f2f094c81bc_producedenergywh"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.364850+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_29_energy_produced","hidden_by":null,"icon":null,"id":"bb98af3292a87812ed7d09b6b9ba2fac","labels":[],"modified_at":"2025-08-13T06:16:22.375328+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_617059df47bb49bd8545a36a6b6b23d2_producedenergywh"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.365157+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_10_energy_produced","hidden_by":null,"icon":null,"id":"e265acbe8d1ab30fcc4610c5c111c0fc","labels":[],"modified_at":"2025-08-13T06:16:22.375418+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_6ad65cf2ad6443448e33973f26f7df3e_producedenergywh"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.365406+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_25_energy_produced","hidden_by":null,"icon":null,"id":"8c0ad7c7be145aadf0066494a76a71ba","labels":[],"modified_at":"2025-08-13T06:16:22.375501+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_795e8eddb4f448af9625130332a41df8_producedenergywh"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.365725+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_23_energy_produced","hidden_by":null,"icon":null,"id":"82c131598daec8a0fa69d74704c23914","labels":[],"modified_at":"2025-08-13T06:16:22.375585+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_82a0888dc072416598d99f8b74ee3d8d_producedenergywh"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.366048+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_18_energy_produced","hidden_by":null,"icon":null,"id":"7fd9877d31c47888534d5b9f0d35ad49","labels":[],"modified_at":"2025-08-13T06:16:22.375668+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_8a2ffda9dbd24bada9a01b880e910612_producedenergywh"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.366328+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_9_energy_produced","hidden_by":null,"icon":null,"id":"4a8a67f7624e11fb6a1d1b270cdccae2","labels":[],"modified_at":"2025-08-13T06:16:22.375748+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_914943d4798c462cadf5e5144989dd5b_producedenergywh"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.366567+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_1_energy_produced","hidden_by":null,"icon":null,"id":"2af0b9f4b2be59178968561ca3dd34c7","labels":[],"modified_at":"2025-08-13T06:16:22.375830+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_926441605113492189f9aa13dac356cd_producedenergywh"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.366828+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_15_energy_produced","hidden_by":null,"icon":null,"id":"d0707002784409b42287cbb9f8e82495","labels":[],"modified_at":"2025-08-13T06:16:22.375910+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_941d6a8b41ab4c57a6b8be14b5981fe6_producedenergywh"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.367075+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_7_energy_produced","hidden_by":null,"icon":null,"id":"f361c724f2d7efa8b664cc89f7727693","labels":[],"modified_at":"2025-08-13T06:16:22.375991+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_988af7bb1fc04aac8bb3b45c660d920a_producedenergywh"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.367305+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_22_energy_produced","hidden_by":null,"icon":null,"id":"50536fe362518b07ab72b5ce6c0143dc","labels":[],"modified_at":"2025-08-13T06:16:22.376084+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_9941b417bfa046ef908d97c998c54c50_producedenergywh"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.367530+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_6_energy_produced","hidden_by":null,"icon":null,"id":"de760f1d7efa90a31801e6e71452e0b1","labels":[],"modified_at":"2025-08-13T06:16:22.376206+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_cbe98d7307ca42658983b9f673211e14_producedenergywh"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.367792+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_11_energy_produced","hidden_by":null,"icon":null,"id":"0d7b6b6c9d0769ac88412713a00bd6c7","labels":[],"modified_at":"2025-08-13T06:16:22.376304+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_d88c0c0e7c584472b2ec7e4d3c53c3b8_producedenergywh"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.368086+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_8_energy_produced","hidden_by":null,"icon":null,"id":"f81a8ee242f860411da16af10803e0a3","labels":[],"modified_at":"2025-08-13T06:16:22.376397+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_e3cb78d38dae41cdb50faee7bfa4ab80_producedenergywh"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.368330+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_3_energy_produced","hidden_by":null,"icon":null,"id":"a076b9af709576d957dc9d3dc4c820b9","labels":[],"modified_at":"2025-08-13T06:16:22.376489+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_ece352c3dc5c417e80757b716f24ba90_producedenergywh"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.368604+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_5_energy_produced","hidden_by":null,"icon":null,"id":"3628639aab2e9b732bad717f39d6f448","labels":[],"modified_at":"2025-08-13T06:16:22.376599+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_f19d909abcc4421f9ec630f82458c7ac_producedenergywh"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.370370+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_2_energy_consumed","hidden_by":null,"icon":null,"id":"fc9328016980de23d891107f520ce68b","labels":[],"modified_at":"2025-08-13T06:16:22.376706+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_0dad2f16cd514812ae1807b0457d473e_consumedenergywh"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.371078+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_4_energy_consumed","hidden_by":null,"icon":null,"id":"b71b52d1559398e14749ce7248ea2836","labels":[],"modified_at":"2025-08-13T06:16:22.376794+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_11a47a0f69d54e12b7200f730c2ffda1_consumedenergywh"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.371525+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_14_energy_consumed","hidden_by":null,"icon":null,"id":"f1c8ee54ff6b0378b4080faf0eeedf01","labels":[],"modified_at":"2025-08-13T06:16:22.376877+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_12ce227695cd44338864b0ef2ec4168b_consumedenergywh"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.372188+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_24_energy_consumed","hidden_by":null,"icon":null,"id":"a8955196e29d742e1674113fd8b722e6","labels":[],"modified_at":"2025-08-13T06:16:22.376970+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_1ad73b0eb44e4022bb6270c76baed0c1_consumedenergywh"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.372658+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_16_energy_consumed","hidden_by":null,"icon":null,"id":"442031aa423df9ee3c4a65c2555efa95","labels":[],"modified_at":"2025-08-13T06:16:22.377065+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_31b36cde0fc642b39eec515267707a6f_consumedenergywh"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.372934+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_19_energy_consumed","hidden_by":null,"icon":null,"id":"a06899850663410df4f9a5882e1170c2","labels":[],"modified_at":"2025-08-13T06:16:22.377148+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_3a847fe6eb374ec3bf92cee1ffaf2eda_consumedenergywh"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.373499+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_12_energy_consumed","hidden_by":null,"icon":null,"id":"71ff69b4c01446061dc85e5be44d374f","labels":[],"modified_at":"2025-08-13T06:16:22.377249+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_497ae7ebb2844772bf926f2f094c81bc_consumedenergywh"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.374067+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_29_energy_consumed","hidden_by":null,"icon":null,"id":"f88f0bb821a531d1b91ff3dcb70982bd","labels":[],"modified_at":"2025-08-13T06:16:22.377359+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_617059df47bb49bd8545a36a6b6b23d2_consumedenergywh"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.374421+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_10_energy_consumed","hidden_by":null,"icon":null,"id":"4aea712b7a3c65bb91efb4ef1f8bcc1b","labels":[],"modified_at":"2025-08-13T06:16:22.377440+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_6ad65cf2ad6443448e33973f26f7df3e_consumedenergywh"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.374750+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_25_energy_consumed","hidden_by":null,"icon":null,"id":"4388f8739eff0fa07ae78869bb016c0d","labels":[],"modified_at":"2025-08-13T06:16:22.377526+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_795e8eddb4f448af9625130332a41df8_consumedenergywh"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.377174+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_23_energy_consumed","hidden_by":null,"icon":null,"id":"4675924bffc46341689652940859df1b","labels":[],"modified_at":"2025-08-13T06:16:22.377637+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_82a0888dc072416598d99f8b74ee3d8d_consumedenergywh"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.377794+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_18_energy_consumed","hidden_by":null,"icon":null,"id":"c0757fc972d663e30859f335a672a22b","labels":[],"modified_at":"2025-08-13T06:16:22.377728+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_8a2ffda9dbd24bada9a01b880e910612_consumedenergywh"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.378302+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_9_energy_consumed","hidden_by":null,"icon":null,"id":"298ca219b03f718e784acd22039a0054","labels":[],"modified_at":"2025-08-13T06:16:22.377831+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_914943d4798c462cadf5e5144989dd5b_consumedenergywh"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.378741+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_1_energy_consumed","hidden_by":null,"icon":null,"id":"881fde59e63f7fb03dc507d771061cdb","labels":[],"modified_at":"2025-08-13T06:16:22.377909+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_926441605113492189f9aa13dac356cd_consumedenergywh"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.379167+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_15_energy_consumed","hidden_by":null,"icon":null,"id":"5e7f40ec4b4140a740aaa4909fee3ce5","labels":[],"modified_at":"2025-08-13T06:16:22.377984+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_941d6a8b41ab4c57a6b8be14b5981fe6_consumedenergywh"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.379525+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_7_energy_consumed","hidden_by":null,"icon":null,"id":"75d5cfe69d36e522f5fce53f5ab6d389","labels":[],"modified_at":"2025-08-13T06:16:22.378070+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_988af7bb1fc04aac8bb3b45c660d920a_consumedenergywh"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.379941+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_22_energy_consumed","hidden_by":null,"icon":null,"id":"87ff1aa5b01bc4bf22aa467181ffeb24","labels":[],"modified_at":"2025-08-13T06:16:22.378146+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_9941b417bfa046ef908d97c998c54c50_consumedenergywh"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.380325+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_6_energy_consumed","hidden_by":null,"icon":null,"id":"5c86ca8d74aa45812c0655554649f8ca","labels":[],"modified_at":"2025-08-13T06:16:22.378228+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_cbe98d7307ca42658983b9f673211e14_consumedenergywh"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.380627+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_11_energy_consumed","hidden_by":null,"icon":null,"id":"2976b6143e3a79eee5e58facdec12f1f","labels":[],"modified_at":"2025-08-13T06:16:22.378305+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_d88c0c0e7c584472b2ec7e4d3c53c3b8_consumedenergywh"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.381059+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_8_energy_consumed","hidden_by":null,"icon":null,"id":"a232b386c1b3c406cbe2c123c22735e7","labels":[],"modified_at":"2025-08-13T06:16:22.378381+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_e3cb78d38dae41cdb50faee7bfa4ab80_consumedenergywh"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.381395+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_3_energy_consumed","hidden_by":null,"icon":null,"id":"f3e86f348f4182629a3b5fb73b2791aa","labels":[],"modified_at":"2025-08-13T06:16:22.378465+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_ece352c3dc5c417e80757b716f24ba90_consumedenergywh"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.381721+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_5_energy_consumed","hidden_by":null,"icon":null,"id":"0c3f8c06d74af3d8b9cf1e5b0808f5a7","labels":[],"modified_at":"2025-08-13T06:16:22.381142+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_f19d909abcc4421f9ec630f82458c7ac_consumedenergywh"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2GWV2T4WST7QXWQ837WF92S","config_subentry_id":null,"created_at":"2025-08-13T05:00:59.079529+00:00","device_class":null,"disabled_by":"integration","entity_id":"binary_sensor.sun_solar_rising_3","hidden_by":null,"icon":null,"id":"79ffc85f20341dc8315573f11d6024a9","labels":[],"modified_at":"2025-08-13T05:20:07.934348+00:00","name":null,"options":{},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2GWV2T4WST7QXWQ837WF92S-solar_rising"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2GWV2T4WST7QXWQ837WF92S","config_subentry_id":null,"created_at":"2025-08-13T05:00:59.079934+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_dawn_3","hidden_by":null,"icon":null,"id":"f4ceb73a0891adfb87b52c649b0dca71","labels":[],"modified_at":"2025-08-13T05:20:07.934414+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2GWV2T4WST7QXWQ837WF92S-next_dawn"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2GWV2T4WST7QXWQ837WF92S","config_subentry_id":null,"created_at":"2025-08-13T05:00:59.080339+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_dusk_3","hidden_by":null,"icon":null,"id":"7f8dbb64edc30391701878556b2d6709","labels":[],"modified_at":"2025-08-13T05:20:07.934468+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2GWV2T4WST7QXWQ837WF92S-next_dusk"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2GWV2T4WST7QXWQ837WF92S","config_subentry_id":null,"created_at":"2025-08-13T05:00:59.080501+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_midnight_3","hidden_by":null,"icon":null,"id":"63ce9cf6461ed3da8e7a3915e7e3156c","labels":[],"modified_at":"2025-08-13T05:20:07.934500+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2GWV2T4WST7QXWQ837WF92S-next_midnight"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2GWV2T4WST7QXWQ837WF92S","config_subentry_id":null,"created_at":"2025-08-13T05:00:59.080642+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_noon_3","hidden_by":null,"icon":null,"id":"55f890bde8f724c864d714badb06af20","labels":[],"modified_at":"2025-08-13T05:20:07.934532+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2GWV2T4WST7QXWQ837WF92S-next_noon"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2GWV2T4WST7QXWQ837WF92S","config_subentry_id":null,"created_at":"2025-08-13T05:00:59.080844+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_rising_3","hidden_by":null,"icon":null,"id":"87951a3020e396019da53306333608bc","labels":[],"modified_at":"2025-08-13T05:20:07.934561+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2GWV2T4WST7QXWQ837WF92S-next_rising"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2GWV2T4WST7QXWQ837WF92S","config_subentry_id":null,"created_at":"2025-08-13T05:00:59.080985+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_setting_3","hidden_by":null,"icon":null,"id":"00a91b7d356470e414ba7a65333594d2","labels":[],"modified_at":"2025-08-13T05:20:07.934592+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2GWV2T4WST7QXWQ837WF92S-next_setting"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2GWV2T4WST7QXWQ837WF92S","config_subentry_id":null,"created_at":"2025-08-13T05:00:59.081132+00:00","device_class":null,"disabled_by":"integration","entity_id":"sensor.sun_solar_elevation_3","hidden_by":null,"icon":null,"id":"635b93010ea2b5f4c33a4b03aac058b0","labels":[],"modified_at":"2025-08-13T05:20:07.934620+00:00","name":null,"options":{},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2GWV2T4WST7QXWQ837WF92S-solar_elevation"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2GWV2T4WST7QXWQ837WF92S","config_subentry_id":null,"created_at":"2025-08-13T05:00:59.081214+00:00","device_class":null,"disabled_by":"integration","entity_id":"sensor.sun_solar_azimuth_3","hidden_by":null,"icon":null,"id":"5d18c4791c2e3faa2aff741795cfd174","labels":[],"modified_at":"2025-08-13T05:20:07.934641+00:00","name":null,"options":{},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2GWV2T4WST7QXWQ837WF92S-solar_azimuth"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2GWV2T4WST7QXWQ837WF92S","config_subentry_id":null,"created_at":"2025-08-13T05:00:59.081291+00:00","device_class":null,"disabled_by":"integration","entity_id":"sensor.sun_solar_rising_3","hidden_by":null,"icon":null,"id":"049ebbcf2f186c5df2e22f5ba855beff","labels":[],"modified_at":"2025-08-13T05:20:07.934662+00:00","name":null,"options":{},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2GWV2T4WST7QXWQ837WF92S-solar_rising"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2GXXTQT3SD7C7BPVK0P6J7W","config_subentry_id":null,"created_at":"2025-08-13T05:19:57.700145+00:00","device_class":null,"disabled_by":"integration","entity_id":"binary_sensor.sun_solar_rising_4","hidden_by":null,"icon":null,"id":"38ce820b5ac766f581e91f71cb26b33c","labels":[],"modified_at":"2025-08-13T05:47:39.871544+00:00","name":null,"options":{},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2GXXTQT3SD7C7BPVK0P6J7W-solar_rising"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2GXXTQT3SD7C7BPVK0P6J7W","config_subentry_id":null,"created_at":"2025-08-13T05:19:57.700547+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_dawn_4","hidden_by":null,"icon":null,"id":"817ab73a3e7a173a8392e4587670e0a2","labels":[],"modified_at":"2025-08-13T05:47:39.871600+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2GXXTQT3SD7C7BPVK0P6J7W-next_dawn"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2GXXTQT3SD7C7BPVK0P6J7W","config_subentry_id":null,"created_at":"2025-08-13T05:19:57.701702+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_dusk_4","hidden_by":null,"icon":null,"id":"cc5e3bac7c6fbac520ab55bff3aa00b1","labels":[],"modified_at":"2025-08-13T05:47:39.871651+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2GXXTQT3SD7C7BPVK0P6J7W-next_dusk"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2GXXTQT3SD7C7BPVK0P6J7W","config_subentry_id":null,"created_at":"2025-08-13T05:19:57.702061+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_midnight_4","hidden_by":null,"icon":null,"id":"77e21ca24c781aac45c36d54895b9bc9","labels":[],"modified_at":"2025-08-13T05:47:39.875265+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2GXXTQT3SD7C7BPVK0P6J7W-next_midnight"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2GXXTQT3SD7C7BPVK0P6J7W","config_subentry_id":null,"created_at":"2025-08-13T05:19:57.702265+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_noon_4","hidden_by":null,"icon":null,"id":"783ea87abe61d652c152d7fb91b49a11","labels":[],"modified_at":"2025-08-13T05:47:39.875333+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2GXXTQT3SD7C7BPVK0P6J7W-next_noon"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2GXXTQT3SD7C7BPVK0P6J7W","config_subentry_id":null,"created_at":"2025-08-13T05:19:57.702410+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_rising_4","hidden_by":null,"icon":null,"id":"1eeb329eebbd439c8eb5cd37deeacaa5","labels":[],"modified_at":"2025-08-13T05:47:39.875358+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2GXXTQT3SD7C7BPVK0P6J7W-next_rising"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2GXXTQT3SD7C7BPVK0P6J7W","config_subentry_id":null,"created_at":"2025-08-13T05:19:57.702538+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_setting_4","hidden_by":null,"icon":null,"id":"a91bf994408f5dcbb1eb8f8b10faf183","labels":[],"modified_at":"2025-08-13T05:47:39.875386+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2GXXTQT3SD7C7BPVK0P6J7W-next_setting"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2GXXTQT3SD7C7BPVK0P6J7W","config_subentry_id":null,"created_at":"2025-08-13T05:19:57.702666+00:00","device_class":null,"disabled_by":"integration","entity_id":"sensor.sun_solar_elevation_4","hidden_by":null,"icon":null,"id":"e23cca6a8b78f117b0051b2042d0f86c","labels":[],"modified_at":"2025-08-13T05:47:39.875410+00:00","name":null,"options":{},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2GXXTQT3SD7C7BPVK0P6J7W-solar_elevation"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2GXXTQT3SD7C7BPVK0P6J7W","config_subentry_id":null,"created_at":"2025-08-13T05:19:57.702790+00:00","device_class":null,"disabled_by":"integration","entity_id":"sensor.sun_solar_azimuth_4","hidden_by":null,"icon":null,"id":"9ba7076637d6a00e0d4548121ef1c7f5","labels":[],"modified_at":"2025-08-13T05:47:39.875429+00:00","name":null,"options":{},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2GXXTQT3SD7C7BPVK0P6J7W-solar_azimuth"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2GXXTQT3SD7C7BPVK0P6J7W","config_subentry_id":null,"created_at":"2025-08-13T05:19:57.702887+00:00","device_class":null,"disabled_by":"integration","entity_id":"sensor.sun_solar_rising_4","hidden_by":null,"icon":null,"id":"717fe89a1ba1325b623da7900b184bc3","labels":[],"modified_at":"2025-08-13T05:47:39.875446+00:00","name":null,"options":{},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2GXXTQT3SD7C7BPVK0P6J7W-solar_rising"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2GZG7XD3DQQSDY3SR568B10","config_subentry_id":null,"created_at":"2025-08-13T05:47:29.586094+00:00","device_class":null,"disabled_by":"integration","entity_id":"binary_sensor.sun_solar_rising_3","hidden_by":null,"icon":null,"id":"dca731c2dba3ae6f3c7f008a9e9df1dd","labels":[],"modified_at":"2025-08-13T05:57:40.368538+00:00","name":null,"options":{},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2GZG7XD3DQQSDY3SR568B10-solar_rising"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2GZG7XD3DQQSDY3SR568B10","config_subentry_id":null,"created_at":"2025-08-13T05:47:29.586754+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_dawn_3","hidden_by":null,"icon":null,"id":"d40f6fdfc0a7c97e8cf473b5662a6575","labels":[],"modified_at":"2025-08-13T05:57:40.368596+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2GZG7XD3DQQSDY3SR568B10-next_dawn"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2GZG7XD3DQQSDY3SR568B10","config_subentry_id":null,"created_at":"2025-08-13T05:47:29.586967+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_dusk_3","hidden_by":null,"icon":null,"id":"68db514e118661737db332df3a43c319","labels":[],"modified_at":"2025-08-13T05:57:40.368649+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2GZG7XD3DQQSDY3SR568B10-next_dusk"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2GZG7XD3DQQSDY3SR568B10","config_subentry_id":null,"created_at":"2025-08-13T05:47:29.587168+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_midnight_3","hidden_by":null,"icon":null,"id":"96cfde9110d76aa20883b17211e9f5dd","labels":[],"modified_at":"2025-08-13T05:57:40.368677+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2GZG7XD3DQQSDY3SR568B10-next_midnight"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2GZG7XD3DQQSDY3SR568B10","config_subentry_id":null,"created_at":"2025-08-13T05:47:29.587309+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_noon_3","hidden_by":null,"icon":null,"id":"df1aca0e454480117159f698a8c2c975","labels":[],"modified_at":"2025-08-13T05:57:40.368707+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2GZG7XD3DQQSDY3SR568B10-next_noon"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2GZG7XD3DQQSDY3SR568B10","config_subentry_id":null,"created_at":"2025-08-13T05:47:29.587424+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_rising_3","hidden_by":null,"icon":null,"id":"0fe3afda2c3fdc725346d703eaed64de","labels":[],"modified_at":"2025-08-13T05:57:40.368730+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2GZG7XD3DQQSDY3SR568B10-next_rising"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2GZG7XD3DQQSDY3SR568B10","config_subentry_id":null,"created_at":"2025-08-13T05:47:29.587531+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_setting_3","hidden_by":null,"icon":null,"id":"1895dc3db432e1f41b87e99c2982e810","labels":[],"modified_at":"2025-08-13T05:57:40.368756+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2GZG7XD3DQQSDY3SR568B10-next_setting"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2GZG7XD3DQQSDY3SR568B10","config_subentry_id":null,"created_at":"2025-08-13T05:47:29.587639+00:00","device_class":null,"disabled_by":"integration","entity_id":"sensor.sun_solar_elevation_3","hidden_by":null,"icon":null,"id":"94add00b4aefbcfc6201746626b51c33","labels":[],"modified_at":"2025-08-13T05:57:40.368779+00:00","name":null,"options":{},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2GZG7XD3DQQSDY3SR568B10-solar_elevation"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2GZG7XD3DQQSDY3SR568B10","config_subentry_id":null,"created_at":"2025-08-13T05:47:29.587712+00:00","device_class":null,"disabled_by":"integration","entity_id":"sensor.sun_solar_azimuth_3","hidden_by":null,"icon":null,"id":"f0d169f4fb9a7ac39768774084e5a9b8","labels":[],"modified_at":"2025-08-13T05:57:40.368797+00:00","name":null,"options":{},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2GZG7XD3DQQSDY3SR568B10-solar_azimuth"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2GZG7XD3DQQSDY3SR568B10","config_subentry_id":null,"created_at":"2025-08-13T05:47:29.587792+00:00","device_class":null,"disabled_by":"integration","entity_id":"sensor.sun_solar_rising_3","hidden_by":null,"icon":null,"id":"1d1aad97fe4904e6a9a2664d1dc37ff0","labels":[],"modified_at":"2025-08-13T05:57:40.368815+00:00","name":null,"options":{},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2GZG7XD3DQQSDY3SR568B10-solar_rising"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.848144+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_dsm_state","hidden_by":null,"icon":null,"id":"2872edc50cb06034bd8698d9795163f4","labels":[],"modified_at":"2025-08-13T18:33:35.796693+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_dsm_state"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.856065+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_2_energy_produced","hidden_by":null,"icon":null,"id":"59c46751d19a82cbc215f55279c12d40","labels":[],"modified_at":"2025-08-13T18:33:35.797408+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_0dad2f16cd514812ae1807b0457d473e_energy_produced"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.861680+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_2_energy_consumed","hidden_by":null,"icon":null,"id":"4246c2b41a7ebfd03ef9d1b80071b08a","labels":[],"modified_at":"2025-08-13T18:33:35.798043+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_0dad2f16cd514812ae1807b0457d473e_energy_consumed"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.856325+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_4_energy_produced","hidden_by":null,"icon":null,"id":"e29101d955996df1dd911fde6717c41e","labels":[],"modified_at":"2025-08-13T18:33:35.797440+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_11a47a0f69d54e12b7200f730c2ffda1_energy_produced"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.861913+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_4_energy_consumed","hidden_by":null,"icon":null,"id":"6c5c344821fa7b0a33becafa109fe34b","labels":[],"modified_at":"2025-08-13T18:33:35.798072+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_11a47a0f69d54e12b7200f730c2ffda1_energy_consumed"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.856548+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_14_energy_produced","hidden_by":null,"icon":null,"id":"3e42c516247a80ec4996644412abb0fd","labels":[],"modified_at":"2025-08-13T18:33:35.797475+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_12ce227695cd44338864b0ef2ec4168b_energy_produced"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.862168+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_14_energy_consumed","hidden_by":null,"icon":null,"id":"2bca07aa17268d6d3d363e1106a76267","labels":[],"modified_at":"2025-08-13T18:33:35.798100+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_12ce227695cd44338864b0ef2ec4168b_energy_consumed"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.856773+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_24_energy_produced","hidden_by":null,"icon":null,"id":"167ac6c41849570d1eb73216a510d219","labels":[],"modified_at":"2025-08-13T18:33:35.797504+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_1ad73b0eb44e4022bb6270c76baed0c1_energy_produced"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.862562+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_24_energy_consumed","hidden_by":null,"icon":null,"id":"0e504ce59b846665357ec6dae085d277","labels":[],"modified_at":"2025-08-13T18:33:35.798131+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_1ad73b0eb44e4022bb6270c76baed0c1_energy_consumed"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.857115+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_16_energy_produced","hidden_by":null,"icon":null,"id":"b47595a0294a4759cc77ff2531c4ba9b","labels":[],"modified_at":"2025-08-13T18:33:35.797536+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_31b36cde0fc642b39eec515267707a6f_energy_produced"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.862808+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_16_energy_consumed","hidden_by":null,"icon":null,"id":"aeaac733055d39ff545f5ba8b3bb1b73","labels":[],"modified_at":"2025-08-13T18:33:35.798162+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_31b36cde0fc642b39eec515267707a6f_energy_consumed"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.857445+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_19_energy_produced","hidden_by":null,"icon":null,"id":"c5e05e41a30c7e069807907d5c6409a5","labels":[],"modified_at":"2025-08-13T18:33:35.797565+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_3a847fe6eb374ec3bf92cee1ffaf2eda_energy_produced"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.863047+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_19_energy_consumed","hidden_by":null,"icon":null,"id":"4d7e04331b157c2f26404dc3f9aaa615","labels":[],"modified_at":"2025-08-13T18:33:35.798191+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_3a847fe6eb374ec3bf92cee1ffaf2eda_energy_consumed"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.857673+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_12_energy_produced","hidden_by":null,"icon":null,"id":"0272c2bf8e251ed731c922765c3d637e","labels":[],"modified_at":"2025-08-13T18:33:35.797593+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_497ae7ebb2844772bf926f2f094c81bc_energy_produced"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.863264+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_12_energy_consumed","hidden_by":null,"icon":null,"id":"8a5ffbf98b570681a1d19f0db9879c3c","labels":[],"modified_at":"2025-08-13T18:33:35.798222+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_497ae7ebb2844772bf926f2f094c81bc_energy_consumed"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.857926+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_29_energy_produced","hidden_by":null,"icon":null,"id":"80f934b48bb12287fefc2d5cb94d7a23","labels":[],"modified_at":"2025-08-13T18:33:35.797620+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_617059df47bb49bd8545a36a6b6b23d2_energy_produced"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.863473+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_29_energy_consumed","hidden_by":null,"icon":null,"id":"a55bc4957f5a9f867f78ec956258d2b8","labels":[],"modified_at":"2025-08-13T18:33:35.798252+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_617059df47bb49bd8545a36a6b6b23d2_energy_consumed"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.858153+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_10_energy_produced","hidden_by":null,"icon":null,"id":"6d4ce66978fc745e3d7518f9e2cb656b","labels":[],"modified_at":"2025-08-13T18:33:35.797646+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_6ad65cf2ad6443448e33973f26f7df3e_energy_produced"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.863717+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_10_energy_consumed","hidden_by":null,"icon":null,"id":"c02eeee0efa2d8dc800631e4735aa8fb","labels":[],"modified_at":"2025-08-13T18:33:35.798283+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_6ad65cf2ad6443448e33973f26f7df3e_energy_consumed"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.858377+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_25_energy_produced","hidden_by":null,"icon":null,"id":"75b3c104df62c71835a913a4087504d3","labels":[],"modified_at":"2025-08-13T18:33:35.797672+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_795e8eddb4f448af9625130332a41df8_energy_produced"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.863962+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_25_energy_consumed","hidden_by":null,"icon":null,"id":"fd39fc4560fe37b7ca633143c4a03e9e","labels":[],"modified_at":"2025-08-13T18:33:35.798309+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_795e8eddb4f448af9625130332a41df8_energy_consumed"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.858599+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_23_energy_produced","hidden_by":null,"icon":null,"id":"77a41ee53f84c7845c3116236c7601a8","labels":[],"modified_at":"2025-08-13T18:33:35.797697+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_82a0888dc072416598d99f8b74ee3d8d_energy_produced"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.864296+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_23_energy_consumed","hidden_by":null,"icon":null,"id":"e353fb054e16a2dec2caf02b1a47a966","labels":[],"modified_at":"2025-08-13T18:33:35.798335+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_82a0888dc072416598d99f8b74ee3d8d_energy_consumed"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.858841+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_18_energy_produced","hidden_by":null,"icon":null,"id":"043ec695cb01e9a4e6780cd38c169058","labels":[],"modified_at":"2025-08-13T18:33:35.797722+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_8a2ffda9dbd24bada9a01b880e910612_energy_produced"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.864559+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_18_energy_consumed","hidden_by":null,"icon":null,"id":"e18513b455c038f0b05d932bdcf7b67c","labels":[],"modified_at":"2025-08-13T18:33:35.798362+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_8a2ffda9dbd24bada9a01b880e910612_energy_consumed"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.859114+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_9_energy_produced","hidden_by":null,"icon":null,"id":"22031cf7346617e30b511bc13790cbae","labels":[],"modified_at":"2025-08-13T18:33:35.797747+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_914943d4798c462cadf5e5144989dd5b_energy_produced"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.864845+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_9_energy_consumed","hidden_by":null,"icon":null,"id":"c9f89eefdb7a2b566971421f1c276b7e","labels":[],"modified_at":"2025-08-13T18:33:35.798388+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_914943d4798c462cadf5e5144989dd5b_energy_consumed"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.859325+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_1_energy_produced","hidden_by":null,"icon":null,"id":"3092401c9a306ab11e8040736c656aa3","labels":[],"modified_at":"2025-08-13T18:33:35.797773+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_926441605113492189f9aa13dac356cd_energy_produced"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.865142+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_1_energy_consumed","hidden_by":null,"icon":null,"id":"d0a3f19b639207da35f7acf1cf7e7283","labels":[],"modified_at":"2025-08-13T18:33:35.798415+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_926441605113492189f9aa13dac356cd_energy_consumed"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.859527+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_15_energy_produced","hidden_by":null,"icon":null,"id":"b530002eea85e3048d9bacc61d44aff5","labels":[],"modified_at":"2025-08-13T18:33:35.797799+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_941d6a8b41ab4c57a6b8be14b5981fe6_energy_produced"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.865537+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_15_energy_consumed","hidden_by":null,"icon":null,"id":"b0b2e1997bb9a966b0d71a86941f3bf6","labels":[],"modified_at":"2025-08-13T18:33:35.798440+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_941d6a8b41ab4c57a6b8be14b5981fe6_energy_consumed"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.859726+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_7_energy_produced","hidden_by":null,"icon":null,"id":"d61e80a2fda55dd3b356fca08a0ec056","labels":[],"modified_at":"2025-08-13T18:33:35.797826+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_988af7bb1fc04aac8bb3b45c660d920a_energy_produced"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.865776+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_7_energy_consumed","hidden_by":null,"icon":null,"id":"d153a0c02b42a7c68d832b1d61a436db","labels":[],"modified_at":"2025-08-13T18:33:35.798466+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_988af7bb1fc04aac8bb3b45c660d920a_energy_consumed"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.859934+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_22_energy_produced","hidden_by":null,"icon":null,"id":"e22bc83806bb963a4fbc907b7c41d81d","labels":[],"modified_at":"2025-08-13T18:33:35.797854+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_9941b417bfa046ef908d97c998c54c50_energy_produced"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.866012+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_22_energy_consumed","hidden_by":null,"icon":null,"id":"c685eb15006fd0f4c44eb5e61ecfb85e","labels":[],"modified_at":"2025-08-13T18:33:35.798491+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_9941b417bfa046ef908d97c998c54c50_energy_consumed"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.860157+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_6_energy_produced","hidden_by":null,"icon":null,"id":"e919e8e8fa77722525187367d4df187b","labels":[],"modified_at":"2025-08-13T18:33:35.797880+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_cbe98d7307ca42658983b9f673211e14_energy_produced"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.866225+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_6_energy_consumed","hidden_by":null,"icon":null,"id":"10f145f39f0f89f1df8b7a604d109c82","labels":[],"modified_at":"2025-08-13T18:33:35.798516+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_cbe98d7307ca42658983b9f673211e14_energy_consumed"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.860358+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_11_energy_produced","hidden_by":null,"icon":null,"id":"553a1b67f5ff12a958169a5e07f8ed48","labels":[],"modified_at":"2025-08-13T18:33:35.797909+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_d88c0c0e7c584472b2ec7e4d3c53c3b8_energy_produced"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.867620+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_11_energy_consumed","hidden_by":null,"icon":null,"id":"740ac5b2118dd2db977a41cfa3dfdc1b","labels":[],"modified_at":"2025-08-13T18:33:35.798542+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_d88c0c0e7c584472b2ec7e4d3c53c3b8_energy_consumed"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.860615+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_8_energy_produced","hidden_by":null,"icon":null,"id":"c2e14efd6d8c62251d5439cd885ba61b","labels":[],"modified_at":"2025-08-13T18:33:35.797935+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_e3cb78d38dae41cdb50faee7bfa4ab80_energy_produced"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.867933+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_8_energy_consumed","hidden_by":null,"icon":null,"id":"11caad665334ce41b1af4a8e22dfb1d4","labels":[],"modified_at":"2025-08-13T18:33:35.798568+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_e3cb78d38dae41cdb50faee7bfa4ab80_energy_consumed"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.861138+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_3_energy_produced","hidden_by":null,"icon":null,"id":"9f5b15cd56cb026ca6e0e51e5ed30405","labels":[],"modified_at":"2025-08-13T18:33:35.797961+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_ece352c3dc5c417e80757b716f24ba90_energy_produced"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.868264+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_3_energy_consumed","hidden_by":null,"icon":null,"id":"0481386b52c47f80cd8f17cf9d0ee784","labels":[],"modified_at":"2025-08-13T18:33:35.798593+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_ece352c3dc5c417e80757b716f24ba90_energy_consumed"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.861419+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_5_energy_produced","hidden_by":null,"icon":null,"id":"d714b3dbec1fe7a4af374bbb5bf068cd","labels":[],"modified_at":"2025-08-13T18:33:35.798013+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_f19d909abcc4421f9ec630f82458c7ac_energy_produced"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.868489+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_5_energy_consumed","hidden_by":null,"icon":null,"id":"dcc12ab989b60d5e9ae9cdb04b108b21","labels":[],"modified_at":"2025-08-13T18:33:35.798618+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_f19d909abcc4421f9ec630f82458c7ac_energy_consumed"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H02JC3PVKEJT3VG6PS4V8R","config_subentry_id":null,"created_at":"2025-08-13T05:57:30.116575+00:00","device_class":null,"disabled_by":"integration","entity_id":"binary_sensor.sun_solar_rising_4","hidden_by":null,"icon":null,"id":"1018173771df791492694fe0e5b18d29","labels":[],"modified_at":"2025-08-13T06:17:25.186746+00:00","name":null,"options":{},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2H02JC3PVKEJT3VG6PS4V8R-solar_rising"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H02JC3PVKEJT3VG6PS4V8R","config_subentry_id":null,"created_at":"2025-08-13T05:57:30.116933+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_dawn_4","hidden_by":null,"icon":null,"id":"01eb4ffd8b8479ddf02ef2c77252bde0","labels":[],"modified_at":"2025-08-13T06:17:25.186808+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2H02JC3PVKEJT3VG6PS4V8R-next_dawn"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H02JC3PVKEJT3VG6PS4V8R","config_subentry_id":null,"created_at":"2025-08-13T05:57:30.117164+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_dusk_4","hidden_by":null,"icon":null,"id":"f5abf0b046ae478484cf95437521eb19","labels":[],"modified_at":"2025-08-13T06:17:25.186862+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2H02JC3PVKEJT3VG6PS4V8R-next_dusk"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H02JC3PVKEJT3VG6PS4V8R","config_subentry_id":null,"created_at":"2025-08-13T05:57:30.117339+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_midnight_4","hidden_by":null,"icon":null,"id":"4c5a238a35411eeab0351f727c88b22c","labels":[],"modified_at":"2025-08-13T06:17:25.186894+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2H02JC3PVKEJT3VG6PS4V8R-next_midnight"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H02JC3PVKEJT3VG6PS4V8R","config_subentry_id":null,"created_at":"2025-08-13T05:57:30.117524+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_noon_4","hidden_by":null,"icon":null,"id":"1d15413e81cf68c9245a2f7788a8760d","labels":[],"modified_at":"2025-08-13T06:17:25.186920+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2H02JC3PVKEJT3VG6PS4V8R-next_noon"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H02JC3PVKEJT3VG6PS4V8R","config_subentry_id":null,"created_at":"2025-08-13T05:57:30.117670+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_rising_4","hidden_by":null,"icon":null,"id":"f472440a1d80225d10736ad4c25fa2de","labels":[],"modified_at":"2025-08-13T06:17:25.186946+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2H02JC3PVKEJT3VG6PS4V8R-next_rising"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H02JC3PVKEJT3VG6PS4V8R","config_subentry_id":null,"created_at":"2025-08-13T05:57:30.117781+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_setting_4","hidden_by":null,"icon":null,"id":"cc24299c995052f15a80775358b4ee73","labels":[],"modified_at":"2025-08-13T06:17:25.186972+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2H02JC3PVKEJT3VG6PS4V8R-next_setting"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H02JC3PVKEJT3VG6PS4V8R","config_subentry_id":null,"created_at":"2025-08-13T05:57:30.117898+00:00","device_class":null,"disabled_by":"integration","entity_id":"sensor.sun_solar_elevation_4","hidden_by":null,"icon":null,"id":"c3b2706d7f4f83dc512e3a37a75b41ed","labels":[],"modified_at":"2025-08-13T06:17:25.187011+00:00","name":null,"options":{},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2H02JC3PVKEJT3VG6PS4V8R-solar_elevation"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H02JC3PVKEJT3VG6PS4V8R","config_subentry_id":null,"created_at":"2025-08-13T05:57:30.117977+00:00","device_class":null,"disabled_by":"integration","entity_id":"sensor.sun_solar_azimuth_4","hidden_by":null,"icon":null,"id":"b0147de22a66a69f8d34378dc7e17402","labels":[],"modified_at":"2025-08-13T06:17:25.187031+00:00","name":null,"options":{},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2H02JC3PVKEJT3VG6PS4V8R-solar_azimuth"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H02JC3PVKEJT3VG6PS4V8R","config_subentry_id":null,"created_at":"2025-08-13T05:57:30.118054+00:00","device_class":null,"disabled_by":"integration","entity_id":"sensor.sun_solar_rising_4","hidden_by":null,"icon":null,"id":"12c6087bf57eed82358c26e1277957ab","labels":[],"modified_at":"2025-08-13T06:17:25.187051+00:00","name":null,"options":{},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2H02JC3PVKEJT3VG6PS4V8R-solar_rising"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H16QEC8NYDACSS5HKKBK0P","config_subentry_id":null,"created_at":"2025-08-13T06:17:14.960099+00:00","device_class":null,"disabled_by":"integration","entity_id":"binary_sensor.sun_solar_rising_3","hidden_by":null,"icon":null,"id":"251a913611eef62473899e93857bc6a6","labels":[],"modified_at":"2025-08-13T06:39:26.301689+00:00","name":null,"options":{},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2H16QEC8NYDACSS5HKKBK0P-solar_rising"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H16QEC8NYDACSS5HKKBK0P","config_subentry_id":null,"created_at":"2025-08-13T06:17:14.960885+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_dawn_3","hidden_by":null,"icon":null,"id":"e3a04fcef6e2db217bfdeeae60fb391d","labels":[],"modified_at":"2025-08-13T06:39:26.301751+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2H16QEC8NYDACSS5HKKBK0P-next_dawn"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H16QEC8NYDACSS5HKKBK0P","config_subentry_id":null,"created_at":"2025-08-13T06:17:14.961265+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_dusk_3","hidden_by":null,"icon":null,"id":"3cbdbdfc1abefe75fe4d892e3cf64d80","labels":[],"modified_at":"2025-08-13T06:39:26.301809+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2H16QEC8NYDACSS5HKKBK0P-next_dusk"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H16QEC8NYDACSS5HKKBK0P","config_subentry_id":null,"created_at":"2025-08-13T06:17:14.961529+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_midnight_3","hidden_by":null,"icon":null,"id":"00dfa3ab6ccd5eaf9e5147a96c935e1d","labels":[],"modified_at":"2025-08-13T06:39:26.301843+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2H16QEC8NYDACSS5HKKBK0P-next_midnight"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H16QEC8NYDACSS5HKKBK0P","config_subentry_id":null,"created_at":"2025-08-13T06:17:14.961752+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_noon_3","hidden_by":null,"icon":null,"id":"caf85e286365736093b531c2861c71bb","labels":[],"modified_at":"2025-08-13T06:39:26.301871+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2H16QEC8NYDACSS5HKKBK0P-next_noon"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H16QEC8NYDACSS5HKKBK0P","config_subentry_id":null,"created_at":"2025-08-13T06:17:14.961925+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_rising_3","hidden_by":null,"icon":null,"id":"e268fa4fed95f8c24b76f298cbaea1cd","labels":[],"modified_at":"2025-08-13T06:39:26.301899+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2H16QEC8NYDACSS5HKKBK0P-next_rising"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H16QEC8NYDACSS5HKKBK0P","config_subentry_id":null,"created_at":"2025-08-13T06:17:14.962128+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_setting_3","hidden_by":null,"icon":null,"id":"66663ff4e0bd8aeb459a2047cdda3a8c","labels":[],"modified_at":"2025-08-13T06:39:26.301927+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2H16QEC8NYDACSS5HKKBK0P-next_setting"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H16QEC8NYDACSS5HKKBK0P","config_subentry_id":null,"created_at":"2025-08-13T06:17:14.962559+00:00","device_class":null,"disabled_by":"integration","entity_id":"sensor.sun_solar_elevation_3","hidden_by":null,"icon":null,"id":"a4e60d8228a7ed17159184f20473c971","labels":[],"modified_at":"2025-08-13T06:39:26.301954+00:00","name":null,"options":{},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2H16QEC8NYDACSS5HKKBK0P-solar_elevation"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H16QEC8NYDACSS5HKKBK0P","config_subentry_id":null,"created_at":"2025-08-13T06:17:14.962717+00:00","device_class":null,"disabled_by":"integration","entity_id":"sensor.sun_solar_azimuth_3","hidden_by":null,"icon":null,"id":"fcf0c57027bfd2890a34c5919b632da2","labels":[],"modified_at":"2025-08-13T06:39:26.301974+00:00","name":null,"options":{},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2H16QEC8NYDACSS5HKKBK0P-solar_azimuth"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H16QEC8NYDACSS5HKKBK0P","config_subentry_id":null,"created_at":"2025-08-13T06:17:14.962831+00:00","device_class":null,"disabled_by":"integration","entity_id":"sensor.sun_solar_rising_3","hidden_by":null,"icon":null,"id":"8153b8bd3a25445975d3146d218e531f","labels":[],"modified_at":"2025-08-13T06:39:26.302005+00:00","name":null,"options":{},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2H16QEC8NYDACSS5HKKBK0P-solar_rising"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.847749+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_current_run_config","hidden_by":null,"icon":null,"id":"ac9bc34e569a63ad053dacbdd9549dae","labels":[],"modified_at":"2025-08-13T18:33:35.796638+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_currentrunconfig"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.847943+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_dsm_grid_state","hidden_by":null,"icon":null,"id":"0a539e2fb02dac5b417c060775cd4728","labels":[],"modified_at":"2025-08-13T18:33:35.796666+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_dsmgridstate"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.848347+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_main_relay_state","hidden_by":null,"icon":null,"id":"c06669d4e6696af19fd74a336663d105","labels":[],"modified_at":"2025-08-13T18:33:35.796721+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_mainrelaystate"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.848548+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_software_version","hidden_by":null,"icon":null,"id":"101ef63a034e44c67da48baadb00b205","labels":[],"modified_at":"2025-08-13T18:33:35.796747+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_softwarever"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H17FA5Y4A1B4B7FYE2XTR8","config_subentry_id":null,"created_at":"2025-08-12T17:45:22.281057+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_span_storage_battery_percentage","hidden_by":null,"icon":null,"id":"6be7cb7cc9d6f5933cf2e7f36e13bd86","labels":[],"modified_at":"2025-08-13T06:39:26.313722+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_battery_level"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H17FA5Y4A1B4B7FYE2XTR8","config_subentry_id":null,"created_at":"2025-08-12T19:40:10.269712+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_battery_level","hidden_by":null,"icon":null,"id":"8c00b165490505a17569ddc992e651e6","labels":[],"modified_at":"2025-08-13T06:39:26.314449+00:00","name":null,"options":{"sensor":{"suggested_display_precision":0},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_storage_battery_percentage"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H17FA5Y4A1B4B7FYE2XTR8","config_subentry_id":null,"created_at":"2025-08-12T04:23:25.346800+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.solar_produced_energy","hidden_by":null,"icon":null,"id":"f0ae2604d7b06e848c82e361292362b9","labels":[],"modified_at":"2025-08-13T06:39:26.314574+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_solar_produced_energy"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H17FA5Y4A1B4B7FYE2XTR8","config_subentry_id":null,"created_at":"2025-08-12T04:23:25.347695+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.solar_current_power","hidden_by":null,"icon":null,"id":"27e0d89028f47e829a7035ac098547d6","labels":[],"modified_at":"2025-08-13T06:39:26.314599+00:00","name":null,"options":{"sensor":{"suggested_display_precision":0},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_solar_current_power"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H17FA5Y4A1B4B7FYE2XTR8","config_subentry_id":null,"created_at":"2025-08-12T04:23:25.344402+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.solar_consumed_energy","hidden_by":null,"icon":null,"id":"6aa5e34ebfb46a5ef14cb66500a3d502","labels":[],"modified_at":"2025-08-13T06:39:26.314624+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_solar_consumed_energy"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H2F1HB23ZT9V5HFE1WFN0H","config_subentry_id":null,"created_at":"2025-08-13T06:39:16.017655+00:00","device_class":null,"disabled_by":"integration","entity_id":"binary_sensor.sun_solar_rising_4","hidden_by":null,"icon":null,"id":"94bad047a177dc645f82f0371c929e4e","labels":[],"modified_at":"2025-08-13T06:50:57.044798+00:00","name":null,"options":{},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2H2F1HB23ZT9V5HFE1WFN0H-solar_rising"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H2F1HB23ZT9V5HFE1WFN0H","config_subentry_id":null,"created_at":"2025-08-13T06:39:16.018092+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_dawn_4","hidden_by":null,"icon":null,"id":"9c7d1effffa805021e990bc92a4efa34","labels":[],"modified_at":"2025-08-13T06:50:57.044857+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2H2F1HB23ZT9V5HFE1WFN0H-next_dawn"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H2F1HB23ZT9V5HFE1WFN0H","config_subentry_id":null,"created_at":"2025-08-13T06:39:16.018362+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_dusk_4","hidden_by":null,"icon":null,"id":"a6d61cea5488a150538a89a1959b39df","labels":[],"modified_at":"2025-08-13T06:50:57.044908+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2H2F1HB23ZT9V5HFE1WFN0H-next_dusk"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H2F1HB23ZT9V5HFE1WFN0H","config_subentry_id":null,"created_at":"2025-08-13T06:39:16.018540+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_midnight_4","hidden_by":null,"icon":null,"id":"c9a455cf8447bde506fe78d9086048b7","labels":[],"modified_at":"2025-08-13T06:50:57.044938+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2H2F1HB23ZT9V5HFE1WFN0H-next_midnight"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H2F1HB23ZT9V5HFE1WFN0H","config_subentry_id":null,"created_at":"2025-08-13T06:39:16.018695+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_noon_4","hidden_by":null,"icon":null,"id":"e9bdb2bddcf129a37ee350b61cc97710","labels":[],"modified_at":"2025-08-13T06:50:57.044965+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2H2F1HB23ZT9V5HFE1WFN0H-next_noon"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H2F1HB23ZT9V5HFE1WFN0H","config_subentry_id":null,"created_at":"2025-08-13T06:39:16.018827+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_rising_4","hidden_by":null,"icon":null,"id":"9366e806f6f62211e3499731bde1a017","labels":[],"modified_at":"2025-08-13T06:50:57.045030+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2H2F1HB23ZT9V5HFE1WFN0H-next_rising"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H2F1HB23ZT9V5HFE1WFN0H","config_subentry_id":null,"created_at":"2025-08-13T06:39:16.018965+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_setting_4","hidden_by":null,"icon":null,"id":"516f34ef6b3b663819506117d872f892","labels":[],"modified_at":"2025-08-13T06:50:57.045067+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2H2F1HB23ZT9V5HFE1WFN0H-next_setting"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H2F1HB23ZT9V5HFE1WFN0H","config_subentry_id":null,"created_at":"2025-08-13T06:39:16.019167+00:00","device_class":null,"disabled_by":"integration","entity_id":"sensor.sun_solar_elevation_4","hidden_by":null,"icon":null,"id":"c5b80a1ab12d37a3b6366162162a721c","labels":[],"modified_at":"2025-08-13T06:50:57.045094+00:00","name":null,"options":{},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2H2F1HB23ZT9V5HFE1WFN0H-solar_elevation"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H2F1HB23ZT9V5HFE1WFN0H","config_subentry_id":null,"created_at":"2025-08-13T06:39:16.019261+00:00","device_class":null,"disabled_by":"integration","entity_id":"sensor.sun_solar_azimuth_4","hidden_by":null,"icon":null,"id":"c3da101875f600454550a00bd3f74326","labels":[],"modified_at":"2025-08-13T06:50:57.045118+00:00","name":null,"options":{},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2H2F1HB23ZT9V5HFE1WFN0H-solar_azimuth"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H2F1HB23ZT9V5HFE1WFN0H","config_subentry_id":null,"created_at":"2025-08-13T06:39:16.019338+00:00","device_class":null,"disabled_by":"integration","entity_id":"sensor.sun_solar_rising_4","hidden_by":null,"icon":null,"id":"57bf31a77ceab87fcfdfd025e4ba52b1","labels":[],"modified_at":"2025-08-13T06:50:57.045137+00:00","name":null,"options":{},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2H2F1HB23ZT9V5HFE1WFN0H-solar_rising"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H3443174B991E3DASPT0NG","config_subentry_id":null,"created_at":"2025-08-13T06:50:46.755490+00:00","device_class":null,"disabled_by":"integration","entity_id":"binary_sensor.sun_solar_rising_3","hidden_by":null,"icon":null,"id":"554e5121c324802b21c72e8d2be2f894","labels":[],"modified_at":"2025-08-13T18:33:35.795067+00:00","name":null,"options":{},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2H3443174B991E3DASPT0NG-solar_rising"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H3443174B991E3DASPT0NG","config_subentry_id":null,"created_at":"2025-08-13T06:50:46.755866+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_dawn_3","hidden_by":null,"icon":null,"id":"661bcc7573e2750f084cc2357d96e485","labels":[],"modified_at":"2025-08-13T18:33:35.795159+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2H3443174B991E3DASPT0NG-next_dawn"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H3443174B991E3DASPT0NG","config_subentry_id":null,"created_at":"2025-08-13T06:50:46.756122+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_dusk_3","hidden_by":null,"icon":null,"id":"199c4add7595639d5e53f93fab96e504","labels":[],"modified_at":"2025-08-13T18:33:35.795241+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2H3443174B991E3DASPT0NG-next_dusk"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H3443174B991E3DASPT0NG","config_subentry_id":null,"created_at":"2025-08-13T06:50:46.756297+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_midnight_3","hidden_by":null,"icon":null,"id":"e89df3d27548f7bd009ddb1501ea504b","labels":[],"modified_at":"2025-08-13T18:33:35.795289+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2H3443174B991E3DASPT0NG-next_midnight"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H3443174B991E3DASPT0NG","config_subentry_id":null,"created_at":"2025-08-13T06:50:46.756446+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_noon_3","hidden_by":null,"icon":null,"id":"7554b29751183245fd16117d2156da05","labels":[],"modified_at":"2025-08-13T18:33:35.795333+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2H3443174B991E3DASPT0NG-next_noon"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H3443174B991E3DASPT0NG","config_subentry_id":null,"created_at":"2025-08-13T06:50:46.756595+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_rising_3","hidden_by":null,"icon":null,"id":"392c67733b4496db737771d3f4d770bd","labels":[],"modified_at":"2025-08-13T18:33:35.795375+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2H3443174B991E3DASPT0NG-next_rising"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H3443174B991E3DASPT0NG","config_subentry_id":null,"created_at":"2025-08-13T06:50:46.756754+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_setting_3","hidden_by":null,"icon":null,"id":"c155cee515fae18f0897b2b0d24a189d","labels":[],"modified_at":"2025-08-13T18:33:35.795417+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2H3443174B991E3DASPT0NG-next_setting"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H3443174B991E3DASPT0NG","config_subentry_id":null,"created_at":"2025-08-13T06:50:46.756881+00:00","device_class":null,"disabled_by":"integration","entity_id":"sensor.sun_solar_elevation_3","hidden_by":null,"icon":null,"id":"6e60a1251b960f803577018d9345f19a","labels":[],"modified_at":"2025-08-13T18:33:35.795459+00:00","name":null,"options":{},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2H3443174B991E3DASPT0NG-solar_elevation"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H3443174B991E3DASPT0NG","config_subentry_id":null,"created_at":"2025-08-13T06:50:46.756967+00:00","device_class":null,"disabled_by":"integration","entity_id":"sensor.sun_solar_azimuth_3","hidden_by":null,"icon":null,"id":"621a57ac2c6c76cedb09ef94d9517d16","labels":[],"modified_at":"2025-08-13T18:33:35.795493+00:00","name":null,"options":{},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2H3443174B991E3DASPT0NG-solar_azimuth"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H3443174B991E3DASPT0NG","config_subentry_id":null,"created_at":"2025-08-13T06:50:46.757057+00:00","device_class":null,"disabled_by":"integration","entity_id":"sensor.sun_solar_rising_3","hidden_by":null,"icon":null,"id":"00875f338b59b024420c5357bdcc2ec2","labels":[],"modified_at":"2025-08-13T18:33:35.795522+00:00","name":null,"options":{},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2H3443174B991E3DASPT0NG-solar_rising"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-11T23:51:22.685988+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_dsm_grid_state_2","hidden_by":null,"icon":null,"id":"8c0badf74e85a0ed71da34e9e2338867","labels":[],"modified_at":"2025-08-13T18:33:35.799187+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_dsm_grid_state"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-11T23:51:22.686152+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_current_run_config_2","hidden_by":null,"icon":null,"id":"92bdb118ff17ee4c5a13d598ac59bfe0","labels":[],"modified_at":"2025-08-13T18:33:35.799215+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_current_run_config"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-11T23:51:22.686598+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_main_relay_state_2","hidden_by":null,"icon":null,"id":"3d2d7f4baebc2383561e15fc73667b43","labels":[],"modified_at":"2025-08-13T18:33:35.799244+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_main_relay_state"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-11T23:51:22.686759+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_unmapped_tab_32_power","hidden_by":"integration","icon":null,"id":"95f8a6df6eadc89f3de62419847a4632","labels":[],"modified_at":"2025-08-13T18:33:35.799272+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_unmapped_tab_32_power"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-11T23:51:22.686977+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_unmapped_tab_32_energy_produced","hidden_by":"integration","icon":null,"id":"36cbeaba0d5e51544152a2aba31d71cb","labels":[],"modified_at":"2025-08-13T18:33:35.799304+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_unmapped_tab_32_energy_produced"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-11T23:51:22.687170+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_unmapped_tab_32_energy_consumed","hidden_by":"integration","icon":null,"id":"d18d4aae6c49fc66e39d5d3561819bae","labels":[],"modified_at":"2025-08-13T18:33:35.799331+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_unmapped_tab_32_energy_consumed"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-11T23:51:22.687355+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_unmapped_tab_27_power","hidden_by":"integration","icon":null,"id":"4453178cdc7092161efe3fb2add352f3","labels":[],"modified_at":"2025-08-13T18:33:35.799356+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_unmapped_tab_27_power"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-11T23:51:22.687527+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_unmapped_tab_27_energy_produced","hidden_by":"integration","icon":null,"id":"e9bcb88fc4082d600566326a434197c3","labels":[],"modified_at":"2025-08-13T18:33:35.799380+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_unmapped_tab_27_energy_produced"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-11T23:51:22.687708+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_unmapped_tab_27_energy_consumed","hidden_by":"integration","icon":null,"id":"ebd4e3edd06bc2ca6d3b99ba0c358bc3","labels":[],"modified_at":"2025-08-13T18:33:35.799404+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_unmapped_tab_27_energy_consumed"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-11T23:51:22.687892+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_unmapped_tab_28_power","hidden_by":"integration","icon":null,"id":"e096a7edd49d1bc813d0fd559900126b","labels":[],"modified_at":"2025-08-13T18:33:35.799433+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_unmapped_tab_28_power"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-11T23:51:22.688064+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_unmapped_tab_28_energy_produced","hidden_by":"integration","icon":null,"id":"5787f118bef84918da4fbce586aad558","labels":[],"modified_at":"2025-08-13T18:33:35.799470+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_unmapped_tab_28_energy_produced"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-11T23:51:22.688287+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_unmapped_tab_28_energy_consumed","hidden_by":"integration","icon":null,"id":"272c6854685062e4f9dece096719d927","labels":[],"modified_at":"2025-08-13T18:33:35.799496+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_unmapped_tab_28_energy_consumed"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-11T23:51:22.688497+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_unmapped_tab_30_power","hidden_by":"integration","icon":null,"id":"aec5e903c72b184cbb32d615b91f225c","labels":[],"modified_at":"2025-08-13T18:33:35.799521+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_unmapped_tab_30_power"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-11T23:51:22.688649+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_unmapped_tab_30_energy_produced","hidden_by":"integration","icon":null,"id":"d099b87aa03296bef5c4f800abd6d0d8","labels":[],"modified_at":"2025-08-13T18:33:35.799555+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_unmapped_tab_30_energy_produced"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-11T23:51:22.688797+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_unmapped_tab_30_energy_consumed","hidden_by":"integration","icon":null,"id":"38326d39091077ebe11e2f9db42fe020","labels":[],"modified_at":"2025-08-13T18:33:35.799590+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_unmapped_tab_30_energy_consumed"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-11T23:51:22.689036+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_software_version_2","hidden_by":null,"icon":null,"id":"57d3c3fd41a9b0a1a60fe1f6bfa2690c","labels":[],"modified_at":"2025-08-13T18:33:35.801042+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_software_version"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-11T23:51:23.240202+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_main_meter_produced_energy_2","hidden_by":null,"icon":null,"id":"d115ab405005de3067e168c7f3a1362c","labels":[],"modified_at":"2025-08-13T18:33:35.801126+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_main_meter_produced_energy"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-11T23:51:23.240246+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_main_meter_consumed_energy_2","hidden_by":null,"icon":null,"id":"37950008f2ffea0670e4253f0b09abe9","labels":[],"modified_at":"2025-08-13T18:33:35.801174+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_main_meter_consumed_energy"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-11T23:51:23.240282+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_feed_through_produced_energy_2","hidden_by":null,"icon":null,"id":"ac8d0473ad359313af7e2f09495ff6d5","labels":[],"modified_at":"2025-08-13T18:33:35.801222+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_feed_through_produced_energy"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-11T23:51:23.240319+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_feed_through_consumed_energy_2","hidden_by":null,"icon":null,"id":"cf929697425caec1d2cbd87b3ce554b6","labels":[],"modified_at":"2025-08-13T18:33:35.801256+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_feed_through_consumed_energy"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-15T17:31:55.131436+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.solar_inverter_instant_power","hidden_by":null,"icon":null,"id":"045853c93ff5c58f32b435c157ea87be","labels":[],"modified_at":"2025-08-16T04:43:43.197237+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755319423.1977437,"platform":"span_panel","unique_id":"span_nj-2316-005k6_solar_inverter_instant_power"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-15T17:31:55.132201+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.solar_inverter_energy_produced","hidden_by":null,"icon":null,"id":"5aacb42598acde3171b22631bba0567d","labels":[],"modified_at":"2025-08-16T04:43:43.197265+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755319423.1977437,"platform":"span_panel","unique_id":"span_nj-2316-005k6_solar_inverter_energy_produced"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-15T17:31:55.132919+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.solar_inverter_energy_consumed","hidden_by":null,"icon":null,"id":"788c979322d75e31c23f144e5a5ae91e","labels":[],"modified_at":"2025-08-16T04:43:43.197296+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755319423.1977437,"platform":"span_panel","unique_id":"span_nj-2316-005k6_solar_inverter_energy_consumed"} - ] - } -} diff --git a/tests/migration_storage/1_0_4/core.device_registry b/tests/migration_storage/1_0_4/core.device_registry deleted file mode 100644 index bdeae531..00000000 --- a/tests/migration_storage/1_0_4/core.device_registry +++ /dev/null @@ -1,39 +0,0 @@ -{ - "version": 1, - "minor_version": 11, - "key": "core.device_registry", - "data": { - "devices": [ - {"area_id":null,"config_entries":["01K2JBARFARWNFC92NKX1158S6"],"config_entries_subentries":{"01K2JBARFARWNFC92NKX1158S6":[null]},"configuration_url":"homeassistant://hassio/addon/core_matter_server","connections":[],"created_at":"2025-08-12T20:21:33.095150+00:00","disabled_by":null,"entry_type":"service","hw_version":null,"id":"4ca44f5be7c86da449cc8c12fecdfe2e","identifiers":[["hassio","core_matter_server"]],"labels":[],"manufacturer":"Official add-ons","model":"Home Assistant Add-on","model_id":null,"modified_at":"2025-08-15T19:01:00.238499+00:00","name_by_user":null,"name":"Matter Server","primary_config_entry":"01K2JBARFARWNFC92NKX1158S6","serial_number":null,"sw_version":"8.1.0","via_device_id":null}, - {"area_id":null,"config_entries":["01K2JBARFARWNFC92NKX1158S6"],"config_entries_subentries":{"01K2JBARFARWNFC92NKX1158S6":[null]},"configuration_url":"homeassistant://hassio/addon/cb646a50_get","connections":[],"created_at":"2025-08-12T20:21:33.095317+00:00","disabled_by":null,"entry_type":"service","hw_version":null,"id":"319613801556ba7699ae5096e9ec8005","identifiers":[["hassio","cb646a50_get"]],"labels":[],"manufacturer":"HACS Add-ons Repository","model":"Home Assistant Add-on","model_id":null,"modified_at":"2025-08-13T18:33:35.794534+00:00","name_by_user":null,"name":"Get HACS","primary_config_entry":"01K2JBARFARWNFC92NKX1158S6","serial_number":null,"sw_version":"1.3.1","via_device_id":null}, - {"area_id":null,"config_entries":["01K2JBARFARWNFC92NKX1158S6"],"config_entries_subentries":{"01K2JBARFARWNFC92NKX1158S6":[null]},"configuration_url":"homeassistant://hassio/addon/a0d7b954_ssh","connections":[],"created_at":"2025-08-12T20:21:33.095380+00:00","disabled_by":null,"entry_type":"service","hw_version":null,"id":"ab3731f6cd9cc377acd97036169f3f22","identifiers":[["hassio","a0d7b954_ssh"]],"labels":[],"manufacturer":"Home Assistant Community Add-ons","model":"Home Assistant Add-on","model_id":null,"modified_at":"2025-08-13T18:33:35.794610+00:00","name_by_user":null,"name":"Advanced SSH & Web Terminal","primary_config_entry":"01K2JBARFARWNFC92NKX1158S6","serial_number":null,"sw_version":"21.0.2","via_device_id":null}, - {"area_id":null,"config_entries":["01K2JBARFARWNFC92NKX1158S6"],"config_entries_subentries":{"01K2JBARFARWNFC92NKX1158S6":[null]},"configuration_url":"homeassistant://hassio/addon/core_samba","connections":[],"created_at":"2025-08-12T20:21:33.095433+00:00","disabled_by":null,"entry_type":"service","hw_version":null,"id":"501eb9aa456c4652973565fb732a3b4e","identifiers":[["hassio","core_samba"]],"labels":[],"manufacturer":"Official add-ons","model":"Home Assistant Add-on","model_id":null,"modified_at":"2025-08-13T18:33:35.794671+00:00","name_by_user":null,"name":"Samba share","primary_config_entry":"01K2JBARFARWNFC92NKX1158S6","serial_number":null,"sw_version":"12.5.2","via_device_id":null}, - {"area_id":null,"config_entries":["01K2JBARFARWNFC92NKX1158S6"],"config_entries_subentries":{"01K2JBARFARWNFC92NKX1158S6":[null]},"configuration_url":null,"connections":[],"created_at":"2025-08-12T20:21:33.095479+00:00","disabled_by":null,"entry_type":"service","hw_version":null,"id":"dde50258240cf5c0df0bc7a04fe78cc1","identifiers":[["hassio","core"]],"labels":[],"manufacturer":"Home Assistant","model":"Home Assistant Core","model_id":null,"modified_at":"2025-08-15T18:58:39.403985+00:00","name_by_user":null,"name":"Home Assistant Core","primary_config_entry":"01K2JBARFARWNFC92NKX1158S6","serial_number":null,"sw_version":"2025.8.2","via_device_id":null}, - {"area_id":null,"config_entries":["01K2JBARFARWNFC92NKX1158S6"],"config_entries_subentries":{"01K2JBARFARWNFC92NKX1158S6":[null]},"configuration_url":null,"connections":[],"created_at":"2025-08-12T20:21:33.095521+00:00","disabled_by":null,"entry_type":"service","hw_version":null,"id":"27af8b2f4b7134648570f505ee894f7f","identifiers":[["hassio","supervisor"]],"labels":[],"manufacturer":"Home Assistant","model":"Home Assistant Supervisor","model_id":null,"modified_at":"2025-08-13T18:33:35.794765+00:00","name_by_user":null,"name":"Home Assistant Supervisor","primary_config_entry":"01K2JBARFARWNFC92NKX1158S6","serial_number":null,"sw_version":"2025.08.1","via_device_id":null}, - {"area_id":null,"config_entries":["01K2JBARFARWNFC92NKX1158S6"],"config_entries_subentries":{"01K2JBARFARWNFC92NKX1158S6":[null]},"configuration_url":null,"connections":[],"created_at":"2025-08-12T20:21:33.095561+00:00","disabled_by":null,"entry_type":"service","hw_version":null,"id":"3621bb606c055e76c452a48d73240067","identifiers":[["hassio","host"]],"labels":[],"manufacturer":"Home Assistant","model":"Home Assistant Host","model_id":null,"modified_at":"2025-08-13T18:33:35.794809+00:00","name_by_user":null,"name":"Home Assistant Host","primary_config_entry":"01K2JBARFARWNFC92NKX1158S6","serial_number":null,"sw_version":null,"via_device_id":null}, - {"area_id":null,"config_entries":["01K2JBARFARWNFC92NKX1158S6"],"config_entries_subentries":{"01K2JBARFARWNFC92NKX1158S6":[null]},"configuration_url":null,"connections":[],"created_at":"2025-08-12T20:21:33.095619+00:00","disabled_by":null,"entry_type":"service","hw_version":null,"id":"82ecbe58a0057ea8b761de3de121d194","identifiers":[["hassio","OS"]],"labels":[],"manufacturer":"Home Assistant","model":"Home Assistant Operating System","model_id":null,"modified_at":"2025-08-15T19:01:00.238754+00:00","name_by_user":null,"name":"Home Assistant Operating System","primary_config_entry":"01K2JBARFARWNFC92NKX1158S6","serial_number":null,"sw_version":"16.1","via_device_id":null}, - {"area_id":null,"config_entries":["01K2JBASJA50R982BKFZB254V1"],"config_entries_subentries":{"01K2JBASJA50R982BKFZB254V1":[null]},"configuration_url":"homeassistant://config/backup","connections":[],"created_at":"2025-08-12T20:21:33.383381+00:00","disabled_by":null,"entry_type":"service","hw_version":null,"id":"299c00a506ba6f1ff71281a172def5dd","identifiers":[["backup","backup_manager"]],"labels":[],"manufacturer":"Home Assistant","model":"Home Assistant Backup","model_id":null,"modified_at":"2025-08-15T18:58:41.315237+00:00","name_by_user":null,"name":"Backup","primary_config_entry":"01K2JBASJA50R982BKFZB254V1","serial_number":null,"sw_version":"2025.8.2","via_device_id":null}, - {"area_id":null,"config_entries":["01K2JBAPRJGMHHG5E8GW97HFKG"],"config_entries_subentries":{"01K2JBAPRJGMHHG5E8GW97HFKG":[null]},"configuration_url":null,"connections":[],"created_at":"2025-08-13T18:33:25.525943+00:00","disabled_by":null,"entry_type":"service","hw_version":null,"id":"fe6a0e95694074756aef7b0d8db21451","identifiers":[["sun","01K2JBAPRJGMHHG5E8GW97HFKG"]],"labels":[],"manufacturer":null,"model":null,"model_id":null,"modified_at":"2025-08-13T18:33:25.525970+00:00","name_by_user":null,"name":"Sun","primary_config_entry":"01K2JBAPRJGMHHG5E8GW97HFKG","serial_number":null,"sw_version":null,"via_device_id":null}, - {"area_id":null,"config_entries":["01K2QC172ABPW4AB4Z5A3EAW3D"],"config_entries_subentries":{"01K2QC172ABPW4AB4Z5A3EAW3D":[null]},"configuration_url":"homeassistant://hacs","connections":[],"created_at":"2025-08-12T20:23:47.897933+00:00","disabled_by":null,"entry_type":"service","hw_version":null,"id":"8badde1dee15f240cb614586522bd66f","identifiers":[["hacs","0717a0cd-745c-48fd-9b16-c8534c9704f9-bc944b0f-fd42-4a58-a072-ade38d1444cd"]],"labels":[],"manufacturer":"hacs.xyz","model":"","model_id":null,"modified_at":"2025-08-15T17:21:55.342052+00:00","name_by_user":null,"name":"HACS","primary_config_entry":"01K2QC172ABPW4AB4Z5A3EAW3D","serial_number":null,"sw_version":"2.0.5","via_device_id":null}, - {"area_id":null,"config_entries":["01K2QC172ABPW4AB4Z5A3EAW3D"],"config_entries_subentries":{"01K2QC172ABPW4AB4Z5A3EAW3D":[null]},"configuration_url":"homeassistant://hacs/repository/553010184","connections":[],"created_at":"2025-08-12T20:23:47.898433+00:00","disabled_by":null,"entry_type":"service","hw_version":null,"id":"345a8a4d0aff3290705074aa63b96dc1","identifiers":[["hacs","553010184"]],"labels":[],"manufacturer":"SpanPanel, cayossarian","model":"integration","model_id":null,"modified_at":"2025-08-15T17:30:40.697042+00:00","name_by_user":null,"name":"Span Panel","primary_config_entry":"01K2QC172ABPW4AB4Z5A3EAW3D","serial_number":null,"sw_version":null,"via_device_id":null}, - {"area_id":null,"config_entries":["01K2QC172ABPW4AB4Z5A3EAW3D"],"config_entries_subentries":{"01K2QC172ABPW4AB4Z5A3EAW3D":[null]},"configuration_url":"homeassistant://hacs/repository/665078127","connections":[],"created_at":"2025-08-12T20:34:58.267730+00:00","disabled_by":null,"entry_type":"service","hw_version":null,"id":"bf660ea2e3e81369c8a336033fbe2365","identifiers":[["hacs","665078127"]],"labels":[],"manufacturer":"arturpragacz","model":"integration","model_id":null,"modified_at":"2025-08-15T17:21:55.342715+00:00","name_by_user":null,"name":"Default Config Exclude","primary_config_entry":"01K2QC172ABPW4AB4Z5A3EAW3D","serial_number":null,"sw_version":null,"via_device_id":null}, - {"area_id":null,"config_entries":["01K2QC172ABPW4AB4Z5A3EAW3D"],"config_entries_subentries":{"01K2QC172ABPW4AB4Z5A3EAW3D":[null]},"configuration_url":"homeassistant://hacs/repository/665077422","connections":[],"created_at":"2025-08-12T20:34:07.692060+00:00","disabled_by":null,"entry_type":"service","hw_version":null,"id":"3fcfd35f39a7fd5b6d00ce3ca2fb6f90","identifiers":[["hacs","665077422"]],"labels":[],"manufacturer":"arturpragacz","model":"integration","model_id":null,"modified_at":"2025-08-15T17:21:55.342898+00:00","name_by_user":null,"name":"Early Loader","primary_config_entry":"01K2QC172ABPW4AB4Z5A3EAW3D","serial_number":null,"sw_version":null,"via_device_id":null}, - {"area_id":null,"config_entries":["01K2QCDYNAJT9RVZEFGRAZG5X6"],"config_entries_subentries":{"01K2QCDYNAJT9RVZEFGRAZG5X6":[null]},"configuration_url":"http://192.168.65.71","connections":[],"created_at":"2025-08-12T20:29:55.965191+00:00","disabled_by":null,"entry_type":null,"hw_version":null,"id":"a00dc2483acb31f18fb95e5d7a6ee3a4","identifiers":[["span_panel","nj-2316-005k6"]],"labels":[],"manufacturer":"Span","model":"Span Panel (00200)","model_id":null,"modified_at":"2025-08-16T03:49:58.541139+00:00","name_by_user":null,"name":"Span Panel","primary_config_entry":"01K2QCDYNAJT9RVZEFGRAZG5X6","serial_number":null,"sw_version":"spanos2/r202525/05","via_device_id":null} - ], - "deleted_devices": [ - {"area_id":null,"config_entries":["01K2FZ30F62NF62FJ79KJ5ZXHK"],"config_entries_subentries":{"01K2FZ30F62NF62FJ79KJ5ZXHK":[null]},"connections":[],"created_at":"2025-08-12T20:21:01.544373+00:00","disabled_by":null,"identifiers":[["sun","01K2FZ30F62NF62FJ79KJ5ZXHK"]],"id":"93880f170c481e7b6a76fdb1d006f781","labels":[],"modified_at":"2025-08-12T20:49:00.956658+00:00","name_by_user":null,"orphaned_timestamp":null}, - {"area_id":null,"config_entries":["01K2G0NYH80DCKVGCMD9AS1TTS"],"config_entries_subentries":{"01K2G0NYH80DCKVGCMD9AS1TTS":[null]},"connections":[],"created_at":"2025-08-12T20:48:50.729866+00:00","disabled_by":null,"identifiers":[["sun","01K2G0NYH80DCKVGCMD9AS1TTS"]],"id":"68813cc19c9f0689b32620fea5951106","labels":[],"modified_at":"2025-08-12T21:03:25.826122+00:00","name_by_user":null,"orphaned_timestamp":null}, - {"area_id":null,"config_entries":["01K2G1GB4DMFSVY3M5FETX1M19"],"config_entries_subentries":{"01K2G1GB4DMFSVY3M5FETX1M19":[null]},"connections":[],"created_at":"2025-08-12T21:03:15.598684+00:00","disabled_by":null,"identifiers":[["sun","01K2G1GB4DMFSVY3M5FETX1M19"]],"id":"a4cc9491df1303fde823bda19e2ddf09","labels":[],"modified_at":"2025-08-12T21:27:09.457765+00:00","name_by_user":null,"orphaned_timestamp":null}, - {"area_id":null,"config_entries":["01K2G2VSCAFTC14E0R1NPSX97N"],"config_entries_subentries":{"01K2G2VSCAFTC14E0R1NPSX97N":[null]},"connections":[],"created_at":"2025-08-12T21:26:59.212411+00:00","disabled_by":null,"identifiers":[["sun","01K2G2VSCAFTC14E0R1NPSX97N"]],"id":"ec40ca8756f77a7d5d3c60dc1dbdba08","labels":[],"modified_at":"2025-08-12T21:36:44.167660+00:00","name_by_user":null,"orphaned_timestamp":null}, - {"area_id":null,"config_entries":["01K2G3DAKZMG3KMB8ZX5Z54T3R"],"config_entries_subentries":{"01K2G3DAKZMG3KMB8ZX5Z54T3R":[null]},"connections":[],"created_at":"2025-08-12T21:36:33.921474+00:00","disabled_by":null,"identifiers":[["sun","01K2G3DAKZMG3KMB8ZX5Z54T3R"]],"id":"543d725a0dce8f8882ac863a47723715","labels":[],"modified_at":"2025-08-12T21:44:04.774194+00:00","name_by_user":null,"orphaned_timestamp":null}, - {"area_id":null,"config_entries":["01K2G3TRX0NV4DS8C09G5B4TYT"],"config_entries_subentries":{"01K2G3TRX0NV4DS8C09G5B4TYT":[null]},"connections":[],"created_at":"2025-08-12T21:43:54.531116+00:00","disabled_by":null,"identifiers":[["sun","01K2G3TRX0NV4DS8C09G5B4TYT"]],"id":"1c53f15c4af102c698e0a6a751e9dab1","labels":[],"modified_at":"2025-08-13T05:01:09.287568+00:00","name_by_user":null,"orphaned_timestamp":null}, - {"area_id":null,"config_entries":["01K2GWV2T4WST7QXWQ837WF92S"],"config_entries_subentries":{"01K2GWV2T4WST7QXWQ837WF92S":[null]},"connections":[],"created_at":"2025-08-13T05:00:59.079405+00:00","disabled_by":null,"identifiers":[["sun","01K2GWV2T4WST7QXWQ837WF92S"]],"id":"38077ecc8774a558bce7942dc371d1b3","labels":[],"modified_at":"2025-08-13T05:20:07.934300+00:00","name_by_user":null,"orphaned_timestamp":null}, - {"area_id":null,"config_entries":["01K2GXXTQT3SD7C7BPVK0P6J7W"],"config_entries_subentries":{"01K2GXXTQT3SD7C7BPVK0P6J7W":[null]},"connections":[],"created_at":"2025-08-13T05:19:57.699973+00:00","disabled_by":null,"identifiers":[["sun","01K2GXXTQT3SD7C7BPVK0P6J7W"]],"id":"8bec2ab050585eb1b60608c1674c649c","labels":[],"modified_at":"2025-08-13T05:47:39.871499+00:00","name_by_user":null,"orphaned_timestamp":null}, - {"area_id":null,"config_entries":["01K2GZG7XD3DQQSDY3SR568B10"],"config_entries_subentries":{"01K2GZG7XD3DQQSDY3SR568B10":[null]},"connections":[],"created_at":"2025-08-13T05:47:29.585960+00:00","disabled_by":null,"identifiers":[["sun","01K2GZG7XD3DQQSDY3SR568B10"]],"id":"8fb1f4d2d55ddeb8c6948a5a4d255c9a","labels":[],"modified_at":"2025-08-13T05:57:40.368496+00:00","name_by_user":null,"orphaned_timestamp":null}, - {"area_id":null,"config_entries":["01K2H02JC3PVKEJT3VG6PS4V8R"],"config_entries_subentries":{"01K2H02JC3PVKEJT3VG6PS4V8R":[null]},"connections":[],"created_at":"2025-08-13T05:57:30.116469+00:00","disabled_by":null,"identifiers":[["sun","01K2H02JC3PVKEJT3VG6PS4V8R"]],"id":"e05308c1b44259985b4d2ae0ab322568","labels":[],"modified_at":"2025-08-13T06:17:25.186694+00:00","name_by_user":null,"orphaned_timestamp":null}, - {"area_id":null,"config_entries":["01K2H16QEC8NYDACSS5HKKBK0P"],"config_entries_subentries":{"01K2H16QEC8NYDACSS5HKKBK0P":[null]},"connections":[],"created_at":"2025-08-13T06:17:14.959959+00:00","disabled_by":null,"identifiers":[["sun","01K2H16QEC8NYDACSS5HKKBK0P"]],"id":"ce855d4326fbc2a266ba375b604ea5a3","labels":[],"modified_at":"2025-08-13T06:39:26.301646+00:00","name_by_user":null,"orphaned_timestamp":null}, - {"area_id":null,"config_entries":["01K2H2F1HB23ZT9V5HFE1WFN0H"],"config_entries_subentries":{"01K2H2F1HB23ZT9V5HFE1WFN0H":[null]},"connections":[],"created_at":"2025-08-13T06:39:16.017514+00:00","disabled_by":null,"identifiers":[["sun","01K2H2F1HB23ZT9V5HFE1WFN0H"]],"id":"2a65baa50cf7015a7c17271b41860022","labels":[],"modified_at":"2025-08-13T06:50:57.044756+00:00","name_by_user":null,"orphaned_timestamp":null}, - {"area_id":null,"config_entries":["01K2H3443174B991E3DASPT0NG"],"config_entries_subentries":{"01K2H3443174B991E3DASPT0NG":[null]},"connections":[],"created_at":"2025-08-13T06:50:46.755358+00:00","disabled_by":null,"identifiers":[["sun","01K2H3443174B991E3DASPT0NG"]],"id":"e41583766bef1992877885dd1b136aaf","labels":[],"modified_at":"2025-08-13T18:33:35.794959+00:00","name_by_user":null,"orphaned_timestamp":null} - ] - } -} diff --git a/tests/migration_storage/1_0_4/core.entity_registry b/tests/migration_storage/1_0_4/core.entity_registry deleted file mode 100644 index b580b92d..00000000 --- a/tests/migration_storage/1_0_4/core.entity_registry +++ /dev/null @@ -1,522 +0,0 @@ -{ - "version": 1, - "minor_version": 18, - "key": "core.entity_registry", - "data": { - "entities": [ - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2DMG10SVYJV7JCJSP556YYM","config_subentry_id":null,"created_at":"2025-08-11T22:37:24.891397+00:00","device_class":null,"device_id":"98356fad7274b949637495af34010fe0","disabled_by":"integration","entity_category":"diagnostic","entity_id":"binary_sensor.sun_solar_rising","hidden_by":null,"icon":null,"id":"505fb32a1186895ad252ccd2831ba60e","has_entity_name":true,"labels":[],"modified_at":"2025-08-12T17:40:24.498039+00:00","name":null,"options":{},"original_device_class":null,"original_icon":null,"original_name":"Solar rising","platform":"sun","suggested_object_id":"sun_solar_rising","supported_features":0,"translation_key":"solar_rising","unique_id":"01K2DMG10SVYJV7JCJSP556YYM-solar_rising","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2DMG10SVYJV7JCJSP556YYM","config_subentry_id":null,"created_at":"2025-08-11T22:37:24.891781+00:00","device_class":null,"device_id":"98356fad7274b949637495af34010fe0","disabled_by":null,"entity_category":"diagnostic","entity_id":"sensor.sun_next_dawn","hidden_by":null,"icon":null,"id":"5344f00a484e5e7f1cf0d7781a50620a","has_entity_name":true,"labels":[],"modified_at":"2025-08-12T17:40:24.498559+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":"timestamp","original_icon":null,"original_name":"Next dawn","platform":"sun","suggested_object_id":"sun_next_dawn","supported_features":0,"translation_key":"next_dawn","unique_id":"01K2DMG10SVYJV7JCJSP556YYM-next_dawn","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2DMG10SVYJV7JCJSP556YYM","config_subentry_id":null,"created_at":"2025-08-11T22:37:24.892060+00:00","device_class":null,"device_id":"98356fad7274b949637495af34010fe0","disabled_by":null,"entity_category":"diagnostic","entity_id":"sensor.sun_next_dusk","hidden_by":null,"icon":null,"id":"5c8689cb42162ae7b078c2f5d185f5b9","has_entity_name":true,"labels":[],"modified_at":"2025-08-12T17:40:24.498779+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":"timestamp","original_icon":null,"original_name":"Next dusk","platform":"sun","suggested_object_id":"sun_next_dusk","supported_features":0,"translation_key":"next_dusk","unique_id":"01K2DMG10SVYJV7JCJSP556YYM-next_dusk","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2DMG10SVYJV7JCJSP556YYM","config_subentry_id":null,"created_at":"2025-08-11T22:37:24.892224+00:00","device_class":null,"device_id":"98356fad7274b949637495af34010fe0","disabled_by":null,"entity_category":"diagnostic","entity_id":"sensor.sun_next_midnight","hidden_by":null,"icon":null,"id":"be913918d16776a6e2936ed8bc526e15","has_entity_name":true,"labels":[],"modified_at":"2025-08-12T17:40:24.498902+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":"timestamp","original_icon":null,"original_name":"Next midnight","platform":"sun","suggested_object_id":"sun_next_midnight","supported_features":0,"translation_key":"next_midnight","unique_id":"01K2DMG10SVYJV7JCJSP556YYM-next_midnight","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2DMG10SVYJV7JCJSP556YYM","config_subentry_id":null,"created_at":"2025-08-11T22:37:24.892366+00:00","device_class":null,"device_id":"98356fad7274b949637495af34010fe0","disabled_by":null,"entity_category":"diagnostic","entity_id":"sensor.sun_next_noon","hidden_by":null,"icon":null,"id":"930477dda16282f2caba59589f6d60fd","has_entity_name":true,"labels":[],"modified_at":"2025-08-12T17:40:24.499019+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":"timestamp","original_icon":null,"original_name":"Next noon","platform":"sun","suggested_object_id":"sun_next_noon","supported_features":0,"translation_key":"next_noon","unique_id":"01K2DMG10SVYJV7JCJSP556YYM-next_noon","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2DMG10SVYJV7JCJSP556YYM","config_subentry_id":null,"created_at":"2025-08-11T22:37:24.892497+00:00","device_class":null,"device_id":"98356fad7274b949637495af34010fe0","disabled_by":null,"entity_category":"diagnostic","entity_id":"sensor.sun_next_rising","hidden_by":null,"icon":null,"id":"dcd0ad2ad1f1e698b4806639dd148b41","has_entity_name":true,"labels":[],"modified_at":"2025-08-12T17:40:24.499135+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":"timestamp","original_icon":null,"original_name":"Next rising","platform":"sun","suggested_object_id":"sun_next_rising","supported_features":0,"translation_key":"next_rising","unique_id":"01K2DMG10SVYJV7JCJSP556YYM-next_rising","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2DMG10SVYJV7JCJSP556YYM","config_subentry_id":null,"created_at":"2025-08-11T22:37:24.892625+00:00","device_class":null,"device_id":"98356fad7274b949637495af34010fe0","disabled_by":null,"entity_category":"diagnostic","entity_id":"sensor.sun_next_setting","hidden_by":null,"icon":null,"id":"de3e86c1dd89fd280d9bcb4136d694e7","has_entity_name":true,"labels":[],"modified_at":"2025-08-12T17:40:24.499246+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":"timestamp","original_icon":null,"original_name":"Next setting","platform":"sun","suggested_object_id":"sun_next_setting","supported_features":0,"translation_key":"next_setting","unique_id":"01K2DMG10SVYJV7JCJSP556YYM-next_setting","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"measurement"},"config_entry_id":"01K2DMG10SVYJV7JCJSP556YYM","config_subentry_id":null,"created_at":"2025-08-11T22:37:24.892748+00:00","device_class":null,"device_id":"98356fad7274b949637495af34010fe0","disabled_by":"integration","entity_category":"diagnostic","entity_id":"sensor.sun_solar_elevation","hidden_by":null,"icon":null,"id":"6cb4f115b37e560c1936fc2b3d09aa90","has_entity_name":true,"labels":[],"modified_at":"2025-08-12T17:40:24.499344+00:00","name":null,"options":{},"original_device_class":null,"original_icon":null,"original_name":"Solar elevation","platform":"sun","suggested_object_id":"sun_solar_elevation","supported_features":0,"translation_key":"solar_elevation","unique_id":"01K2DMG10SVYJV7JCJSP556YYM-solar_elevation","previous_unique_id":null,"unit_of_measurement":"°"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"measurement"},"config_entry_id":"01K2DMG10SVYJV7JCJSP556YYM","config_subentry_id":null,"created_at":"2025-08-11T22:37:24.892831+00:00","device_class":null,"device_id":"98356fad7274b949637495af34010fe0","disabled_by":"integration","entity_category":"diagnostic","entity_id":"sensor.sun_solar_azimuth","hidden_by":null,"icon":null,"id":"9d95d0ec0a03600aae48b6e9952dd321","has_entity_name":true,"labels":[],"modified_at":"2025-08-12T17:40:24.499403+00:00","name":null,"options":{},"original_device_class":null,"original_icon":null,"original_name":"Solar azimuth","platform":"sun","suggested_object_id":"sun_solar_azimuth","supported_features":0,"translation_key":"solar_azimuth","unique_id":"01K2DMG10SVYJV7JCJSP556YYM-solar_azimuth","previous_unique_id":null,"unit_of_measurement":"°"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2DMG10SVYJV7JCJSP556YYM","config_subentry_id":null,"created_at":"2025-08-11T22:37:24.892949+00:00","device_class":null,"device_id":"98356fad7274b949637495af34010fe0","disabled_by":"integration","entity_category":"diagnostic","entity_id":"sensor.sun_solar_rising","hidden_by":null,"icon":null,"id":"a71cb254fb1854776e0cca531026e429","has_entity_name":true,"labels":[],"modified_at":"2025-08-12T17:40:24.499462+00:00","name":null,"options":{},"original_device_class":null,"original_icon":null,"original_name":"Solar rising","platform":"sun","suggested_object_id":"sun_solar_rising","supported_features":0,"translation_key":"solar_rising","unique_id":"01K2DMG10SVYJV7JCJSP556YYM-solar_rising","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"measurement"},"config_entry_id":"01K2JBARFARWNFC92NKX1158S6","config_subentry_id":null,"created_at":"2025-08-11T22:37:27.324762+00:00","device_class":null,"device_id":"dde50258240cf5c0df0bc7a04fe78cc1","disabled_by":"integration","entity_category":null,"entity_id":"sensor.home_assistant_core_cpu_percent","hidden_by":null,"icon":null,"id":"bd542bc73fb6bd6e2fd985489c45ef1e","has_entity_name":true,"labels":[],"modified_at":"2025-08-13T18:33:27.288140+00:00","name":null,"options":{},"original_device_class":null,"original_icon":null,"original_name":"CPU percent","platform":"hassio","suggested_object_id":null,"supported_features":0,"translation_key":"cpu_percent","unique_id":"home_assistant_core_cpu_percent","previous_unique_id":null,"unit_of_measurement":"%"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"measurement"},"config_entry_id":"01K2JBARFARWNFC92NKX1158S6","config_subentry_id":null,"created_at":"2025-08-11T22:37:27.324961+00:00","device_class":null,"device_id":"dde50258240cf5c0df0bc7a04fe78cc1","disabled_by":"integration","entity_category":null,"entity_id":"sensor.home_assistant_core_memory_percent","hidden_by":null,"icon":null,"id":"6dfdab7fb83d054170986fee47019353","has_entity_name":true,"labels":[],"modified_at":"2025-08-13T18:33:27.288185+00:00","name":null,"options":{},"original_device_class":null,"original_icon":null,"original_name":"Memory percent","platform":"hassio","suggested_object_id":null,"supported_features":0,"translation_key":"memory_percent","unique_id":"home_assistant_core_memory_percent","previous_unique_id":null,"unit_of_measurement":"%"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"measurement"},"config_entry_id":"01K2JBARFARWNFC92NKX1158S6","config_subentry_id":null,"created_at":"2025-08-11T22:37:27.325055+00:00","device_class":null,"device_id":"27af8b2f4b7134648570f505ee894f7f","disabled_by":"integration","entity_category":null,"entity_id":"sensor.home_assistant_supervisor_cpu_percent","hidden_by":null,"icon":null,"id":"4c67f28c9f4bcee9e5a744c6f139c68a","has_entity_name":true,"labels":[],"modified_at":"2025-08-13T18:33:27.288235+00:00","name":null,"options":{},"original_device_class":null,"original_icon":null,"original_name":"CPU percent","platform":"hassio","suggested_object_id":null,"supported_features":0,"translation_key":"cpu_percent","unique_id":"home_assistant_supervisor_cpu_percent","previous_unique_id":null,"unit_of_measurement":"%"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"measurement"},"config_entry_id":"01K2JBARFARWNFC92NKX1158S6","config_subentry_id":null,"created_at":"2025-08-11T22:37:27.325127+00:00","device_class":null,"device_id":"27af8b2f4b7134648570f505ee894f7f","disabled_by":"integration","entity_category":null,"entity_id":"sensor.home_assistant_supervisor_memory_percent","hidden_by":null,"icon":null,"id":"2ecafcc11770edf8eb82732694de4e4c","has_entity_name":true,"labels":[],"modified_at":"2025-08-13T18:33:27.288277+00:00","name":null,"options":{},"original_device_class":null,"original_icon":null,"original_name":"Memory percent","platform":"hassio","suggested_object_id":null,"supported_features":0,"translation_key":"memory_percent","unique_id":"home_assistant_supervisor_memory_percent","previous_unique_id":null,"unit_of_measurement":"%"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2JBARFARWNFC92NKX1158S6","config_subentry_id":null,"created_at":"2025-08-11T22:37:27.325241+00:00","device_class":null,"device_id":"3621bb606c055e76c452a48d73240067","disabled_by":"integration","entity_category":"diagnostic","entity_id":"sensor.home_assistant_host_os_agent_version","hidden_by":null,"icon":null,"id":"fd96b884c479a931c0f80f5d1fd108bc","has_entity_name":true,"labels":[],"modified_at":"2025-08-13T18:33:27.288332+00:00","name":null,"options":{},"original_device_class":null,"original_icon":null,"original_name":"OS Agent version","platform":"hassio","suggested_object_id":null,"supported_features":0,"translation_key":"agent_version","unique_id":"home_assistant_host_agent_version","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2JBARFARWNFC92NKX1158S6","config_subentry_id":null,"created_at":"2025-08-11T22:37:27.325313+00:00","device_class":null,"device_id":"3621bb606c055e76c452a48d73240067","disabled_by":"integration","entity_category":"diagnostic","entity_id":"sensor.home_assistant_host_apparmor_version","hidden_by":null,"icon":null,"id":"2e4d9630a39a96d5914f42442643156a","has_entity_name":true,"labels":[],"modified_at":"2025-08-13T18:33:27.288375+00:00","name":null,"options":{},"original_device_class":null,"original_icon":null,"original_name":"AppArmor version","platform":"hassio","suggested_object_id":null,"supported_features":0,"translation_key":"apparmor_version","unique_id":"home_assistant_host_apparmor_version","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2JBARFARWNFC92NKX1158S6","config_subentry_id":null,"created_at":"2025-08-11T22:37:27.325433+00:00","device_class":null,"device_id":"3621bb606c055e76c452a48d73240067","disabled_by":"integration","entity_category":"diagnostic","entity_id":"sensor.home_assistant_host_disk_total","hidden_by":null,"icon":null,"id":"e7ee092676bfc458d2bbbe256505bc42","has_entity_name":true,"labels":[],"modified_at":"2025-08-13T18:33:27.288427+00:00","name":null,"options":{},"original_device_class":"data_size","original_icon":null,"original_name":"Disk total","platform":"hassio","suggested_object_id":null,"supported_features":0,"translation_key":"disk_total","unique_id":"home_assistant_host_disk_total","previous_unique_id":null,"unit_of_measurement":"GB"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2JBARFARWNFC92NKX1158S6","config_subentry_id":null,"created_at":"2025-08-11T22:37:27.325557+00:00","device_class":null,"device_id":"3621bb606c055e76c452a48d73240067","disabled_by":"integration","entity_category":"diagnostic","entity_id":"sensor.home_assistant_host_disk_used","hidden_by":null,"icon":null,"id":"2ea268ad20f83dcebe9879610ce40e53","has_entity_name":true,"labels":[],"modified_at":"2025-08-13T18:33:27.288474+00:00","name":null,"options":{},"original_device_class":"data_size","original_icon":null,"original_name":"Disk used","platform":"hassio","suggested_object_id":null,"supported_features":0,"translation_key":"disk_used","unique_id":"home_assistant_host_disk_used","previous_unique_id":null,"unit_of_measurement":"GB"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2JBARFARWNFC92NKX1158S6","config_subentry_id":null,"created_at":"2025-08-11T22:37:27.325686+00:00","device_class":null,"device_id":"3621bb606c055e76c452a48d73240067","disabled_by":"integration","entity_category":"diagnostic","entity_id":"sensor.home_assistant_host_disk_free","hidden_by":null,"icon":null,"id":"af5c1c80fd3178a2d341aac638915637","has_entity_name":true,"labels":[],"modified_at":"2025-08-13T18:33:27.288518+00:00","name":null,"options":{},"original_device_class":"data_size","original_icon":null,"original_name":"Disk free","platform":"hassio","suggested_object_id":null,"supported_features":0,"translation_key":"disk_free","unique_id":"home_assistant_host_disk_free","previous_unique_id":null,"unit_of_measurement":"GB"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2JBARFARWNFC92NKX1158S6","config_subentry_id":null,"created_at":"2025-08-11T22:37:27.325807+00:00","device_class":null,"device_id":"82ecbe58a0057ea8b761de3de121d194","disabled_by":"integration","entity_category":null,"entity_id":"sensor.home_assistant_operating_system_version","hidden_by":null,"icon":null,"id":"9c59166dc8fb8b25320cea1a03a18b3a","has_entity_name":true,"labels":[],"modified_at":"2025-08-13T18:33:27.288583+00:00","name":null,"options":{},"original_device_class":null,"original_icon":null,"original_name":"Version","platform":"hassio","suggested_object_id":null,"supported_features":0,"translation_key":"version","unique_id":"home_assistant_os_version","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2JBARFARWNFC92NKX1158S6","config_subentry_id":null,"created_at":"2025-08-11T22:37:27.325956+00:00","device_class":null,"device_id":"82ecbe58a0057ea8b761de3de121d194","disabled_by":"integration","entity_category":null,"entity_id":"sensor.home_assistant_operating_system_newest_version","hidden_by":null,"icon":null,"id":"b01c36a6867982e17119cfc1ce5828c9","has_entity_name":true,"labels":[],"modified_at":"2025-08-13T18:33:27.288636+00:00","name":null,"options":{},"original_device_class":null,"original_icon":null,"original_name":"Newest version","platform":"hassio","suggested_object_id":null,"supported_features":0,"translation_key":"version_latest","unique_id":"home_assistant_os_version_latest","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2JBARFARWNFC92NKX1158S6","config_subentry_id":null,"created_at":"2025-08-11T22:37:27.326928+00:00","device_class":null,"device_id":"27af8b2f4b7134648570f505ee894f7f","disabled_by":null,"entity_category":"config","entity_id":"update.home_assistant_supervisor_update","hidden_by":null,"icon":null,"id":"8222b807c5439892ed0f1c5ec3fd33ef","has_entity_name":true,"labels":[],"modified_at":"2025-08-13T18:33:27.289308+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":null,"original_icon":null,"original_name":"Update","platform":"hassio","suggested_object_id":null,"supported_features":1,"translation_key":"update","unique_id":"home_assistant_supervisor_version_latest","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2JBARFARWNFC92NKX1158S6","config_subentry_id":null,"created_at":"2025-08-11T22:37:27.327314+00:00","device_class":null,"device_id":"dde50258240cf5c0df0bc7a04fe78cc1","disabled_by":null,"entity_category":"config","entity_id":"update.home_assistant_core_update","hidden_by":null,"icon":null,"id":"4a3c281a1e96cab3b0a5604ad85df058","has_entity_name":true,"labels":[],"modified_at":"2025-08-13T18:33:27.289529+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":null,"original_icon":null,"original_name":"Update","platform":"hassio","suggested_object_id":null,"supported_features":11,"translation_key":"update","unique_id":"home_assistant_core_version_latest","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2JBARFARWNFC92NKX1158S6","config_subentry_id":null,"created_at":"2025-08-11T22:37:27.327597+00:00","device_class":null,"device_id":"82ecbe58a0057ea8b761de3de121d194","disabled_by":null,"entity_category":"config","entity_id":"update.home_assistant_operating_system_update","hidden_by":null,"icon":null,"id":"574cba0e211676652c8b24bec0b8fb4f","has_entity_name":true,"labels":[],"modified_at":"2025-08-13T18:33:27.290136+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":null,"original_icon":null,"original_name":"Update","platform":"hassio","suggested_object_id":null,"supported_features":11,"translation_key":"update","unique_id":"home_assistant_os_version_latest","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"event_types":["completed","failed","in_progress"]},"config_entry_id":"01K2JBASJA50R982BKFZB254V1","config_subentry_id":null,"created_at":"2025-08-11T22:37:29.819897+00:00","device_class":null,"device_id":"299c00a506ba6f1ff71281a172def5dd","disabled_by":null,"entity_category":null,"entity_id":"event.backup_automatic_backup","hidden_by":null,"icon":null,"id":"bc5d21b2728e00a78c1969e317e2aef1","has_entity_name":true,"labels":[],"modified_at":"2025-08-13T18:33:28.465850+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":null,"original_icon":null,"original_name":"Automatic backup","platform":"backup","suggested_object_id":null,"supported_features":0,"translation_key":"automatic_backup_event","unique_id":"automatic_backup_event","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"options":["idle","create_backup","blocked","receive_backup","restore_backup"]},"config_entry_id":"01K2JBASJA50R982BKFZB254V1","config_subentry_id":null,"created_at":"2025-08-11T22:37:29.820383+00:00","device_class":null,"device_id":"299c00a506ba6f1ff71281a172def5dd","disabled_by":null,"entity_category":null,"entity_id":"sensor.backup_backup_manager_state","hidden_by":null,"icon":null,"id":"4fe60bdd7f2fe703bd4d8a1462f69611","has_entity_name":true,"labels":[],"modified_at":"2025-08-13T18:33:28.466402+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":"enum","original_icon":null,"original_name":"Backup Manager state","platform":"backup","suggested_object_id":null,"supported_features":0,"translation_key":"backup_manager_state","unique_id":"backup_manager_state","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2JBASJA50R982BKFZB254V1","config_subentry_id":null,"created_at":"2025-08-11T22:37:29.820666+00:00","device_class":null,"device_id":"299c00a506ba6f1ff71281a172def5dd","disabled_by":null,"entity_category":null,"entity_id":"sensor.backup_next_scheduled_automatic_backup","hidden_by":null,"icon":null,"id":"a2da661cde5b861527123a48af8270df","has_entity_name":true,"labels":[],"modified_at":"2025-08-13T18:33:28.466591+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":"timestamp","original_icon":null,"original_name":"Next scheduled automatic backup","platform":"backup","suggested_object_id":null,"supported_features":0,"translation_key":"next_scheduled_automatic_backup","unique_id":"next_scheduled_automatic_backup","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2JBASJA50R982BKFZB254V1","config_subentry_id":null,"created_at":"2025-08-11T22:37:29.820855+00:00","device_class":null,"device_id":"299c00a506ba6f1ff71281a172def5dd","disabled_by":null,"entity_category":null,"entity_id":"sensor.backup_last_successful_automatic_backup","hidden_by":null,"icon":null,"id":"a64cc16b9aa254c8a78c7c8a9a824068","has_entity_name":true,"labels":[],"modified_at":"2025-08-13T18:33:28.466739+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":"timestamp","original_icon":null,"original_name":"Last successful automatic backup","platform":"backup","suggested_object_id":null,"supported_features":0,"translation_key":"last_successful_automatic_backup","unique_id":"last_successful_automatic_backup","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2JBASJA50R982BKFZB254V1","config_subentry_id":null,"created_at":"2025-08-11T22:37:29.821087+00:00","device_class":null,"device_id":"299c00a506ba6f1ff71281a172def5dd","disabled_by":null,"entity_category":null,"entity_id":"sensor.backup_last_attempted_automatic_backup","hidden_by":null,"icon":null,"id":"063919031930e81cf591487d66d2e712","has_entity_name":true,"labels":[],"modified_at":"2025-08-13T18:33:28.466882+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":"timestamp","original_icon":null,"original_name":"Last attempted automatic backup","platform":"backup","suggested_object_id":null,"supported_features":0,"translation_key":"last_attempted_automatic_backup","unique_id":"last_attempted_automatic_backup","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{},"config_entry_id":"01K2DMG5TTSC7HV00CD08JNT9B","config_subentry_id":null,"created_at":"2025-08-11T22:37:30.334260+00:00","device_class":null,"device_id":"a6cd4cd3fc304cee7f7d57571cf48c6e","disabled_by":null,"entity_category":null,"entity_id":"media_player.s95qr_129","hidden_by":null,"icon":null,"id":"aeb9764c6340d3d21d7c10dc0a1d356b","has_entity_name":true,"labels":[],"modified_at":"2025-08-12T20:05:09.813431+00:00","name":null,"options":{"conversation":{"should_expose":true}},"original_device_class":"speaker","original_icon":null,"original_name":null,"platform":"cast","suggested_object_id":null,"supported_features":131968,"translation_key":null,"unique_id":"7700e652-df2b-869e-c330-8f572703c1ef","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2JBARFARWNFC92NKX1158S6","config_subentry_id":null,"created_at":"2025-08-11T22:38:49.420644+00:00","device_class":null,"device_id":"4ca44f5be7c86da449cc8c12fecdfe2e","disabled_by":"integration","entity_category":null,"entity_id":"binary_sensor.matter_server_running","hidden_by":null,"icon":null,"id":"1d8b3073fb1ca4a8639896768ab5902e","has_entity_name":true,"labels":[],"modified_at":"2025-08-13T18:33:27.286628+00:00","name":null,"options":{},"original_device_class":"running","original_icon":null,"original_name":"Running","platform":"hassio","suggested_object_id":null,"supported_features":0,"translation_key":"state","unique_id":"core_matter_server_state","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2JBARFARWNFC92NKX1158S6","config_subentry_id":null,"created_at":"2025-08-11T22:38:49.421620+00:00","device_class":null,"device_id":"4ca44f5be7c86da449cc8c12fecdfe2e","disabled_by":"integration","entity_category":null,"entity_id":"sensor.matter_server_version","hidden_by":null,"icon":null,"id":"a47702c079127d9f84c4eb18e019b77c","has_entity_name":true,"labels":[],"modified_at":"2025-08-13T18:33:27.287246+00:00","name":null,"options":{},"original_device_class":null,"original_icon":null,"original_name":"Version","platform":"hassio","suggested_object_id":null,"supported_features":0,"translation_key":"version","unique_id":"core_matter_server_version","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2JBARFARWNFC92NKX1158S6","config_subentry_id":null,"created_at":"2025-08-11T22:38:49.421844+00:00","device_class":null,"device_id":"4ca44f5be7c86da449cc8c12fecdfe2e","disabled_by":"integration","entity_category":null,"entity_id":"sensor.matter_server_newest_version","hidden_by":null,"icon":null,"id":"1d48f1def5e63bc0585ef9f5f63c5705","has_entity_name":true,"labels":[],"modified_at":"2025-08-13T18:33:27.287319+00:00","name":null,"options":{},"original_device_class":null,"original_icon":null,"original_name":"Newest version","platform":"hassio","suggested_object_id":null,"supported_features":0,"translation_key":"version_latest","unique_id":"core_matter_server_version_latest","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"measurement"},"config_entry_id":"01K2JBARFARWNFC92NKX1158S6","config_subentry_id":null,"created_at":"2025-08-11T22:38:49.421959+00:00","device_class":null,"device_id":"4ca44f5be7c86da449cc8c12fecdfe2e","disabled_by":"integration","entity_category":null,"entity_id":"sensor.matter_server_cpu_percent","hidden_by":null,"icon":null,"id":"f706ae7eef88b84ecec8c0ed9cdfb673","has_entity_name":true,"labels":[],"modified_at":"2025-08-13T18:33:27.287373+00:00","name":null,"options":{},"original_device_class":null,"original_icon":null,"original_name":"CPU percent","platform":"hassio","suggested_object_id":null,"supported_features":0,"translation_key":"cpu_percent","unique_id":"core_matter_server_cpu_percent","previous_unique_id":null,"unit_of_measurement":"%"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"measurement"},"config_entry_id":"01K2JBARFARWNFC92NKX1158S6","config_subentry_id":null,"created_at":"2025-08-11T22:38:49.422114+00:00","device_class":null,"device_id":"4ca44f5be7c86da449cc8c12fecdfe2e","disabled_by":"integration","entity_category":null,"entity_id":"sensor.matter_server_memory_percent","hidden_by":null,"icon":null,"id":"1cd9ae5755e3a8ab27e34e8e62732454","has_entity_name":true,"labels":[],"modified_at":"2025-08-13T18:33:27.287437+00:00","name":null,"options":{},"original_device_class":null,"original_icon":null,"original_name":"Memory percent","platform":"hassio","suggested_object_id":null,"supported_features":0,"translation_key":"memory_percent","unique_id":"core_matter_server_memory_percent","previous_unique_id":null,"unit_of_measurement":"%"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2JBARFARWNFC92NKX1158S6","config_subentry_id":null,"created_at":"2025-08-11T22:38:49.424388+00:00","device_class":null,"device_id":"4ca44f5be7c86da449cc8c12fecdfe2e","disabled_by":null,"entity_category":"config","entity_id":"update.matter_server_update","hidden_by":null,"icon":null,"id":"738e2a23164974880106068fb7d7b01f","has_entity_name":true,"labels":[],"modified_at":"2025-08-13T18:33:27.289661+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":null,"original_icon":null,"original_name":"Update","platform":"hassio","suggested_object_id":null,"supported_features":25,"translation_key":"update","unique_id":"core_matter_server_version_latest","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-11T22:39:06.363902+00:00","device_class":null,"device_id":null,"disabled_by":null,"entity_category":null,"entity_id":"person.bill","hidden_by":null,"icon":null,"id":"27ce97a9b461ec77767f3958539900f8","has_entity_name":false,"labels":[],"modified_at":"2025-08-11T22:39:06.364322+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":null,"original_icon":null,"original_name":"bill","platform":"person","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"bill","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2DMK773VYDC504YGAB6SJ5N","config_subentry_id":null,"created_at":"2025-08-11T22:39:09.651714+00:00","device_class":null,"device_id":null,"disabled_by":null,"entity_category":null,"entity_id":"todo.shopping_list","hidden_by":null,"icon":null,"id":"3d020bdedd48c3a6b254cd1724e1227e","has_entity_name":true,"labels":[],"modified_at":"2025-08-11T22:39:09.651924+00:00","name":null,"options":{"conversation":{"should_expose":true}},"original_device_class":null,"original_icon":null,"original_name":"Shopping List","platform":"shopping_list","suggested_object_id":null,"supported_features":15,"translation_key":"shopping_list","unique_id":"01K2DMK773VYDC504YGAB6SJ5N","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2DMK7788KDKPFBXAPW4NG9W","config_subentry_id":null,"created_at":"2025-08-11T22:39:09.672560+00:00","device_class":null,"device_id":null,"disabled_by":null,"entity_category":null,"entity_id":"tts.google_translate_en_com","hidden_by":null,"icon":null,"id":"1be496a9596ca31bf34db7f2b4fa47f3","has_entity_name":false,"labels":[],"modified_at":"2025-08-11T22:39:09.672770+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":null,"original_icon":null,"original_name":"Google Translate en com","platform":"google_translate","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"01K2DMK7788KDKPFBXAPW4NG9W","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2JBARFARWNFC92NKX1158S6","config_subentry_id":null,"created_at":"2025-08-11T22:48:45.277581+00:00","device_class":null,"device_id":"319613801556ba7699ae5096e9ec8005","disabled_by":"integration","entity_category":null,"entity_id":"binary_sensor.get_hacs_running","hidden_by":null,"icon":null,"id":"a527527813498d5b1f5613bdc5437d04","has_entity_name":true,"labels":[],"modified_at":"2025-08-13T18:33:27.286714+00:00","name":null,"options":{},"original_device_class":"running","original_icon":null,"original_name":"Running","platform":"hassio","suggested_object_id":null,"supported_features":0,"translation_key":"state","unique_id":"cb646a50_get_state","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2JBARFARWNFC92NKX1158S6","config_subentry_id":null,"created_at":"2025-08-11T22:48:45.278400+00:00","device_class":null,"device_id":"319613801556ba7699ae5096e9ec8005","disabled_by":"integration","entity_category":null,"entity_id":"sensor.get_hacs_version","hidden_by":null,"icon":null,"id":"7a0e6fc6a227d104c77186c708f33f87","has_entity_name":true,"labels":[],"modified_at":"2025-08-13T18:33:27.287489+00:00","name":null,"options":{},"original_device_class":null,"original_icon":null,"original_name":"Version","platform":"hassio","suggested_object_id":null,"supported_features":0,"translation_key":"version","unique_id":"cb646a50_get_version","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2JBARFARWNFC92NKX1158S6","config_subentry_id":null,"created_at":"2025-08-11T22:48:45.278515+00:00","device_class":null,"device_id":"319613801556ba7699ae5096e9ec8005","disabled_by":"integration","entity_category":null,"entity_id":"sensor.get_hacs_newest_version","hidden_by":null,"icon":null,"id":"5a97ea35ea2f330732578dba80598aae","has_entity_name":true,"labels":[],"modified_at":"2025-08-13T18:33:27.287540+00:00","name":null,"options":{},"original_device_class":null,"original_icon":null,"original_name":"Newest version","platform":"hassio","suggested_object_id":null,"supported_features":0,"translation_key":"version_latest","unique_id":"cb646a50_get_version_latest","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"measurement"},"config_entry_id":"01K2JBARFARWNFC92NKX1158S6","config_subentry_id":null,"created_at":"2025-08-11T22:48:45.278589+00:00","device_class":null,"device_id":"319613801556ba7699ae5096e9ec8005","disabled_by":"integration","entity_category":null,"entity_id":"sensor.get_hacs_cpu_percent","hidden_by":null,"icon":null,"id":"c7173b1bcf5714b635e0f882ac48eebc","has_entity_name":true,"labels":[],"modified_at":"2025-08-13T18:33:27.287587+00:00","name":null,"options":{},"original_device_class":null,"original_icon":null,"original_name":"CPU percent","platform":"hassio","suggested_object_id":null,"supported_features":0,"translation_key":"cpu_percent","unique_id":"cb646a50_get_cpu_percent","previous_unique_id":null,"unit_of_measurement":"%"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"measurement"},"config_entry_id":"01K2JBARFARWNFC92NKX1158S6","config_subentry_id":null,"created_at":"2025-08-11T22:48:45.278651+00:00","device_class":null,"device_id":"319613801556ba7699ae5096e9ec8005","disabled_by":"integration","entity_category":null,"entity_id":"sensor.get_hacs_memory_percent","hidden_by":null,"icon":null,"id":"c2f1c96d08bbba69ea5ca8e731458bc1","has_entity_name":true,"labels":[],"modified_at":"2025-08-13T18:33:27.287631+00:00","name":null,"options":{},"original_device_class":null,"original_icon":null,"original_name":"Memory percent","platform":"hassio","suggested_object_id":null,"supported_features":0,"translation_key":"memory_percent","unique_id":"cb646a50_get_memory_percent","previous_unique_id":null,"unit_of_measurement":"%"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2JBARFARWNFC92NKX1158S6","config_subentry_id":null,"created_at":"2025-08-11T22:48:45.280735+00:00","device_class":null,"device_id":"319613801556ba7699ae5096e9ec8005","disabled_by":null,"entity_category":"config","entity_id":"update.get_hacs_update","hidden_by":null,"icon":null,"id":"c388d195dc6b88db915ddca67825abe0","has_entity_name":true,"labels":[],"modified_at":"2025-08-13T18:33:27.289788+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":null,"original_icon":null,"original_name":"Update","platform":"hassio","suggested_object_id":null,"supported_features":25,"translation_key":"update","unique_id":"cb646a50_get_version_latest","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2JBARFARWNFC92NKX1158S6","config_subentry_id":null,"created_at":"2025-08-12T00:01:09.002499+00:00","device_class":null,"device_id":"ab3731f6cd9cc377acd97036169f3f22","disabled_by":"integration","entity_category":null,"entity_id":"binary_sensor.advanced_ssh_web_terminal_running","hidden_by":null,"icon":null,"id":"dc49b76fa1cb20c9024e24d741f9c18f","has_entity_name":true,"labels":[],"modified_at":"2025-08-13T18:33:27.286762+00:00","name":null,"options":{},"original_device_class":"running","original_icon":null,"original_name":"Running","platform":"hassio","suggested_object_id":null,"supported_features":0,"translation_key":"state","unique_id":"a0d7b954_ssh_state","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2JBARFARWNFC92NKX1158S6","config_subentry_id":null,"created_at":"2025-08-12T00:01:09.003452+00:00","device_class":null,"device_id":"ab3731f6cd9cc377acd97036169f3f22","disabled_by":"integration","entity_category":null,"entity_id":"sensor.advanced_ssh_web_terminal_version","hidden_by":null,"icon":null,"id":"4dc48de24e208e0f0d2f94e8392a500a","has_entity_name":true,"labels":[],"modified_at":"2025-08-13T18:33:27.287678+00:00","name":null,"options":{},"original_device_class":null,"original_icon":null,"original_name":"Version","platform":"hassio","suggested_object_id":null,"supported_features":0,"translation_key":"version","unique_id":"a0d7b954_ssh_version","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2JBARFARWNFC92NKX1158S6","config_subentry_id":null,"created_at":"2025-08-12T00:01:09.003623+00:00","device_class":null,"device_id":"ab3731f6cd9cc377acd97036169f3f22","disabled_by":"integration","entity_category":null,"entity_id":"sensor.advanced_ssh_web_terminal_newest_version","hidden_by":null,"icon":null,"id":"93983cbb2852e36bc7ec51f79d1d6434","has_entity_name":true,"labels":[],"modified_at":"2025-08-13T18:33:27.287739+00:00","name":null,"options":{},"original_device_class":null,"original_icon":null,"original_name":"Newest version","platform":"hassio","suggested_object_id":null,"supported_features":0,"translation_key":"version_latest","unique_id":"a0d7b954_ssh_version_latest","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"measurement"},"config_entry_id":"01K2JBARFARWNFC92NKX1158S6","config_subentry_id":null,"created_at":"2025-08-12T00:01:09.003743+00:00","device_class":null,"device_id":"ab3731f6cd9cc377acd97036169f3f22","disabled_by":"integration","entity_category":null,"entity_id":"sensor.advanced_ssh_web_terminal_cpu_percent","hidden_by":null,"icon":null,"id":"e94e7cbb4a28f4aa3c070fdbf17cd47b","has_entity_name":true,"labels":[],"modified_at":"2025-08-13T18:33:27.287860+00:00","name":null,"options":{},"original_device_class":null,"original_icon":null,"original_name":"CPU percent","platform":"hassio","suggested_object_id":null,"supported_features":0,"translation_key":"cpu_percent","unique_id":"a0d7b954_ssh_cpu_percent","previous_unique_id":null,"unit_of_measurement":"%"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"measurement"},"config_entry_id":"01K2JBARFARWNFC92NKX1158S6","config_subentry_id":null,"created_at":"2025-08-12T00:01:09.003857+00:00","device_class":null,"device_id":"ab3731f6cd9cc377acd97036169f3f22","disabled_by":"integration","entity_category":null,"entity_id":"sensor.advanced_ssh_web_terminal_memory_percent","hidden_by":null,"icon":null,"id":"c3645e40c43ca58b2490baab89bf0725","has_entity_name":true,"labels":[],"modified_at":"2025-08-13T18:33:27.287909+00:00","name":null,"options":{},"original_device_class":null,"original_icon":null,"original_name":"Memory percent","platform":"hassio","suggested_object_id":null,"supported_features":0,"translation_key":"memory_percent","unique_id":"a0d7b954_ssh_memory_percent","previous_unique_id":null,"unit_of_measurement":"%"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2JBARFARWNFC92NKX1158S6","config_subentry_id":null,"created_at":"2025-08-12T00:01:09.005170+00:00","device_class":null,"device_id":"ab3731f6cd9cc377acd97036169f3f22","disabled_by":null,"entity_category":"config","entity_id":"update.advanced_ssh_web_terminal_update","hidden_by":null,"icon":null,"id":"305bad9b0a37bd2926e5b21233b2ad98","has_entity_name":true,"labels":[],"modified_at":"2025-08-13T18:33:27.289909+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":null,"original_icon":null,"original_name":"Update","platform":"hassio","suggested_object_id":null,"supported_features":25,"translation_key":"update","unique_id":"a0d7b954_ssh_version_latest","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2JBARFARWNFC92NKX1158S6","config_subentry_id":null,"created_at":"2025-08-12T00:21:08.870379+00:00","device_class":null,"device_id":"501eb9aa456c4652973565fb732a3b4e","disabled_by":"integration","entity_category":null,"entity_id":"binary_sensor.samba_share_running","hidden_by":null,"icon":null,"id":"84ccdd5d9d5e17ab55136b561e344202","has_entity_name":true,"labels":[],"modified_at":"2025-08-13T18:33:27.286823+00:00","name":null,"options":{},"original_device_class":"running","original_icon":null,"original_name":"Running","platform":"hassio","suggested_object_id":null,"supported_features":0,"translation_key":"state","unique_id":"core_samba_state","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2JBARFARWNFC92NKX1158S6","config_subentry_id":null,"created_at":"2025-08-12T00:21:08.871101+00:00","device_class":null,"device_id":"501eb9aa456c4652973565fb732a3b4e","disabled_by":"integration","entity_category":null,"entity_id":"sensor.samba_share_version","hidden_by":null,"icon":null,"id":"e6d8ad0c91a71d9818d89a03411281d1","has_entity_name":true,"labels":[],"modified_at":"2025-08-13T18:33:27.287953+00:00","name":null,"options":{},"original_device_class":null,"original_icon":null,"original_name":"Version","platform":"hassio","suggested_object_id":null,"supported_features":0,"translation_key":"version","unique_id":"core_samba_version","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2JBARFARWNFC92NKX1158S6","config_subentry_id":null,"created_at":"2025-08-12T00:21:08.871214+00:00","device_class":null,"device_id":"501eb9aa456c4652973565fb732a3b4e","disabled_by":"integration","entity_category":null,"entity_id":"sensor.samba_share_newest_version","hidden_by":null,"icon":null,"id":"3456c08c5547e1458231782e634d4598","has_entity_name":true,"labels":[],"modified_at":"2025-08-13T18:33:27.288001+00:00","name":null,"options":{},"original_device_class":null,"original_icon":null,"original_name":"Newest version","platform":"hassio","suggested_object_id":null,"supported_features":0,"translation_key":"version_latest","unique_id":"core_samba_version_latest","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"measurement"},"config_entry_id":"01K2JBARFARWNFC92NKX1158S6","config_subentry_id":null,"created_at":"2025-08-12T00:21:08.871288+00:00","device_class":null,"device_id":"501eb9aa456c4652973565fb732a3b4e","disabled_by":"integration","entity_category":null,"entity_id":"sensor.samba_share_cpu_percent","hidden_by":null,"icon":null,"id":"6b913bf86c8b36e365d59ae87281ba36","has_entity_name":true,"labels":[],"modified_at":"2025-08-13T18:33:27.288043+00:00","name":null,"options":{},"original_device_class":null,"original_icon":null,"original_name":"CPU percent","platform":"hassio","suggested_object_id":null,"supported_features":0,"translation_key":"cpu_percent","unique_id":"core_samba_cpu_percent","previous_unique_id":null,"unit_of_measurement":"%"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"measurement"},"config_entry_id":"01K2JBARFARWNFC92NKX1158S6","config_subentry_id":null,"created_at":"2025-08-12T00:21:08.871358+00:00","device_class":null,"device_id":"501eb9aa456c4652973565fb732a3b4e","disabled_by":"integration","entity_category":null,"entity_id":"sensor.samba_share_memory_percent","hidden_by":null,"icon":null,"id":"851556bbce1066ff8c8e477d7815204b","has_entity_name":true,"labels":[],"modified_at":"2025-08-13T18:33:27.288086+00:00","name":null,"options":{},"original_device_class":null,"original_icon":null,"original_name":"Memory percent","platform":"hassio","suggested_object_id":null,"supported_features":0,"translation_key":"memory_percent","unique_id":"core_samba_memory_percent","previous_unique_id":null,"unit_of_measurement":"%"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2JBARFARWNFC92NKX1158S6","config_subentry_id":null,"created_at":"2025-08-12T00:21:08.872505+00:00","device_class":null,"device_id":"501eb9aa456c4652973565fb732a3b4e","disabled_by":null,"entity_category":"config","entity_id":"update.samba_share_update","hidden_by":null,"icon":null,"id":"552e94d0827335a3488721e65888f85f","has_entity_name":true,"labels":[],"modified_at":"2025-08-13T18:33:27.290031+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":null,"original_icon":null,"original_name":"Update","platform":"hassio","suggested_object_id":null,"supported_features":25,"translation_key":"update","unique_id":"core_samba_version_latest","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2FY66Y1AN0G099D140MFDVM","config_subentry_id":null,"created_at":"2025-08-12T20:05:17.890797+00:00","device_class":null,"device_id":"469a3227d19a5115c26f30ed1e3388bb","disabled_by":"integration","entity_category":"diagnostic","entity_id":"binary_sensor.sun_solar_rising_2","hidden_by":null,"icon":null,"id":"87e0d716949b33a12d360be20db63981","has_entity_name":true,"labels":[],"modified_at":"2025-08-12T20:05:17.890855+00:00","name":null,"options":{},"original_device_class":null,"original_icon":null,"original_name":"Solar rising","platform":"sun","suggested_object_id":"sun_solar_rising","supported_features":0,"translation_key":"solar_rising","unique_id":"01K2FY66Y1AN0G099D140MFDVM-solar_rising","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2FY66Y1AN0G099D140MFDVM","config_subentry_id":null,"created_at":"2025-08-12T20:05:17.891144+00:00","device_class":null,"device_id":"469a3227d19a5115c26f30ed1e3388bb","disabled_by":null,"entity_category":"diagnostic","entity_id":"sensor.sun_next_dawn_2","hidden_by":null,"icon":null,"id":"9fe45ec0c31c55950ba7088bcea086fa","has_entity_name":true,"labels":[],"modified_at":"2025-08-12T20:05:47.904946+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":"timestamp","original_icon":null,"original_name":"Next dawn","platform":"sun","suggested_object_id":"sun_next_dawn","supported_features":0,"translation_key":"next_dawn","unique_id":"01K2FY66Y1AN0G099D140MFDVM-next_dawn","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2FY66Y1AN0G099D140MFDVM","config_subentry_id":null,"created_at":"2025-08-12T20:05:17.891704+00:00","device_class":null,"device_id":"469a3227d19a5115c26f30ed1e3388bb","disabled_by":null,"entity_category":"diagnostic","entity_id":"sensor.sun_next_dusk_2","hidden_by":null,"icon":null,"id":"db43f2363e79c1a8d3ca61cee45bcf20","has_entity_name":true,"labels":[],"modified_at":"2025-08-12T20:05:47.905205+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":"timestamp","original_icon":null,"original_name":"Next dusk","platform":"sun","suggested_object_id":"sun_next_dusk","supported_features":0,"translation_key":"next_dusk","unique_id":"01K2FY66Y1AN0G099D140MFDVM-next_dusk","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2FY66Y1AN0G099D140MFDVM","config_subentry_id":null,"created_at":"2025-08-12T20:05:17.891913+00:00","device_class":null,"device_id":"469a3227d19a5115c26f30ed1e3388bb","disabled_by":null,"entity_category":"diagnostic","entity_id":"sensor.sun_next_midnight_2","hidden_by":null,"icon":null,"id":"c94565bfd6e5282cb736f333e10843bd","has_entity_name":true,"labels":[],"modified_at":"2025-08-12T20:05:47.905283+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":"timestamp","original_icon":null,"original_name":"Next midnight","platform":"sun","suggested_object_id":"sun_next_midnight","supported_features":0,"translation_key":"next_midnight","unique_id":"01K2FY66Y1AN0G099D140MFDVM-next_midnight","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2FY66Y1AN0G099D140MFDVM","config_subentry_id":null,"created_at":"2025-08-12T20:05:17.892058+00:00","device_class":null,"device_id":"469a3227d19a5115c26f30ed1e3388bb","disabled_by":null,"entity_category":"diagnostic","entity_id":"sensor.sun_next_noon_2","hidden_by":null,"icon":null,"id":"584146f49e44f99dc6ade0c4b1d446f5","has_entity_name":true,"labels":[],"modified_at":"2025-08-12T20:05:47.905349+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":"timestamp","original_icon":null,"original_name":"Next noon","platform":"sun","suggested_object_id":"sun_next_noon","supported_features":0,"translation_key":"next_noon","unique_id":"01K2FY66Y1AN0G099D140MFDVM-next_noon","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2FY66Y1AN0G099D140MFDVM","config_subentry_id":null,"created_at":"2025-08-12T20:05:17.892176+00:00","device_class":null,"device_id":"469a3227d19a5115c26f30ed1e3388bb","disabled_by":null,"entity_category":"diagnostic","entity_id":"sensor.sun_next_rising_2","hidden_by":null,"icon":null,"id":"690d7b650d5641aa570cf88d0a2e3d95","has_entity_name":true,"labels":[],"modified_at":"2025-08-12T20:05:47.905404+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":"timestamp","original_icon":null,"original_name":"Next rising","platform":"sun","suggested_object_id":"sun_next_rising","supported_features":0,"translation_key":"next_rising","unique_id":"01K2FY66Y1AN0G099D140MFDVM-next_rising","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2FY66Y1AN0G099D140MFDVM","config_subentry_id":null,"created_at":"2025-08-12T20:05:17.892420+00:00","device_class":null,"device_id":"469a3227d19a5115c26f30ed1e3388bb","disabled_by":null,"entity_category":"diagnostic","entity_id":"sensor.sun_next_setting_2","hidden_by":null,"icon":null,"id":"eb5eca780da1bbaa73056658fc7180ad","has_entity_name":true,"labels":[],"modified_at":"2025-08-12T20:05:47.905455+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":"timestamp","original_icon":null,"original_name":"Next setting","platform":"sun","suggested_object_id":"sun_next_setting","supported_features":0,"translation_key":"next_setting","unique_id":"01K2FY66Y1AN0G099D140MFDVM-next_setting","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"measurement"},"config_entry_id":"01K2FY66Y1AN0G099D140MFDVM","config_subentry_id":null,"created_at":"2025-08-12T20:05:17.892591+00:00","device_class":null,"device_id":"469a3227d19a5115c26f30ed1e3388bb","disabled_by":"integration","entity_category":"diagnostic","entity_id":"sensor.sun_solar_elevation_2","hidden_by":null,"icon":null,"id":"160ec769f531c511c3bc3f2f607b0301","has_entity_name":true,"labels":[],"modified_at":"2025-08-12T20:05:17.892615+00:00","name":null,"options":{},"original_device_class":null,"original_icon":null,"original_name":"Solar elevation","platform":"sun","suggested_object_id":"sun_solar_elevation","supported_features":0,"translation_key":"solar_elevation","unique_id":"01K2FY66Y1AN0G099D140MFDVM-solar_elevation","previous_unique_id":null,"unit_of_measurement":"°"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"measurement"},"config_entry_id":"01K2FY66Y1AN0G099D140MFDVM","config_subentry_id":null,"created_at":"2025-08-12T20:05:17.892666+00:00","device_class":null,"device_id":"469a3227d19a5115c26f30ed1e3388bb","disabled_by":"integration","entity_category":"diagnostic","entity_id":"sensor.sun_solar_azimuth_2","hidden_by":null,"icon":null,"id":"5b37d92565b4fefa05d1e758b5be9ce8","has_entity_name":true,"labels":[],"modified_at":"2025-08-12T20:05:17.892686+00:00","name":null,"options":{},"original_device_class":null,"original_icon":null,"original_name":"Solar azimuth","platform":"sun","suggested_object_id":"sun_solar_azimuth","supported_features":0,"translation_key":"solar_azimuth","unique_id":"01K2FY66Y1AN0G099D140MFDVM-solar_azimuth","previous_unique_id":null,"unit_of_measurement":"°"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2FY66Y1AN0G099D140MFDVM","config_subentry_id":null,"created_at":"2025-08-12T20:05:17.892727+00:00","device_class":null,"device_id":"469a3227d19a5115c26f30ed1e3388bb","disabled_by":"integration","entity_category":"diagnostic","entity_id":"sensor.sun_solar_rising_2","hidden_by":null,"icon":null,"id":"6682744b2a19aaff207ece777a42ae85","has_entity_name":true,"labels":[],"modified_at":"2025-08-12T20:05:17.892745+00:00","name":null,"options":{},"original_device_class":null,"original_icon":null,"original_name":"Solar rising","platform":"sun","suggested_object_id":"sun_solar_rising","supported_features":0,"translation_key":"solar_rising","unique_id":"01K2FY66Y1AN0G099D140MFDVM-solar_rising","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2JBAPRJGMHHG5E8GW97HFKG","config_subentry_id":null,"created_at":"2025-08-13T18:33:25.526099+00:00","device_class":null,"device_id":"fe6a0e95694074756aef7b0d8db21451","disabled_by":"integration","entity_category":"diagnostic","entity_id":"binary_sensor.sun_solar_rising_4","hidden_by":null,"icon":null,"id":"50aa231f10ed46a212be8c259dfdb439","has_entity_name":true,"labels":[],"modified_at":"2025-08-13T18:33:25.526161+00:00","name":null,"options":{},"original_device_class":null,"original_icon":null,"original_name":"Solar rising","platform":"sun","suggested_object_id":"sun_solar_rising","supported_features":0,"translation_key":"solar_rising","unique_id":"01K2JBAPRJGMHHG5E8GW97HFKG-solar_rising","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2JBAPRJGMHHG5E8GW97HFKG","config_subentry_id":null,"created_at":"2025-08-13T18:33:25.526902+00:00","device_class":null,"device_id":"fe6a0e95694074756aef7b0d8db21451","disabled_by":null,"entity_category":"diagnostic","entity_id":"sensor.sun_next_dawn_4","hidden_by":null,"icon":null,"id":"38563389f203e5bf950709a48cf9b20b","has_entity_name":true,"labels":[],"modified_at":"2025-08-13T18:33:25.795585+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":"timestamp","original_icon":null,"original_name":"Next dawn","platform":"sun","suggested_object_id":"sun_next_dawn","supported_features":0,"translation_key":"next_dawn","unique_id":"01K2JBAPRJGMHHG5E8GW97HFKG-next_dawn","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2JBAPRJGMHHG5E8GW97HFKG","config_subentry_id":null,"created_at":"2025-08-13T18:33:25.527212+00:00","device_class":null,"device_id":"fe6a0e95694074756aef7b0d8db21451","disabled_by":null,"entity_category":"diagnostic","entity_id":"sensor.sun_next_dusk_4","hidden_by":null,"icon":null,"id":"e4506f669d35a6e69f650e200fcaec0e","has_entity_name":true,"labels":[],"modified_at":"2025-08-13T18:33:25.795754+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":"timestamp","original_icon":null,"original_name":"Next dusk","platform":"sun","suggested_object_id":"sun_next_dusk","supported_features":0,"translation_key":"next_dusk","unique_id":"01K2JBAPRJGMHHG5E8GW97HFKG-next_dusk","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2JBAPRJGMHHG5E8GW97HFKG","config_subentry_id":null,"created_at":"2025-08-13T18:33:25.527364+00:00","device_class":null,"device_id":"fe6a0e95694074756aef7b0d8db21451","disabled_by":null,"entity_category":"diagnostic","entity_id":"sensor.sun_next_midnight_4","hidden_by":null,"icon":null,"id":"e97f8a266e30bc1a0419f9b479b980d2","has_entity_name":true,"labels":[],"modified_at":"2025-08-13T18:33:25.795821+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":"timestamp","original_icon":null,"original_name":"Next midnight","platform":"sun","suggested_object_id":"sun_next_midnight","supported_features":0,"translation_key":"next_midnight","unique_id":"01K2JBAPRJGMHHG5E8GW97HFKG-next_midnight","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2JBAPRJGMHHG5E8GW97HFKG","config_subentry_id":null,"created_at":"2025-08-13T18:33:25.527494+00:00","device_class":null,"device_id":"fe6a0e95694074756aef7b0d8db21451","disabled_by":null,"entity_category":"diagnostic","entity_id":"sensor.sun_next_noon_4","hidden_by":null,"icon":null,"id":"b3ee63a21bf1d27f1aff298c83c0cc84","has_entity_name":true,"labels":[],"modified_at":"2025-08-13T18:33:25.795870+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":"timestamp","original_icon":null,"original_name":"Next noon","platform":"sun","suggested_object_id":"sun_next_noon","supported_features":0,"translation_key":"next_noon","unique_id":"01K2JBAPRJGMHHG5E8GW97HFKG-next_noon","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2JBAPRJGMHHG5E8GW97HFKG","config_subentry_id":null,"created_at":"2025-08-13T18:33:25.527614+00:00","device_class":null,"device_id":"fe6a0e95694074756aef7b0d8db21451","disabled_by":null,"entity_category":"diagnostic","entity_id":"sensor.sun_next_rising_4","hidden_by":null,"icon":null,"id":"f87603d4463566013bbc9061be6c0295","has_entity_name":true,"labels":[],"modified_at":"2025-08-13T18:33:25.795914+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":"timestamp","original_icon":null,"original_name":"Next rising","platform":"sun","suggested_object_id":"sun_next_rising","supported_features":0,"translation_key":"next_rising","unique_id":"01K2JBAPRJGMHHG5E8GW97HFKG-next_rising","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2JBAPRJGMHHG5E8GW97HFKG","config_subentry_id":null,"created_at":"2025-08-13T18:33:25.527725+00:00","device_class":null,"device_id":"fe6a0e95694074756aef7b0d8db21451","disabled_by":null,"entity_category":"diagnostic","entity_id":"sensor.sun_next_setting_4","hidden_by":null,"icon":null,"id":"f170bd71fe262549a526c7ed49db2876","has_entity_name":true,"labels":[],"modified_at":"2025-08-13T18:33:25.795955+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":"timestamp","original_icon":null,"original_name":"Next setting","platform":"sun","suggested_object_id":"sun_next_setting","supported_features":0,"translation_key":"next_setting","unique_id":"01K2JBAPRJGMHHG5E8GW97HFKG-next_setting","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"measurement"},"config_entry_id":"01K2JBAPRJGMHHG5E8GW97HFKG","config_subentry_id":null,"created_at":"2025-08-13T18:33:25.527851+00:00","device_class":null,"device_id":"fe6a0e95694074756aef7b0d8db21451","disabled_by":"integration","entity_category":"diagnostic","entity_id":"sensor.sun_solar_elevation_4","hidden_by":null,"icon":null,"id":"e7804664a543e2553357f7dee3b71eb8","has_entity_name":true,"labels":[],"modified_at":"2025-08-13T18:33:25.527879+00:00","name":null,"options":{},"original_device_class":null,"original_icon":null,"original_name":"Solar elevation","platform":"sun","suggested_object_id":"sun_solar_elevation","supported_features":0,"translation_key":"solar_elevation","unique_id":"01K2JBAPRJGMHHG5E8GW97HFKG-solar_elevation","previous_unique_id":null,"unit_of_measurement":"°"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"measurement"},"config_entry_id":"01K2JBAPRJGMHHG5E8GW97HFKG","config_subentry_id":null,"created_at":"2025-08-13T18:33:25.527966+00:00","device_class":null,"device_id":"fe6a0e95694074756aef7b0d8db21451","disabled_by":"integration","entity_category":"diagnostic","entity_id":"sensor.sun_solar_azimuth_4","hidden_by":null,"icon":null,"id":"949cfea72f2460c58e7e7362be9f1e31","has_entity_name":true,"labels":[],"modified_at":"2025-08-13T18:33:25.527986+00:00","name":null,"options":{},"original_device_class":null,"original_icon":null,"original_name":"Solar azimuth","platform":"sun","suggested_object_id":"sun_solar_azimuth","supported_features":0,"translation_key":"solar_azimuth","unique_id":"01K2JBAPRJGMHHG5E8GW97HFKG-solar_azimuth","previous_unique_id":null,"unit_of_measurement":"°"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2JBAPRJGMHHG5E8GW97HFKG","config_subentry_id":null,"created_at":"2025-08-13T18:33:25.528056+00:00","device_class":null,"device_id":"fe6a0e95694074756aef7b0d8db21451","disabled_by":"integration","entity_category":"diagnostic","entity_id":"sensor.sun_solar_rising_4","hidden_by":null,"icon":null,"id":"4d3693e27ef879cf450a6150638dc62b","has_entity_name":true,"labels":[],"modified_at":"2025-08-13T18:33:25.528075+00:00","name":null,"options":{},"original_device_class":null,"original_icon":null,"original_name":"Solar rising","platform":"sun","suggested_object_id":"sun_solar_rising","supported_features":0,"translation_key":"solar_rising","unique_id":"01K2JBAPRJGMHHG5E8GW97HFKG-solar_rising","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2QC172ABPW4AB4Z5A3EAW3D","config_subentry_id":null,"created_at":"2025-08-11T22:55:57.265048+00:00","device_class":null,"device_id":"8badde1dee15f240cb614586522bd66f","disabled_by":"integration","entity_category":"diagnostic","entity_id":"switch.hacs_pre_release","hidden_by":null,"icon":null,"id":"b858b5346eb418fe7917ef53cba0fc8b","has_entity_name":true,"labels":[],"modified_at":"2025-08-15T17:21:55.342283+00:00","name":null,"options":{},"original_device_class":null,"original_icon":null,"original_name":"Pre-release","platform":"hacs","suggested_object_id":null,"supported_features":0,"translation_key":"pre-release","unique_id":"172733314","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2QC172ABPW4AB4Z5A3EAW3D","config_subentry_id":null,"created_at":"2025-08-11T22:55:57.265326+00:00","device_class":null,"device_id":"345a8a4d0aff3290705074aa63b96dc1","disabled_by":"integration","entity_category":"diagnostic","entity_id":"switch.span_panel_pre_release","hidden_by":null,"icon":null,"id":"91c1d18f86ffbf9e4e744a9d4f15cf27","has_entity_name":true,"labels":[],"modified_at":"2025-08-15T17:21:55.342607+00:00","name":null,"options":{},"original_device_class":null,"original_icon":null,"original_name":"Pre-release","platform":"hacs","suggested_object_id":null,"supported_features":0,"translation_key":"pre-release","unique_id":"553010184","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2QC172ABPW4AB4Z5A3EAW3D","config_subentry_id":null,"created_at":"2025-08-12T20:35:50.284487+00:00","device_class":null,"device_id":"bf660ea2e3e81369c8a336033fbe2365","disabled_by":"integration","entity_category":"diagnostic","entity_id":"switch.default_config_exclude_pre_release","hidden_by":null,"icon":null,"id":"eb9148390d2d0c52a62f89e760af9611","has_entity_name":true,"labels":[],"modified_at":"2025-08-15T17:21:55.342792+00:00","name":null,"options":{},"original_device_class":null,"original_icon":null,"original_name":"Pre-release","platform":"hacs","suggested_object_id":null,"supported_features":0,"translation_key":"pre-release","unique_id":"665078127","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2QC172ABPW4AB4Z5A3EAW3D","config_subentry_id":null,"created_at":"2025-08-12T20:35:50.284344+00:00","device_class":null,"device_id":"3fcfd35f39a7fd5b6d00ce3ca2fb6f90","disabled_by":"integration","entity_category":"diagnostic","entity_id":"switch.early_loader_pre_release","hidden_by":null,"icon":null,"id":"ea07a3dac5c7bac459ea172e7c95d52d","has_entity_name":true,"labels":[],"modified_at":"2025-08-15T17:21:55.342953+00:00","name":null,"options":{},"original_device_class":null,"original_icon":null,"original_name":"Pre-release","platform":"hacs","suggested_object_id":null,"supported_features":0,"translation_key":"pre-release","unique_id":"665077422","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2QC172ABPW4AB4Z5A3EAW3D","config_subentry_id":null,"created_at":"2025-08-11T22:54:55.550648+00:00","device_class":null,"device_id":"8badde1dee15f240cb614586522bd66f","disabled_by":null,"entity_category":"config","entity_id":"update.hacs_update","hidden_by":null,"icon":null,"id":"5d771397e0b4451b1e08200189d03408","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:21:55.343982+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":null,"original_icon":null,"original_name":"HACS update","platform":"hacs","suggested_object_id":null,"supported_features":23,"translation_key":null,"unique_id":"172733314","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2QC172ABPW4AB4Z5A3EAW3D","config_subentry_id":null,"created_at":"2025-08-11T22:55:36.175734+00:00","device_class":null,"device_id":"345a8a4d0aff3290705074aa63b96dc1","disabled_by":null,"entity_category":"config","entity_id":"update.span_panel_update","hidden_by":null,"icon":null,"id":"4eb77fcfe2c789dc25da7072132cd409","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:21:55.344373+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":null,"original_icon":null,"original_name":"Span Panel update","platform":"hacs","suggested_object_id":null,"supported_features":23,"translation_key":null,"unique_id":"553010184","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2QC172ABPW4AB4Z5A3EAW3D","config_subentry_id":null,"created_at":"2025-08-12T20:34:58.267895+00:00","device_class":null,"device_id":"bf660ea2e3e81369c8a336033fbe2365","disabled_by":null,"entity_category":"config","entity_id":"update.default_config_exclude_update","hidden_by":null,"icon":null,"id":"285e2c9a7d6f391f87818e41b5388316","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:21:55.344552+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":null,"original_icon":null,"original_name":"Default Config Exclude update","platform":"hacs","suggested_object_id":null,"supported_features":23,"translation_key":null,"unique_id":"665078127","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2QC172ABPW4AB4Z5A3EAW3D","config_subentry_id":null,"created_at":"2025-08-12T20:34:07.692226+00:00","device_class":null,"device_id":"3fcfd35f39a7fd5b6d00ce3ca2fb6f90","disabled_by":null,"entity_category":"config","entity_id":"update.early_loader_update","hidden_by":null,"icon":null,"id":"e9f5b6d2e591d27b2cd8bf0642c7958d","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:21:55.344688+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":null,"original_icon":null,"original_name":"Early Loader update","platform":"hacs","suggested_object_id":null,"supported_features":23,"translation_key":null,"unique_id":"665077422","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-11T22:56:33.787324+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"binary_sensor.span_panel_door_state","hidden_by":null,"icon":null,"id":"ae06b3c5b9f5fb4c995056f80cc50ebe","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.326954+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":"tamper","original_icon":null,"original_name":"Door State","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_doorState","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-11T22:56:33.787679+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"binary_sensor.span_panel_ethernet_link","hidden_by":null,"icon":null,"id":"6c267cef4c337d848c9f42066801f2ec","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.327317+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":"connectivity","original_icon":null,"original_name":"Ethernet Link","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_eth0Link","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-11T22:56:33.787922+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"binary_sensor.span_panel_wi_fi_link","hidden_by":null,"icon":null,"id":"7b41bd7118fe9e92131cebffac76b7aa","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.327512+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":"connectivity","original_icon":null,"original_name":"Wi-Fi Link","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_wlanLink","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-11T22:56:33.790397+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"binary_sensor.span_panel_cellular_link","hidden_by":null,"icon":null,"id":"e7077f65e323c58a2d30f99f0efce90f","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.327667+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":"connectivity","original_icon":null,"original_name":"Cellular Link","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_wwanLink","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"options":["Must Have","Nice To Have","Non-Essential"]},"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-11T22:56:33.790991+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"select.span_panel_lights_dining_room_priority","hidden_by":null,"icon":null,"id":"8f414dbb1b82831edd33f7c0dc2c6cef","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.330984+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":null,"original_icon":null,"original_name":"Lights Dining Room Circuit Priority","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_select_0dad2f16cd514812ae1807b0457d473e","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"options":["Must Have","Nice To Have","Non-Essential"]},"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-11T22:56:33.791443+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"select.span_panel_lights_outlets_bedroom_priority","hidden_by":null,"icon":null,"id":"dff271a7420cc427b67760ebb1430336","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.333082+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":null,"original_icon":null,"original_name":"Lights-Outlets Bedroom Circuit Priority","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_select_11a47a0f69d54e12b7200f730c2ffda1","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"options":["Must Have","Nice To Have","Non-Essential"]},"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-11T22:56:33.791711+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"select.span_panel_dining_room_wine_fridge_priority","hidden_by":null,"icon":null,"id":"4912a9322aa664f2a112007672890f92","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.334335+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":null,"original_icon":null,"original_name":"Dining Room Wine Fridge Circuit Priority","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_select_12ce227695cd44338864b0ef2ec4168b","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"options":["Must Have","Nice To Have","Non-Essential"]},"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-11T22:56:33.792090+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"select.span_panel_dryer_priority","hidden_by":null,"icon":null,"id":"6a7172a7a2e4d5edc3bf9cff48d82eba","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.335445+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":null,"original_icon":null,"original_name":"Dryer Circuit Priority","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_select_1ad73b0eb44e4022bb6270c76baed0c1","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"options":["Must Have","Nice To Have","Non-Essential"]},"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-11T22:56:33.792239+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"select.span_panel_circuit_16_priority","hidden_by":null,"icon":null,"id":"f743cddb5c0b670c5bda7427bea643ab","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.336772+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":null,"original_icon":null,"original_name":"Outlets / Kitchen Circuit Priority","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_select_31b36cde0fc642b39eec515267707a6f","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"options":["Must Have","Nice To Have","Non-Essential"]},"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-11T22:56:33.792378+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"select.span_panel_circuit_19_priority","hidden_by":null,"icon":null,"id":"22bfca9599c79dfe45da594e2414870a","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.338414+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":null,"original_icon":null,"original_name":"Microwave & Oven Circuit Priority","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_select_3a847fe6eb374ec3bf92cee1ffaf2eda","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"options":["Must Have","Nice To Have","Non-Essential"]},"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-11T22:56:33.792548+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"select.span_panel_dishwasher_priority","hidden_by":null,"icon":null,"id":"69aeaf03f69554ec38b661f3ad3c9a79","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.339922+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":null,"original_icon":null,"original_name":"Dishwasher Circuit Priority","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_select_497ae7ebb2844772bf926f2f094c81bc","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"options":["Must Have","Nice To Have","Non-Essential"]},"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-11T22:56:33.793332+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"select.span_panel_spa_priority","hidden_by":null,"icon":null,"id":"46a81e97f5d36c2896c267520679ceb5","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.340874+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":null,"original_icon":null,"original_name":"Spa Circuit Priority","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_select_617059df47bb49bd8545a36a6b6b23d2","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"options":["Must Have","Nice To Have","Non-Essential"]},"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-11T22:56:33.793518+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"select.span_panel_kitchen_disposal_priority","hidden_by":null,"icon":null,"id":"5d820a9d696f5c3127daa774e34084d4","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.341865+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":null,"original_icon":null,"original_name":"Kitchen Disposal Circuit Priority","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_select_6ad65cf2ad6443448e33973f26f7df3e","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"options":["Must Have","Nice To Have","Non-Essential"]},"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-11T22:56:33.793701+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"select.span_panel_fountain_priority","hidden_by":null,"icon":null,"id":"29386a779f9cf34103027d6cc6fc943e","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T00:49:15.338203+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":null,"original_icon":null,"original_name":"Fountain Circuit Priority","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_select_795e8eddb4f448af9625130332a41df8","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"options":["Must Have","Nice To Have","Non-Essential"]},"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-11T22:56:33.793857+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"select.span_panel_large_garage_ev_priority","hidden_by":null,"icon":null,"id":"c6d74933d30f9f164126e40fb177b44b","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.344151+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":null,"original_icon":null,"original_name":"Large Garage EV Circuit Priority","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_select_8a2ffda9dbd24bada9a01b880e910612","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"options":["Must Have","Nice To Have","Non-Essential"]},"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-11T22:56:33.794384+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"select.span_panel_other_outlets_priority","hidden_by":null,"icon":null,"id":"14bd913697842358f143fafdcf42a824","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.345140+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":null,"original_icon":null,"original_name":"Other Outlets Circuit Priority","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_select_914943d4798c462cadf5e5144989dd5b","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"options":["Must Have","Nice To Have","Non-Essential"]},"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-11T22:56:33.794598+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"select.span_panel_master_bedroom_priority","hidden_by":null,"icon":null,"id":"02524e293e039df5e46e603a81df0340","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.346418+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":null,"original_icon":null,"original_name":"Master bedroom Circuit Priority","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_select_926441605113492189f9aa13dac356cd","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"options":["Must Have","Nice To Have","Non-Essential"]},"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-11T22:56:33.794733+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"select.span_panel_air_conditioner_priority","hidden_by":null,"icon":null,"id":"d6653fcb2b588d1c81c7d914a74b3992","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.347579+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":null,"original_icon":null,"original_name":"Air Conditioner Circuit Priority","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_select_941d6a8b41ab4c57a6b8be14b5981fe6","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"options":["Must Have","Nice To Have","Non-Essential"]},"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-11T22:56:33.794875+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"select.span_panel_garage_outlets_priority","hidden_by":null,"icon":null,"id":"36ceecc4e2cdd6385988c55f85ee34ab","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.349428+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":null,"original_icon":null,"original_name":"Garage Outlets Circuit Priority","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_select_988af7bb1fc04aac8bb3b45c660d920a","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"options":["Must Have","Nice To Have","Non-Essential"]},"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-11T22:56:33.795036+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"select.span_panel_furnace_priority","hidden_by":null,"icon":null,"id":"244e7d25c7614271d62ec6f2dccbd88f","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.350579+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":null,"original_icon":null,"original_name":"Furnace Circuit Priority","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_select_cbe98d7307ca42658983b9f673211e14","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"options":["Must Have","Nice To Have","Non-Essential"]},"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-11T22:56:33.795187+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"select.span_panel_small_garage_ev_priority","hidden_by":null,"icon":null,"id":"8d91bbe1e7165cec44b834773040febf","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.351604+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":null,"original_icon":null,"original_name":"Small Garage EV Circuit Priority","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_select_d88c0c0e7c584472b2ec7e4d3c53c3b8","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"options":["Must Have","Nice To Have","Non-Essential"]},"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-11T22:56:33.795345+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"select.span_panel_washing_machine_area_priority","hidden_by":null,"icon":null,"id":"aa3a62b39263a26c5dc85ce0efea6059","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.352569+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":null,"original_icon":null,"original_name":"Washing Machine Area Circuit Priority","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_select_e3cb78d38dae41cdb50faee7bfa4ab80","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"options":["Must Have","Nice To Have","Non-Essential"]},"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-11T22:56:33.795566+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"select.span_panel_kitchen_and_master_bath_priority","hidden_by":null,"icon":null,"id":"b214997912394795f6a87fa9f7963df3","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.353766+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":null,"original_icon":null,"original_name":"Kitchen and Master Bath Circuit Priority","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_select_ece352c3dc5c417e80757b716f24ba90","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"options":["Must Have","Nice To Have","Non-Essential"]},"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-11T22:56:33.795742+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"select.span_panel_lights_kitchen_master_bathroom_priority","hidden_by":null,"icon":null,"id":"cdeb56ce12044310aac5ce62b004088b","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.355211+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":null,"original_icon":null,"original_name":"Lights Kitchen Master bathroom Circuit Priority","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_select_f19d909abcc4421f9ec630f82458c7ac","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"measurement"},"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.106583+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_current_power","hidden_by":null,"icon":null,"id":"dec780cbf5bb61110203cc80e0788664","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.366323+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"power","original_icon":"mdi:flash","original_name":"Current Power","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_instantGridPowerW","previous_unique_id":null,"unit_of_measurement":"W"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"measurement"},"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.107326+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_feed_through_power","hidden_by":null,"icon":null,"id":"0cdcf2963f73490d7121c199699d9269","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.366903+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"power","original_icon":"mdi:flash","original_name":"Feed Through Power","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_feedthroughPowerW","previous_unique_id":null,"unit_of_measurement":"W"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"total_increasing"},"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.107683+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_main_meter_produced_energy","hidden_by":null,"icon":null,"id":"61dbb9274d45cb461fdc039f7c3fd3f7","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.367216+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"energy","original_icon":"mdi:flash","original_name":"Main Meter Produced Energy","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_mainMeterEnergy.producedEnergyWh","previous_unique_id":null,"unit_of_measurement":"Wh"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"total_increasing"},"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.107974+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_main_meter_consumed_energy","hidden_by":null,"icon":null,"id":"491d4787cd1e217fd4c14cd8b03f96e5","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.367507+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"energy","original_icon":"mdi:flash","original_name":"Main Meter Consumed Energy","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_mainMeterEnergy.consumedEnergyWh","previous_unique_id":null,"unit_of_measurement":"Wh"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"total_increasing"},"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.108312+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_feed_through_produced_energy","hidden_by":null,"icon":null,"id":"0c800057ccdbe26636ba4f42b254eba5","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.367799+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"energy","original_icon":"mdi:flash","original_name":"Feed Through Produced Energy","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_feedthroughEnergy.producedEnergyWh","previous_unique_id":null,"unit_of_measurement":"Wh"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"total_increasing"},"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.109043+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_feed_through_consumed_energy","hidden_by":null,"icon":null,"id":"0e97ce558aa0e9fc99a09249943fe961","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.368095+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"energy","original_icon":"mdi:flash","original_name":"Feed Through Consumed Energy","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_feedthroughEnergy.consumedEnergyWh","previous_unique_id":null,"unit_of_measurement":"Wh"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.109391+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_current_run_config","hidden_by":null,"icon":null,"id":"16661d9e4c5bc1a92c7ce86e1accc18f","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.368368+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":null,"original_icon":"mdi:flash","original_name":"Current Run Config","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_currentRunConfig","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.109613+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_dsm_grid_state","hidden_by":null,"icon":null,"id":"da48cbd532eab3a839cc33bbe008032d","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.368683+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":null,"original_icon":"mdi:flash","original_name":"DSM Grid State","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_dsmGridState","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.109808+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_dsm_state","hidden_by":null,"icon":null,"id":"9591fe259c66fcbd2af98e5c6f4d93db","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.368946+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":null,"original_icon":"mdi:flash","original_name":"DSM State","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_dsmState","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.110016+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_main_relay_state","hidden_by":null,"icon":null,"id":"c26c378aa8688697dd7f0dce0097cf8e","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.369235+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":null,"original_icon":"mdi:flash","original_name":"Main Relay State","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_mainRelayState","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.110300+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_software_version","hidden_by":null,"icon":null,"id":"06d09884f67cb2ee7af2a90720d83acb","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.369512+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":null,"original_icon":"mdi:flash","original_name":"Software Version","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_softwareVer","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"measurement"},"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.110545+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_2_power","hidden_by":null,"icon":null,"id":"19ce1481e73263250f245517b1594b7a","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.369676+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"power","original_icon":"mdi:flash","original_name":"Lights Dining Room Power","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_0dad2f16cd514812ae1807b0457d473e_instantPowerW","previous_unique_id":null,"unit_of_measurement":"W"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"measurement"},"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.110789+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_4_power","hidden_by":null,"icon":null,"id":"2aa1dadbdf083af026c65ac057aa46ca","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.370827+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"power","original_icon":"mdi:flash","original_name":"Lights-Outlets Bedroom Power","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_11a47a0f69d54e12b7200f730c2ffda1_instantPowerW","previous_unique_id":null,"unit_of_measurement":"W"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"measurement"},"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.111032+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_14_power","hidden_by":null,"icon":null,"id":"079314109b4d52969ba81217f5eaaeac","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.371494+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"power","original_icon":"mdi:flash","original_name":"Dining Room Wine Fridge Power","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_12ce227695cd44338864b0ef2ec4168b_instantPowerW","previous_unique_id":null,"unit_of_measurement":"W"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"measurement"},"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.111255+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_24_power","hidden_by":null,"icon":null,"id":"d3371e2671b02a83e31d967e0c221934","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.372177+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"power","original_icon":"mdi:flash","original_name":"Dryer Power","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_1ad73b0eb44e4022bb6270c76baed0c1_instantPowerW","previous_unique_id":null,"unit_of_measurement":"W"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"measurement"},"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.111470+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_16_power","hidden_by":null,"icon":null,"id":"03684c7ceb0fb1ac0aeb987cee72d48c","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.372684+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"power","original_icon":"mdi:flash","original_name":"Outlets / Kitchen Power","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_31b36cde0fc642b39eec515267707a6f_instantPowerW","previous_unique_id":null,"unit_of_measurement":"W"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"measurement"},"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.111686+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_19_power","hidden_by":null,"icon":null,"id":"41581c1c69d4290a011b92dc69fff58a","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.373318+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"power","original_icon":"mdi:flash","original_name":"Microwave & Oven Power","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_3a847fe6eb374ec3bf92cee1ffaf2eda_instantPowerW","previous_unique_id":null,"unit_of_measurement":"W"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"measurement"},"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.111913+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_12_power","hidden_by":null,"icon":null,"id":"ce785758a74bcce7766b55b8f1008925","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.373944+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"power","original_icon":"mdi:flash","original_name":"Dishwasher Power","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_497ae7ebb2844772bf926f2f094c81bc_instantPowerW","previous_unique_id":null,"unit_of_measurement":"W"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"measurement"},"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.112164+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_29_power","hidden_by":null,"icon":null,"id":"e610287d2ec55ddd59209c98dc619256","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.374581+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"power","original_icon":"mdi:flash","original_name":"Spa Power","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_617059df47bb49bd8545a36a6b6b23d2_instantPowerW","previous_unique_id":null,"unit_of_measurement":"W"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"measurement"},"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.112388+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_10_power","hidden_by":null,"icon":null,"id":"534e3f460519b59ddd7d3cdcd45dddf5","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.375330+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"power","original_icon":"mdi:flash","original_name":"Kitchen Disposal Power","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_6ad65cf2ad6443448e33973f26f7df3e_instantPowerW","previous_unique_id":null,"unit_of_measurement":"W"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"measurement"},"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.112595+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_25_power","hidden_by":null,"icon":null,"id":"de13ee63a56b46b5b0501d1ba1fe8b8f","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T00:49:15.380437+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"power","original_icon":"mdi:flash","original_name":"Fountain Power","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_795e8eddb4f448af9625130332a41df8_instantPowerW","previous_unique_id":null,"unit_of_measurement":"W"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"measurement"},"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.112825+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_23_power","hidden_by":null,"icon":null,"id":"13f2a10f28cf0e105c5c2b3ba74ad43b","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.376963+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"power","original_icon":"mdi:flash","original_name":"Internet Living room Power","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_82a0888dc072416598d99f8b74ee3d8d_instantPowerW","previous_unique_id":null,"unit_of_measurement":"W"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"measurement"},"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.113051+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_18_power","hidden_by":null,"icon":null,"id":"040a4e000a3c14e4bea763acc9fd6fbd","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.377621+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"power","original_icon":"mdi:flash","original_name":"Large Garage EV Power","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_8a2ffda9dbd24bada9a01b880e910612_instantPowerW","previous_unique_id":null,"unit_of_measurement":"W"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"measurement"},"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.113273+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_9_power","hidden_by":null,"icon":null,"id":"b8947837aea8f7688380f729cfef621b","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.378271+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"power","original_icon":"mdi:flash","original_name":"Other Outlets Power","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_914943d4798c462cadf5e5144989dd5b_instantPowerW","previous_unique_id":null,"unit_of_measurement":"W"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"measurement"},"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.113487+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_1_power","hidden_by":null,"icon":null,"id":"ae1bb3a4cede098b817a2b52919e64da","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.378870+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"power","original_icon":"mdi:flash","original_name":"Master bedroom Power","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_926441605113492189f9aa13dac356cd_instantPowerW","previous_unique_id":null,"unit_of_measurement":"W"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"measurement"},"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.113689+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_15_power","hidden_by":null,"icon":null,"id":"a81855265386cc73800948dbde1783e2","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.379508+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"power","original_icon":"mdi:flash","original_name":"Air Conditioner Power","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_941d6a8b41ab4c57a6b8be14b5981fe6_instantPowerW","previous_unique_id":null,"unit_of_measurement":"W"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"measurement"},"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.114436+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_7_power","hidden_by":null,"icon":null,"id":"b887c66922fee144930612369df071c3","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.380750+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"power","original_icon":"mdi:flash","original_name":"Garage Outlets Power","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_988af7bb1fc04aac8bb3b45c660d920a_instantPowerW","previous_unique_id":null,"unit_of_measurement":"W"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"measurement"},"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.114776+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_22_power","hidden_by":null,"icon":null,"id":"4e35db0ef0e25f93c29976eb39a7c79a","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.382154+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"power","original_icon":"mdi:flash","original_name":"Refrigerator Power","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_9941b417bfa046ef908d97c998c54c50_instantPowerW","previous_unique_id":null,"unit_of_measurement":"W"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"measurement"},"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.115081+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_6_power","hidden_by":null,"icon":null,"id":"06ebe61ee8546ba6c839d9dd6afbf7d0","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.383444+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"power","original_icon":"mdi:flash","original_name":"Furnace Power","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_cbe98d7307ca42658983b9f673211e14_instantPowerW","previous_unique_id":null,"unit_of_measurement":"W"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"measurement"},"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.115419+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_11_power","hidden_by":null,"icon":null,"id":"9c8cf277b249b682c096ca98174e8b60","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.384597+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"power","original_icon":"mdi:flash","original_name":"Small Garage EV Power","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_d88c0c0e7c584472b2ec7e4d3c53c3b8_instantPowerW","previous_unique_id":null,"unit_of_measurement":"W"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"measurement"},"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.115690+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_8_power","hidden_by":null,"icon":null,"id":"f7ad137494690574b493e6bf912f1362","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.385618+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"power","original_icon":"mdi:flash","original_name":"Washing Machine Area Power","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_e3cb78d38dae41cdb50faee7bfa4ab80_instantPowerW","previous_unique_id":null,"unit_of_measurement":"W"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"measurement"},"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.115935+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_3_power","hidden_by":null,"icon":null,"id":"d17d1520eca33612789df2441bd9b5f3","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.386319+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"power","original_icon":"mdi:flash","original_name":"Kitchen and Master Bath Power","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_ece352c3dc5c417e80757b716f24ba90_instantPowerW","previous_unique_id":null,"unit_of_measurement":"W"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"measurement"},"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.116184+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_5_power","hidden_by":null,"icon":null,"id":"bac4726564781c97216dff05229204fd","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.387350+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"power","original_icon":"mdi:flash","original_name":"Lights Kitchen Master bathroom Power","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_f19d909abcc4421f9ec630f82458c7ac_instantPowerW","previous_unique_id":null,"unit_of_measurement":"W"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"total_increasing"},"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.116443+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_2_energy_produced","hidden_by":null,"icon":null,"id":"6ee0c0a95a9fd854eed5134b5ef222d5","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.391208+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"energy","original_icon":"mdi:flash","original_name":"Lights Dining Room Produced Energy","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_0dad2f16cd514812ae1807b0457d473e_producedEnergyWh","previous_unique_id":null,"unit_of_measurement":"Wh"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"total_increasing"},"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.116681+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_4_energy_produced","hidden_by":null,"icon":null,"id":"21ce13c37eb3fe88e4299c1f50a42c92","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.392284+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"energy","original_icon":"mdi:flash","original_name":"Lights-Outlets Bedroom Produced Energy","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_11a47a0f69d54e12b7200f730c2ffda1_producedEnergyWh","previous_unique_id":null,"unit_of_measurement":"Wh"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"total_increasing"},"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.117098+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_14_energy_produced","hidden_by":null,"icon":null,"id":"5050bb89ccfec0f01d2bf3075d3dbb30","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.392957+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"energy","original_icon":"mdi:flash","original_name":"Dining Room Wine Fridge Produced Energy","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_12ce227695cd44338864b0ef2ec4168b_producedEnergyWh","previous_unique_id":null,"unit_of_measurement":"Wh"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"total_increasing"},"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.117355+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_24_energy_produced","hidden_by":null,"icon":null,"id":"d9d98ea725a273fca21bf77fecc02899","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.393626+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"energy","original_icon":"mdi:flash","original_name":"Dryer Produced Energy","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_1ad73b0eb44e4022bb6270c76baed0c1_producedEnergyWh","previous_unique_id":null,"unit_of_measurement":"Wh"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"total_increasing"},"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.117589+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_16_energy_produced","hidden_by":null,"icon":null,"id":"7b9f2578deb4e93845a551fd44675caa","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.394635+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"energy","original_icon":"mdi:flash","original_name":"Outlets / Kitchen Produced Energy","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_31b36cde0fc642b39eec515267707a6f_producedEnergyWh","previous_unique_id":null,"unit_of_measurement":"Wh"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"total_increasing"},"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.117830+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_19_energy_produced","hidden_by":null,"icon":null,"id":"507ff0aa373d736511fcfc8327094844","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.395386+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"energy","original_icon":"mdi:flash","original_name":"Microwave & Oven Produced Energy","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_3a847fe6eb374ec3bf92cee1ffaf2eda_producedEnergyWh","previous_unique_id":null,"unit_of_measurement":"Wh"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"total_increasing"},"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.118064+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_12_energy_produced","hidden_by":null,"icon":null,"id":"d2a61e8de0449693c9b270242ab8cba8","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.396095+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"energy","original_icon":"mdi:flash","original_name":"Dishwasher Produced Energy","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_497ae7ebb2844772bf926f2f094c81bc_producedEnergyWh","previous_unique_id":null,"unit_of_measurement":"Wh"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"total_increasing"},"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.118340+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_29_energy_produced","hidden_by":null,"icon":null,"id":"1dbf521b7ac8014151602b35d481b3c6","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.396729+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"energy","original_icon":"mdi:flash","original_name":"Spa Produced Energy","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_617059df47bb49bd8545a36a6b6b23d2_producedEnergyWh","previous_unique_id":null,"unit_of_measurement":"Wh"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"total_increasing"},"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.118594+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_10_energy_produced","hidden_by":null,"icon":null,"id":"b50f7a56afdd296c7a58f54610dd126f","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.397713+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"energy","original_icon":"mdi:flash","original_name":"Kitchen Disposal Produced Energy","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_6ad65cf2ad6443448e33973f26f7df3e_producedEnergyWh","previous_unique_id":null,"unit_of_measurement":"Wh"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"total_increasing"},"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.118821+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_25_energy_produced","hidden_by":null,"icon":null,"id":"39c8f492b3044cc06b48bf781f4ecb76","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T00:49:15.397337+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"energy","original_icon":"mdi:flash","original_name":"Fountain Produced Energy","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_795e8eddb4f448af9625130332a41df8_producedEnergyWh","previous_unique_id":null,"unit_of_measurement":"Wh"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"total_increasing"},"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.119063+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_23_energy_produced","hidden_by":null,"icon":null,"id":"00b3e55750dce4e0566a34b3e6b1ed4f","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.399822+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"energy","original_icon":"mdi:flash","original_name":"Internet Living room Produced Energy","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_82a0888dc072416598d99f8b74ee3d8d_producedEnergyWh","previous_unique_id":null,"unit_of_measurement":"Wh"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"total_increasing"},"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.119284+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_18_energy_produced","hidden_by":null,"icon":null,"id":"3ff1d7114ff5ebe7f64cf4db57396b4f","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.400690+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"energy","original_icon":"mdi:flash","original_name":"Large Garage EV Produced Energy","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_8a2ffda9dbd24bada9a01b880e910612_producedEnergyWh","previous_unique_id":null,"unit_of_measurement":"Wh"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"total_increasing"},"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.119495+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_9_energy_produced","hidden_by":null,"icon":null,"id":"c2aa1d5f7027c8977a9a1857432397c3","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.401782+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"energy","original_icon":"mdi:flash","original_name":"Other Outlets Produced Energy","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_914943d4798c462cadf5e5144989dd5b_producedEnergyWh","previous_unique_id":null,"unit_of_measurement":"Wh"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"total_increasing"},"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.120713+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_1_energy_produced","hidden_by":null,"icon":null,"id":"871676b8c26ea522ab0f6e8c12a0ace5","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.403014+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"energy","original_icon":"mdi:flash","original_name":"Master bedroom Produced Energy","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_926441605113492189f9aa13dac356cd_producedEnergyWh","previous_unique_id":null,"unit_of_measurement":"Wh"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"total_increasing"},"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.121027+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_15_energy_produced","hidden_by":null,"icon":null,"id":"95168bc734415b17f9c63fafefbc079b","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.403989+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"energy","original_icon":"mdi:flash","original_name":"Air Conditioner Produced Energy","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_941d6a8b41ab4c57a6b8be14b5981fe6_producedEnergyWh","previous_unique_id":null,"unit_of_measurement":"Wh"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"total_increasing"},"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.121280+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_7_energy_produced","hidden_by":null,"icon":null,"id":"49fd529ed03f857f58b832bbcd753327","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.405456+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"energy","original_icon":"mdi:flash","original_name":"Garage Outlets Produced Energy","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_988af7bb1fc04aac8bb3b45c660d920a_producedEnergyWh","previous_unique_id":null,"unit_of_measurement":"Wh"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"total_increasing"},"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.121510+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_22_energy_produced","hidden_by":null,"icon":null,"id":"9b5b30e5f76ff2a5ad008943d392ebf8","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.406809+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"energy","original_icon":"mdi:flash","original_name":"Refrigerator Produced Energy","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_9941b417bfa046ef908d97c998c54c50_producedEnergyWh","previous_unique_id":null,"unit_of_measurement":"Wh"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"total_increasing"},"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.121726+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_6_energy_produced","hidden_by":null,"icon":null,"id":"7522f5b6cf4dc53a157fafe5f58d3a79","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.407970+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"energy","original_icon":"mdi:flash","original_name":"Furnace Produced Energy","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_cbe98d7307ca42658983b9f673211e14_producedEnergyWh","previous_unique_id":null,"unit_of_measurement":"Wh"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"total_increasing"},"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.121944+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_11_energy_produced","hidden_by":null,"icon":null,"id":"82f7dadc7b6f595044fb35060f19df13","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.408892+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"energy","original_icon":"mdi:flash","original_name":"Small Garage EV Produced Energy","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_d88c0c0e7c584472b2ec7e4d3c53c3b8_producedEnergyWh","previous_unique_id":null,"unit_of_measurement":"Wh"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"total_increasing"},"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.122201+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_8_energy_produced","hidden_by":null,"icon":null,"id":"71cec55ed46dd6516e4e25ca3cec3c31","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.409698+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"energy","original_icon":"mdi:flash","original_name":"Washing Machine Area Produced Energy","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_e3cb78d38dae41cdb50faee7bfa4ab80_producedEnergyWh","previous_unique_id":null,"unit_of_measurement":"Wh"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"total_increasing"},"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.122430+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_3_energy_produced","hidden_by":null,"icon":null,"id":"7c8ea792832e60b416415529e17d4f46","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.410423+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"energy","original_icon":"mdi:flash","original_name":"Kitchen and Master Bath Produced Energy","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_ece352c3dc5c417e80757b716f24ba90_producedEnergyWh","previous_unique_id":null,"unit_of_measurement":"Wh"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"total_increasing"},"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.122655+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_5_energy_produced","hidden_by":null,"icon":null,"id":"daf3d85442d1398e0474ecbe60162295","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.410966+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"energy","original_icon":"mdi:flash","original_name":"Lights Kitchen Master bathroom Produced Energy","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_f19d909abcc4421f9ec630f82458c7ac_producedEnergyWh","previous_unique_id":null,"unit_of_measurement":"Wh"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"total_increasing"},"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.122862+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_2_energy_consumed","hidden_by":null,"icon":null,"id":"4ed4a17c2fd701e29b972bded6caa501","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.411649+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"energy","original_icon":"mdi:flash","original_name":"Lights Dining Room Consumed Energy","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_0dad2f16cd514812ae1807b0457d473e_consumedEnergyWh","previous_unique_id":null,"unit_of_measurement":"Wh"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"total_increasing"},"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.123114+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_4_energy_consumed","hidden_by":null,"icon":null,"id":"ffaefebdeabec1b0d6f1c804f6c41c2f","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.412293+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"energy","original_icon":"mdi:flash","original_name":"Lights-Outlets Bedroom Consumed Energy","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_11a47a0f69d54e12b7200f730c2ffda1_consumedEnergyWh","previous_unique_id":null,"unit_of_measurement":"Wh"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"total_increasing"},"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.123330+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_14_energy_consumed","hidden_by":null,"icon":null,"id":"695a574927eac5558556df4669ad84aa","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.412805+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"energy","original_icon":"mdi:flash","original_name":"Dining Room Wine Fridge Consumed Energy","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_12ce227695cd44338864b0ef2ec4168b_consumedEnergyWh","previous_unique_id":null,"unit_of_measurement":"Wh"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"total_increasing"},"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.123534+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_24_energy_consumed","hidden_by":null,"icon":null,"id":"2ea34b03bfb6d02e964cdbbb195e93d7","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.413312+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"energy","original_icon":"mdi:flash","original_name":"Dryer Consumed Energy","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_1ad73b0eb44e4022bb6270c76baed0c1_consumedEnergyWh","previous_unique_id":null,"unit_of_measurement":"Wh"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"total_increasing"},"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.123772+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_16_energy_consumed","hidden_by":null,"icon":null,"id":"f5f3ca9f0f9e4b305817c523ca7b729d","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.414341+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"energy","original_icon":"mdi:flash","original_name":"Outlets / Kitchen Consumed Energy","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_31b36cde0fc642b39eec515267707a6f_consumedEnergyWh","previous_unique_id":null,"unit_of_measurement":"Wh"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"total_increasing"},"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.124040+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_19_energy_consumed","hidden_by":null,"icon":null,"id":"cbe29e8c154804c998583b92ab1f8b50","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.414945+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"energy","original_icon":"mdi:flash","original_name":"Microwave & Oven Consumed Energy","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_3a847fe6eb374ec3bf92cee1ffaf2eda_consumedEnergyWh","previous_unique_id":null,"unit_of_measurement":"Wh"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"total_increasing"},"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.124334+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_12_energy_consumed","hidden_by":null,"icon":null,"id":"92a66a0142dd430ecc90fdb35704c99f","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.415478+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"energy","original_icon":"mdi:flash","original_name":"Dishwasher Consumed Energy","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_497ae7ebb2844772bf926f2f094c81bc_consumedEnergyWh","previous_unique_id":null,"unit_of_measurement":"Wh"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"total_increasing"},"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.124555+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_29_energy_consumed","hidden_by":null,"icon":null,"id":"e72ed5302015f8c0df61879e41b7d892","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.416017+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"energy","original_icon":"mdi:flash","original_name":"Spa Consumed Energy","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_617059df47bb49bd8545a36a6b6b23d2_consumedEnergyWh","previous_unique_id":null,"unit_of_measurement":"Wh"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"total_increasing"},"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.124821+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_10_energy_consumed","hidden_by":null,"icon":null,"id":"4a2a32c8f06df1eed9bd1f2eaf60f2ff","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.416662+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"energy","original_icon":"mdi:flash","original_name":"Kitchen Disposal Consumed Energy","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_6ad65cf2ad6443448e33973f26f7df3e_consumedEnergyWh","previous_unique_id":null,"unit_of_measurement":"Wh"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"total_increasing"},"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.125066+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_25_energy_consumed","hidden_by":null,"icon":null,"id":"6ebc7934c3074a5328ea3c0e050edbe6","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T00:49:15.414976+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"energy","original_icon":"mdi:flash","original_name":"Fountain Consumed Energy","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_795e8eddb4f448af9625130332a41df8_consumedEnergyWh","previous_unique_id":null,"unit_of_measurement":"Wh"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"total_increasing"},"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.125316+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_23_energy_consumed","hidden_by":null,"icon":null,"id":"2902b357e59ec0244b7fd4bc9eba791d","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.417709+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"energy","original_icon":"mdi:flash","original_name":"Internet Living room Consumed Energy","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_82a0888dc072416598d99f8b74ee3d8d_consumedEnergyWh","previous_unique_id":null,"unit_of_measurement":"Wh"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"total_increasing"},"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.125535+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_18_energy_consumed","hidden_by":null,"icon":null,"id":"4b7fe86270fc6abb6241280f2d577d45","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.419293+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"energy","original_icon":"mdi:flash","original_name":"Large Garage EV Consumed Energy","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_8a2ffda9dbd24bada9a01b880e910612_consumedEnergyWh","previous_unique_id":null,"unit_of_measurement":"Wh"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"total_increasing"},"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.129208+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_9_energy_consumed","hidden_by":null,"icon":null,"id":"896e69f2f580703cc777ad4d59365a66","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.420427+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"energy","original_icon":"mdi:flash","original_name":"Other Outlets Consumed Energy","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_914943d4798c462cadf5e5144989dd5b_consumedEnergyWh","previous_unique_id":null,"unit_of_measurement":"Wh"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"total_increasing"},"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.129570+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_1_energy_consumed","hidden_by":null,"icon":null,"id":"89070e495b27cf0640b353c864e82a81","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.421233+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"energy","original_icon":"mdi:flash","original_name":"Master bedroom Consumed Energy","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_926441605113492189f9aa13dac356cd_consumedEnergyWh","previous_unique_id":null,"unit_of_measurement":"Wh"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"total_increasing"},"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.129928+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_15_energy_consumed","hidden_by":null,"icon":null,"id":"f777f9ad2278baf14d412d95685e944b","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.422040+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"energy","original_icon":"mdi:flash","original_name":"Air Conditioner Consumed Energy","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_941d6a8b41ab4c57a6b8be14b5981fe6_consumedEnergyWh","previous_unique_id":null,"unit_of_measurement":"Wh"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"total_increasing"},"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.130202+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_7_energy_consumed","hidden_by":null,"icon":null,"id":"8920426e8b671af4900dc5a6074c385d","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.423235+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"energy","original_icon":"mdi:flash","original_name":"Garage Outlets Consumed Energy","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_988af7bb1fc04aac8bb3b45c660d920a_consumedEnergyWh","previous_unique_id":null,"unit_of_measurement":"Wh"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"total_increasing"},"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.130469+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_22_energy_consumed","hidden_by":null,"icon":null,"id":"6b09fc81690875658dee9ece3c68f415","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.425181+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"energy","original_icon":"mdi:flash","original_name":"Refrigerator Consumed Energy","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_9941b417bfa046ef908d97c998c54c50_consumedEnergyWh","previous_unique_id":null,"unit_of_measurement":"Wh"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"total_increasing"},"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.130771+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_6_energy_consumed","hidden_by":null,"icon":null,"id":"ad28024d7650a3e486e57d2485e471da","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.426137+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"energy","original_icon":"mdi:flash","original_name":"Furnace Consumed Energy","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_cbe98d7307ca42658983b9f673211e14_consumedEnergyWh","previous_unique_id":null,"unit_of_measurement":"Wh"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"total_increasing"},"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.131036+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_11_energy_consumed","hidden_by":null,"icon":null,"id":"87a13560f5a4091ada85becca9998c07","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.426890+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"energy","original_icon":"mdi:flash","original_name":"Small Garage EV Consumed Energy","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_d88c0c0e7c584472b2ec7e4d3c53c3b8_consumedEnergyWh","previous_unique_id":null,"unit_of_measurement":"Wh"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"total_increasing"},"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.131296+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_8_energy_consumed","hidden_by":null,"icon":null,"id":"35328f1b6994513f9103f3dee7fdb955","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.427596+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"energy","original_icon":"mdi:flash","original_name":"Washing Machine Area Consumed Energy","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_e3cb78d38dae41cdb50faee7bfa4ab80_consumedEnergyWh","previous_unique_id":null,"unit_of_measurement":"Wh"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"total_increasing"},"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.131593+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_3_energy_consumed","hidden_by":null,"icon":null,"id":"657a5a8b0743caf26ca89263fdf0cea6","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.428202+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"energy","original_icon":"mdi:flash","original_name":"Kitchen and Master Bath Consumed Energy","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_ece352c3dc5c417e80757b716f24ba90_consumedEnergyWh","previous_unique_id":null,"unit_of_measurement":"Wh"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"total_increasing"},"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-13T18:34:39.131858+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_circuit_5_energy_consumed","hidden_by":null,"icon":null,"id":"1f9d81acff8b1dcb6c7adbd37f2d29dc","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.428885+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"energy","original_icon":"mdi:flash","original_name":"Lights Kitchen Master bathroom Consumed Energy","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_f19d909abcc4421f9ec630f82458c7ac_consumedEnergyWh","previous_unique_id":null,"unit_of_measurement":"Wh"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-11T22:56:33.834254+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"switch.span_panel_lights_dining_room_breaker","hidden_by":null,"icon":null,"id":"abf75c314604d60956cc2227db59b89c","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.433737+00:00","name":null,"options":{"conversation":{"should_expose":true}},"original_device_class":null,"original_icon":"mdi:toggle-switch","original_name":"Lights Dining Room Breaker","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_relay_0dad2f16cd514812ae1807b0457d473e","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-11T22:56:33.834459+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"switch.span_panel_lights_outlets_bedroom_breaker","hidden_by":null,"icon":null,"id":"6c3ef18902e7d4838f095ff553f2623f","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.434918+00:00","name":null,"options":{"conversation":{"should_expose":true}},"original_device_class":null,"original_icon":"mdi:toggle-switch","original_name":"Lights-Outlets Bedroom Breaker","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_relay_11a47a0f69d54e12b7200f730c2ffda1","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-11T22:56:33.834613+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"switch.span_panel_dining_room_wine_fridge_breaker","hidden_by":null,"icon":null,"id":"22012431ed7713a0d9f042ea8b61f310","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.436252+00:00","name":null,"options":{"conversation":{"should_expose":true}},"original_device_class":null,"original_icon":"mdi:toggle-switch","original_name":"Dining Room Wine Fridge Breaker","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_relay_12ce227695cd44338864b0ef2ec4168b","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-11T22:56:33.834758+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"switch.span_panel_dryer_breaker","hidden_by":null,"icon":null,"id":"e4d046f5770a49b011116e2e498b67a3","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.437241+00:00","name":null,"options":{"conversation":{"should_expose":true}},"original_device_class":null,"original_icon":"mdi:toggle-switch","original_name":"Dryer Breaker","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_relay_1ad73b0eb44e4022bb6270c76baed0c1","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-11T22:56:33.834887+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"switch.span_panel_circuit_16_breaker","hidden_by":null,"icon":null,"id":"8202c353497db18a59f4adeb731e58af","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.438160+00:00","name":null,"options":{"conversation":{"should_expose":true}},"original_device_class":null,"original_icon":"mdi:toggle-switch","original_name":"Outlets / Kitchen Breaker","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_relay_31b36cde0fc642b39eec515267707a6f","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-11T22:56:33.835022+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"switch.span_panel_circuit_19_breaker","hidden_by":null,"icon":null,"id":"3cee4a96dcf07e2ded224f35d7ff172c","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.439051+00:00","name":null,"options":{"conversation":{"should_expose":true}},"original_device_class":null,"original_icon":"mdi:toggle-switch","original_name":"Microwave & Oven Breaker","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_relay_3a847fe6eb374ec3bf92cee1ffaf2eda","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-11T22:56:33.835244+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"switch.span_panel_dishwasher_breaker","hidden_by":null,"icon":null,"id":"2bfa5ec9f6000a428c1010c32f802cf6","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.439910+00:00","name":null,"options":{"conversation":{"should_expose":true}},"original_device_class":null,"original_icon":"mdi:toggle-switch","original_name":"Dishwasher Breaker","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_relay_497ae7ebb2844772bf926f2f094c81bc","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-11T22:56:33.835374+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"switch.span_panel_spa_breaker","hidden_by":null,"icon":null,"id":"8b8a34f22d729f06f87020cb853376e7","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.440801+00:00","name":null,"options":{"conversation":{"should_expose":true}},"original_device_class":null,"original_icon":"mdi:toggle-switch","original_name":"Spa Breaker","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_relay_617059df47bb49bd8545a36a6b6b23d2","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-11T22:56:33.835496+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"switch.span_panel_kitchen_disposal_breaker","hidden_by":null,"icon":null,"id":"653266580714ee7b92caab4462760402","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.441989+00:00","name":null,"options":{"conversation":{"should_expose":true}},"original_device_class":null,"original_icon":"mdi:toggle-switch","original_name":"Kitchen Disposal Breaker","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_relay_6ad65cf2ad6443448e33973f26f7df3e","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-11T22:56:33.835616+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"switch.span_panel_fountain_breaker","hidden_by":null,"icon":null,"id":"62e1dc596a8a0a34844df17d715adc01","has_entity_name":false,"labels":[],"modified_at":"2025-08-16T00:49:15.444310+00:00","name":null,"options":{"conversation":{"should_expose":true}},"original_device_class":null,"original_icon":"mdi:toggle-switch","original_name":"Fountain Breaker","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_relay_795e8eddb4f448af9625130332a41df8","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-11T22:56:33.835736+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"switch.span_panel_large_garage_ev_breaker","hidden_by":null,"icon":null,"id":"b5fa556532e8e0a0933a6d9247c96810","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.443957+00:00","name":null,"options":{"conversation":{"should_expose":true}},"original_device_class":null,"original_icon":"mdi:toggle-switch","original_name":"Large Garage EV Breaker","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_relay_8a2ffda9dbd24bada9a01b880e910612","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-11T22:56:33.835858+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"switch.span_panel_other_outlets_breaker","hidden_by":null,"icon":null,"id":"7dc771b4bd4a64ab45c40abf758d95ae","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.444932+00:00","name":null,"options":{"conversation":{"should_expose":true}},"original_device_class":null,"original_icon":"mdi:toggle-switch","original_name":"Other Outlets Breaker","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_relay_914943d4798c462cadf5e5144989dd5b","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-11T22:56:33.835974+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"switch.span_panel_master_bedroom_breaker","hidden_by":null,"icon":null,"id":"4b508d23de583de1bb74b78ba1999bf2","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.445820+00:00","name":null,"options":{"conversation":{"should_expose":true}},"original_device_class":null,"original_icon":"mdi:toggle-switch","original_name":"Master bedroom Breaker","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_relay_926441605113492189f9aa13dac356cd","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-11T22:56:33.836104+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"switch.span_panel_air_conditioner_breaker","hidden_by":null,"icon":null,"id":"bd59e8ad90665772b1a5075d71f00ec7","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.447193+00:00","name":null,"options":{"conversation":{"should_expose":true}},"original_device_class":null,"original_icon":"mdi:toggle-switch","original_name":"Air Conditioner Breaker","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_relay_941d6a8b41ab4c57a6b8be14b5981fe6","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-11T22:56:33.836228+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"switch.span_panel_garage_outlets_breaker","hidden_by":null,"icon":null,"id":"eb299c0c60b4d88e35a7b8229c647550","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.448678+00:00","name":null,"options":{"conversation":{"should_expose":true}},"original_device_class":null,"original_icon":"mdi:toggle-switch","original_name":"Garage Outlets Breaker","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_relay_988af7bb1fc04aac8bb3b45c660d920a","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-11T22:56:33.838517+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"switch.span_panel_furnace_breaker","hidden_by":null,"icon":null,"id":"50c9dcba436a5431985f450045ef8af2","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.450247+00:00","name":null,"options":{"conversation":{"should_expose":true}},"original_device_class":null,"original_icon":"mdi:toggle-switch","original_name":"Furnace Breaker","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_relay_cbe98d7307ca42658983b9f673211e14","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-11T22:56:33.838676+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"switch.span_panel_small_garage_ev_breaker","hidden_by":null,"icon":null,"id":"f34ce27d35c79d38133f05ce2bb2e77a","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.452204+00:00","name":null,"options":{"conversation":{"should_expose":true}},"original_device_class":null,"original_icon":"mdi:toggle-switch","original_name":"Small Garage EV Breaker","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_relay_d88c0c0e7c584472b2ec7e4d3c53c3b8","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-11T22:56:33.838819+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"switch.span_panel_washing_machine_area_breaker","hidden_by":null,"icon":null,"id":"505168539e65f1b166ed75b9176964e1","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.453464+00:00","name":null,"options":{"conversation":{"should_expose":true}},"original_device_class":null,"original_icon":"mdi:toggle-switch","original_name":"Washing Machine Area Breaker","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_relay_e3cb78d38dae41cdb50faee7bfa4ab80","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-11T22:56:33.838946+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"switch.span_panel_kitchen_and_master_bath_breaker","hidden_by":null,"icon":null,"id":"5ef055afc70668b2a83494559e69989f","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.454469+00:00","name":null,"options":{"conversation":{"should_expose":true}},"original_device_class":null,"original_icon":"mdi:toggle-switch","original_name":"Kitchen and Master Bath Breaker","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_relay_ece352c3dc5c417e80757b716f24ba90","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":null,"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-11T22:56:33.839146+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"switch.span_panel_lights_kitchen_master_bathroom_breaker","hidden_by":null,"icon":null,"id":"0d39a51931c34b31205dcef3cfb785f3","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:30:41.455505+00:00","name":null,"options":{"conversation":{"should_expose":true}},"original_device_class":null,"original_icon":"mdi:toggle-switch","original_name":"Lights Kitchen Master bathroom Breaker","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_relay_f19d909abcc4421f9ec630f82458c7ac","previous_unique_id":null,"unit_of_measurement":null}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"measurement"},"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-15T17:31:55.131436+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.solar_inverter_instant_power","hidden_by":null,"icon":null,"id":"045853c93ff5c58f32b435c157ea87be","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:31:55.131910+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"power","original_icon":"mdi:flash","original_name":"Solar Inverter Instant Power","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_solar_inverter_instant_power","previous_unique_id":null,"unit_of_measurement":"W"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"total_increasing"},"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-15T17:31:55.132201+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.solar_inverter_energy_produced","hidden_by":null,"icon":null,"id":"5aacb42598acde3171b22631bba0567d","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:31:55.132645+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"energy","original_icon":"mdi:flash","original_name":"Solar Inverter Energy Produced","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_solar_inverter_energy_produced","previous_unique_id":null,"unit_of_measurement":"Wh"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"total_increasing"},"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-15T17:31:55.132919+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.solar_inverter_energy_consumed","hidden_by":null,"icon":null,"id":"788c979322d75e31c23f144e5a5ae91e","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:31:55.133260+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"original_device_class":"energy","original_icon":"mdi:flash","original_name":"Solar Inverter Energy Consumed","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_solar_inverter_energy_consumed","previous_unique_id":null,"unit_of_measurement":"Wh"}, - {"aliases":[],"area_id":null,"categories":{},"capabilities":{"state_class":"measurement"},"config_entry_id":"01K2QCDYNAJT9RVZEFGRAZG5X6","config_subentry_id":null,"created_at":"2025-08-13T18:47:05.951473+00:00","device_class":null,"device_id":"a00dc2483acb31f18fb95e5d7a6ee3a4","disabled_by":null,"entity_category":null,"entity_id":"sensor.span_panel_span_storage_battery_percentage","hidden_by":null,"icon":null,"id":"e9d1e9107609826104e27186528e1ba6","has_entity_name":false,"labels":[],"modified_at":"2025-08-15T17:31:55.178748+00:00","name":null,"options":{"conversation":{"should_expose":false}},"original_device_class":"battery","original_icon":"mdi:battery","original_name":"SPAN Storage Battery Percentage","platform":"span_panel","suggested_object_id":null,"supported_features":0,"translation_key":null,"unique_id":"span_nj-2316-005k6_batteryPercentage","previous_unique_id":null,"unit_of_measurement":"%"} - ], - "deleted_entities": [ - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.848789+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_2_power","hidden_by":null,"icon":null,"id":"808cbd5c6138e389ef0af19c89450fb4","labels":[],"modified_at":"2025-08-13T18:33:35.796773+00:00","name":null,"options":{"conversation":{"should_expose":false},"sensor":{"suggested_display_precision":0}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_0dad2f16cd514812ae1807b0457d473e_power"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.849038+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_4_power","hidden_by":null,"icon":null,"id":"46bb736639b15e9a3d83feeda164d353","labels":[],"modified_at":"2025-08-13T18:33:35.796800+00:00","name":null,"options":{"conversation":{"should_expose":false},"sensor":{"suggested_display_precision":0}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_11a47a0f69d54e12b7200f730c2ffda1_power"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.849275+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_14_power","hidden_by":null,"icon":null,"id":"4c1a95ce6da6c8f8a0a7e322462cfa71","labels":[],"modified_at":"2025-08-13T18:33:35.796827+00:00","name":null,"options":{"conversation":{"should_expose":false},"sensor":{"suggested_display_precision":0}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_12ce227695cd44338864b0ef2ec4168b_power"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.850201+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_24_power","hidden_by":null,"icon":null,"id":"08d1fce03c6fce3a73fcd510c109e750","labels":[],"modified_at":"2025-08-13T18:33:35.796851+00:00","name":null,"options":{"conversation":{"should_expose":false},"sensor":{"suggested_display_precision":0}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_1ad73b0eb44e4022bb6270c76baed0c1_power"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.850595+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_16_power","hidden_by":null,"icon":null,"id":"a4e06e925cc86af49c0dac4531bd9ffe","labels":[],"modified_at":"2025-08-13T18:33:35.796879+00:00","name":null,"options":{"conversation":{"should_expose":false},"sensor":{"suggested_display_precision":0}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_31b36cde0fc642b39eec515267707a6f_power"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.850851+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_19_power","hidden_by":null,"icon":null,"id":"7c67df022b0ab1d2203bd030a9108114","labels":[],"modified_at":"2025-08-13T18:33:35.796907+00:00","name":null,"options":{"conversation":{"should_expose":false},"sensor":{"suggested_display_precision":0}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_3a847fe6eb374ec3bf92cee1ffaf2eda_power"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.851162+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_12_power","hidden_by":null,"icon":null,"id":"a2edd951e6b91d057b4339351d89a896","labels":[],"modified_at":"2025-08-13T18:33:35.796934+00:00","name":null,"options":{"conversation":{"should_expose":false},"sensor":{"suggested_display_precision":0}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_497ae7ebb2844772bf926f2f094c81bc_power"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.851387+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_29_power","hidden_by":null,"icon":null,"id":"6d253f88fdb2c0bf2d76ec2dc1caf87a","labels":[],"modified_at":"2025-08-13T18:33:35.796960+00:00","name":null,"options":{"conversation":{"should_expose":false},"sensor":{"suggested_display_precision":0}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_617059df47bb49bd8545a36a6b6b23d2_power"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.851625+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_10_power","hidden_by":null,"icon":null,"id":"544e891765d44c5768796c475754fc8d","labels":[],"modified_at":"2025-08-13T18:33:35.796988+00:00","name":null,"options":{"conversation":{"should_expose":false},"sensor":{"suggested_display_precision":0}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_6ad65cf2ad6443448e33973f26f7df3e_power"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.851838+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_25_power","hidden_by":null,"icon":null,"id":"81251feabd0ce07e232feef278857d82","labels":[],"modified_at":"2025-08-13T18:33:35.797024+00:00","name":null,"options":{"conversation":{"should_expose":false},"sensor":{"suggested_display_precision":0}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_795e8eddb4f448af9625130332a41df8_power"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.852103+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_23_power","hidden_by":null,"icon":null,"id":"7061756b76400dbb4cadaf795dd51bca","labels":[],"modified_at":"2025-08-13T18:33:35.797052+00:00","name":null,"options":{"conversation":{"should_expose":false},"sensor":{"suggested_display_precision":0}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_82a0888dc072416598d99f8b74ee3d8d_power"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.852378+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_18_power","hidden_by":null,"icon":null,"id":"b1e68b4ea9eca8cc9feab4a118febc88","labels":[],"modified_at":"2025-08-13T18:33:35.797078+00:00","name":null,"options":{"conversation":{"should_expose":false},"sensor":{"suggested_display_precision":0}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_8a2ffda9dbd24bada9a01b880e910612_power"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.852592+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_9_power","hidden_by":null,"icon":null,"id":"1001fa7cc8cf6db7c40c9a4a11ca2f75","labels":[],"modified_at":"2025-08-13T18:33:35.797105+00:00","name":null,"options":{"conversation":{"should_expose":false},"sensor":{"suggested_display_precision":0}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_914943d4798c462cadf5e5144989dd5b_power"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.852800+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_1_power","hidden_by":null,"icon":null,"id":"a8127dad86761b405d2882223b22e75b","labels":[],"modified_at":"2025-08-13T18:33:35.797129+00:00","name":null,"options":{"conversation":{"should_expose":false},"sensor":{"suggested_display_precision":0}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_926441605113492189f9aa13dac356cd_power"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.853409+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_15_power","hidden_by":null,"icon":null,"id":"31e77b72e1286df7e7a22d3ae708c728","labels":[],"modified_at":"2025-08-13T18:33:35.797158+00:00","name":null,"options":{"conversation":{"should_expose":false},"sensor":{"suggested_display_precision":0}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_941d6a8b41ab4c57a6b8be14b5981fe6_power"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.853683+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_7_power","hidden_by":null,"icon":null,"id":"9b34ed1127961604c9674876992b6af1","labels":[],"modified_at":"2025-08-13T18:33:35.797182+00:00","name":null,"options":{"conversation":{"should_expose":false},"sensor":{"suggested_display_precision":0}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_988af7bb1fc04aac8bb3b45c660d920a_power"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.853947+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_22_power","hidden_by":null,"icon":null,"id":"9ccfd2a6ad3cf45528738544e866aa8a","labels":[],"modified_at":"2025-08-13T18:33:35.797210+00:00","name":null,"options":{"conversation":{"should_expose":false},"sensor":{"suggested_display_precision":0}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_9941b417bfa046ef908d97c998c54c50_power"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.854268+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_6_power","hidden_by":null,"icon":null,"id":"f01a1b09a0b8767e1c651baa086a82bf","labels":[],"modified_at":"2025-08-13T18:33:35.797246+00:00","name":null,"options":{"conversation":{"should_expose":false},"sensor":{"suggested_display_precision":0}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_cbe98d7307ca42658983b9f673211e14_power"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.854506+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_11_power","hidden_by":null,"icon":null,"id":"0b0ec77e24c008b39d13839762a7245e","labels":[],"modified_at":"2025-08-13T18:33:35.797277+00:00","name":null,"options":{"conversation":{"should_expose":false},"sensor":{"suggested_display_precision":0}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_d88c0c0e7c584472b2ec7e4d3c53c3b8_power"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.854784+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_8_power","hidden_by":null,"icon":null,"id":"3ffe5f5eea79b511979eb2c41d639a3a","labels":[],"modified_at":"2025-08-13T18:33:35.797306+00:00","name":null,"options":{"conversation":{"should_expose":false},"sensor":{"suggested_display_precision":0}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_e3cb78d38dae41cdb50faee7bfa4ab80_power"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.855111+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_3_power","hidden_by":null,"icon":null,"id":"16a42513be41d433858e97f70aebe650","labels":[],"modified_at":"2025-08-13T18:33:35.797337+00:00","name":null,"options":{"conversation":{"should_expose":false},"sensor":{"suggested_display_precision":0}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_ece352c3dc5c417e80757b716f24ba90_power"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.855721+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_5_power","hidden_by":null,"icon":null,"id":"04e738f071279ac2ae07d61bc6715e5f","labels":[],"modified_at":"2025-08-13T18:33:35.797365+00:00","name":null,"options":{"conversation":{"should_expose":false},"sensor":{"suggested_display_precision":0}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_f19d909abcc4421f9ec630f82458c7ac_power"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2FZ30F62NF62FJ79KJ5ZXHK","config_subentry_id":null,"created_at":"2025-08-12T20:21:01.544626+00:00","device_class":null,"disabled_by":"integration","entity_id":"binary_sensor.sun_solar_rising_3","hidden_by":null,"icon":null,"id":"bbe4113be77060eafb43ec8518793f00","labels":[],"modified_at":"2025-08-12T20:49:00.956736+00:00","name":null,"options":{},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2FZ30F62NF62FJ79KJ5ZXHK-solar_rising"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2FZ30F62NF62FJ79KJ5ZXHK","config_subentry_id":null,"created_at":"2025-08-12T20:21:01.545280+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_dawn_3","hidden_by":null,"icon":null,"id":"55f6a91678b3459e4cae6040ec753401","labels":[],"modified_at":"2025-08-12T20:49:00.956869+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2FZ30F62NF62FJ79KJ5ZXHK-next_dawn"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2FZ30F62NF62FJ79KJ5ZXHK","config_subentry_id":null,"created_at":"2025-08-12T20:21:01.545515+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_dusk_3","hidden_by":null,"icon":null,"id":"e5905681bd77dcc1d4fa2ce856600d46","labels":[],"modified_at":"2025-08-12T20:49:00.956952+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2FZ30F62NF62FJ79KJ5ZXHK-next_dusk"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2FZ30F62NF62FJ79KJ5ZXHK","config_subentry_id":null,"created_at":"2025-08-12T20:21:01.545939+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_midnight_3","hidden_by":null,"icon":null,"id":"d69c42bb7be140251ae233402795cc20","labels":[],"modified_at":"2025-08-12T20:49:00.956991+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2FZ30F62NF62FJ79KJ5ZXHK-next_midnight"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2FZ30F62NF62FJ79KJ5ZXHK","config_subentry_id":null,"created_at":"2025-08-12T20:21:01.546178+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_noon_3","hidden_by":null,"icon":null,"id":"75e1512e025bf6fd79661f5a2fd3e35f","labels":[],"modified_at":"2025-08-12T20:49:00.957066+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2FZ30F62NF62FJ79KJ5ZXHK-next_noon"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2FZ30F62NF62FJ79KJ5ZXHK","config_subentry_id":null,"created_at":"2025-08-12T20:21:01.546328+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_rising_3","hidden_by":null,"icon":null,"id":"b1655977ea34852f5bb7abf947ba0365","labels":[],"modified_at":"2025-08-12T20:49:00.957095+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2FZ30F62NF62FJ79KJ5ZXHK-next_rising"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2FZ30F62NF62FJ79KJ5ZXHK","config_subentry_id":null,"created_at":"2025-08-12T20:21:01.546442+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_setting_3","hidden_by":null,"icon":null,"id":"62730a8e0bd730e400ecbb74bc806dcb","labels":[],"modified_at":"2025-08-12T20:49:00.957122+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2FZ30F62NF62FJ79KJ5ZXHK-next_setting"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2FZ30F62NF62FJ79KJ5ZXHK","config_subentry_id":null,"created_at":"2025-08-12T20:21:01.546541+00:00","device_class":null,"disabled_by":"integration","entity_id":"sensor.sun_solar_elevation_3","hidden_by":null,"icon":null,"id":"136e4a6f2c7639255e89866a9aff6cfd","labels":[],"modified_at":"2025-08-12T20:49:00.957147+00:00","name":null,"options":{},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2FZ30F62NF62FJ79KJ5ZXHK-solar_elevation"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2FZ30F62NF62FJ79KJ5ZXHK","config_subentry_id":null,"created_at":"2025-08-12T20:21:01.546599+00:00","device_class":null,"disabled_by":"integration","entity_id":"sensor.sun_solar_azimuth_3","hidden_by":null,"icon":null,"id":"60b33e14ac5406effc757c750b4f3c56","labels":[],"modified_at":"2025-08-12T20:49:00.957167+00:00","name":null,"options":{},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2FZ30F62NF62FJ79KJ5ZXHK-solar_azimuth"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2FZ30F62NF62FJ79KJ5ZXHK","config_subentry_id":null,"created_at":"2025-08-12T20:21:01.546649+00:00","device_class":null,"disabled_by":"integration","entity_id":"sensor.sun_solar_rising_3","hidden_by":null,"icon":null,"id":"3b90254bfc80421e1b0619ff78f2e5ed","labels":[],"modified_at":"2025-08-12T20:49:00.957190+00:00","name":null,"options":{},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2FZ30F62NF62FJ79KJ5ZXHK-solar_rising"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2G0NYH80DCKVGCMD9AS1TTS","config_subentry_id":null,"created_at":"2025-08-12T20:48:50.729971+00:00","device_class":null,"disabled_by":"integration","entity_id":"binary_sensor.sun_solar_rising_4","hidden_by":null,"icon":null,"id":"4a78bb88e4d02103c19171414e7bb6d5","labels":[],"modified_at":"2025-08-12T21:03:25.826235+00:00","name":null,"options":{},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2G0NYH80DCKVGCMD9AS1TTS-solar_rising"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2G0NYH80DCKVGCMD9AS1TTS","config_subentry_id":null,"created_at":"2025-08-12T20:48:50.730481+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_dawn_4","hidden_by":null,"icon":null,"id":"5f51661d9af9833d8ab2e97896eeae8c","labels":[],"modified_at":"2025-08-12T21:03:25.826399+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2G0NYH80DCKVGCMD9AS1TTS-next_dawn"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2G0NYH80DCKVGCMD9AS1TTS","config_subentry_id":null,"created_at":"2025-08-12T20:48:50.730684+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_dusk_4","hidden_by":null,"icon":null,"id":"4cbaa44cd430215ae208349f2b353a8a","labels":[],"modified_at":"2025-08-12T21:03:25.826491+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2G0NYH80DCKVGCMD9AS1TTS-next_dusk"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2G0NYH80DCKVGCMD9AS1TTS","config_subentry_id":null,"created_at":"2025-08-12T20:48:50.730806+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_midnight_4","hidden_by":null,"icon":null,"id":"7bac2228d74421a0ccea5eaec5035073","labels":[],"modified_at":"2025-08-12T21:03:25.826533+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2G0NYH80DCKVGCMD9AS1TTS-next_midnight"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2G0NYH80DCKVGCMD9AS1TTS","config_subentry_id":null,"created_at":"2025-08-12T20:48:50.730909+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_noon_4","hidden_by":null,"icon":null,"id":"e48d755ea0e0643621b23872f84297bf","labels":[],"modified_at":"2025-08-12T21:03:25.826570+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2G0NYH80DCKVGCMD9AS1TTS-next_noon"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2G0NYH80DCKVGCMD9AS1TTS","config_subentry_id":null,"created_at":"2025-08-12T20:48:50.731013+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_rising_4","hidden_by":null,"icon":null,"id":"fd07e450559468a027639d53ccc9bc6a","labels":[],"modified_at":"2025-08-12T21:03:25.826605+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2G0NYH80DCKVGCMD9AS1TTS-next_rising"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2G0NYH80DCKVGCMD9AS1TTS","config_subentry_id":null,"created_at":"2025-08-12T20:48:50.731130+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_setting_4","hidden_by":null,"icon":null,"id":"f47902b0280403b5f0e94c8c3024c50f","labels":[],"modified_at":"2025-08-12T21:03:25.826641+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2G0NYH80DCKVGCMD9AS1TTS-next_setting"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2G0NYH80DCKVGCMD9AS1TTS","config_subentry_id":null,"created_at":"2025-08-12T20:48:50.731242+00:00","device_class":null,"disabled_by":"integration","entity_id":"sensor.sun_solar_elevation_4","hidden_by":null,"icon":null,"id":"45310e2fb4635e4bae111090e20a1ace","labels":[],"modified_at":"2025-08-12T21:03:25.826674+00:00","name":null,"options":{},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2G0NYH80DCKVGCMD9AS1TTS-solar_elevation"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2G0NYH80DCKVGCMD9AS1TTS","config_subentry_id":null,"created_at":"2025-08-12T20:48:50.731307+00:00","device_class":null,"disabled_by":"integration","entity_id":"sensor.sun_solar_azimuth_4","hidden_by":null,"icon":null,"id":"336e819bf7d6789704abbd789f79040b","labels":[],"modified_at":"2025-08-12T21:03:25.826700+00:00","name":null,"options":{},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2G0NYH80DCKVGCMD9AS1TTS-solar_azimuth"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2G0NYH80DCKVGCMD9AS1TTS","config_subentry_id":null,"created_at":"2025-08-12T20:48:50.731359+00:00","device_class":null,"disabled_by":"integration","entity_id":"sensor.sun_solar_rising_4","hidden_by":null,"icon":null,"id":"a3bd32eda3845cf54516a975cb5cce09","labels":[],"modified_at":"2025-08-12T21:03:25.826724+00:00","name":null,"options":{},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2G0NYH80DCKVGCMD9AS1TTS-solar_rising"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.846080+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_current_power","hidden_by":null,"icon":null,"id":"444d9a4d8e0f4acc5b63044d0db4194c","labels":[],"modified_at":"2025-08-13T18:33:35.796474+00:00","name":null,"options":{"conversation":{"should_expose":false},"sensor":{"suggested_display_precision":0}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_current_power"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.846478+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_feed_through_power","hidden_by":null,"icon":null,"id":"7ae32c94f4a624ed9364aaf3e925251d","labels":[],"modified_at":"2025-08-13T18:33:35.796503+00:00","name":null,"options":{"conversation":{"should_expose":false},"sensor":{"suggested_display_precision":0}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_feed_through_power"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2G1GB4DMFSVY3M5FETX1M19","config_subentry_id":null,"created_at":"2025-08-12T21:03:15.598833+00:00","device_class":null,"disabled_by":"integration","entity_id":"binary_sensor.sun_solar_rising_3","hidden_by":null,"icon":null,"id":"bb9876bd58a8f8c291dcba452410d4b1","labels":[],"modified_at":"2025-08-12T21:27:09.457808+00:00","name":null,"options":{},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2G1GB4DMFSVY3M5FETX1M19-solar_rising"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2G1GB4DMFSVY3M5FETX1M19","config_subentry_id":null,"created_at":"2025-08-12T21:03:15.599238+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_dawn_3","hidden_by":null,"icon":null,"id":"fb6325652926e58235d853b7a8c7c514","labels":[],"modified_at":"2025-08-12T21:27:09.457870+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2G1GB4DMFSVY3M5FETX1M19-next_dawn"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2G1GB4DMFSVY3M5FETX1M19","config_subentry_id":null,"created_at":"2025-08-12T21:03:15.599482+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_dusk_3","hidden_by":null,"icon":null,"id":"fb6eda8a86c7bdb6636a9cd636d9d9d1","labels":[],"modified_at":"2025-08-12T21:27:09.457932+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2G1GB4DMFSVY3M5FETX1M19-next_dusk"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2G1GB4DMFSVY3M5FETX1M19","config_subentry_id":null,"created_at":"2025-08-12T21:03:15.599642+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_midnight_3","hidden_by":null,"icon":null,"id":"c489480c63b490d237664e65b92988cd","labels":[],"modified_at":"2025-08-12T21:27:09.457964+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2G1GB4DMFSVY3M5FETX1M19-next_midnight"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2G1GB4DMFSVY3M5FETX1M19","config_subentry_id":null,"created_at":"2025-08-12T21:03:15.599784+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_noon_3","hidden_by":null,"icon":null,"id":"f21f5b2b38c8761c19962297646fe8fb","labels":[],"modified_at":"2025-08-12T21:27:09.457991+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2G1GB4DMFSVY3M5FETX1M19-next_noon"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2G1GB4DMFSVY3M5FETX1M19","config_subentry_id":null,"created_at":"2025-08-12T21:03:15.599923+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_rising_3","hidden_by":null,"icon":null,"id":"e4f3ac5ad411e5906fa7bb9938090639","labels":[],"modified_at":"2025-08-12T21:27:09.458061+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2G1GB4DMFSVY3M5FETX1M19-next_rising"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2G1GB4DMFSVY3M5FETX1M19","config_subentry_id":null,"created_at":"2025-08-12T21:03:15.600266+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_setting_3","hidden_by":null,"icon":null,"id":"52d4f2329ee041ddc1e00e23c4813e42","labels":[],"modified_at":"2025-08-12T21:27:09.458090+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2G1GB4DMFSVY3M5FETX1M19-next_setting"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2G1GB4DMFSVY3M5FETX1M19","config_subentry_id":null,"created_at":"2025-08-12T21:03:15.600448+00:00","device_class":null,"disabled_by":"integration","entity_id":"sensor.sun_solar_elevation_3","hidden_by":null,"icon":null,"id":"45fe4f3258240d7db77198b0cfbe538d","labels":[],"modified_at":"2025-08-12T21:27:09.458116+00:00","name":null,"options":{},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2G1GB4DMFSVY3M5FETX1M19-solar_elevation"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2G1GB4DMFSVY3M5FETX1M19","config_subentry_id":null,"created_at":"2025-08-12T21:03:15.600550+00:00","device_class":null,"disabled_by":"integration","entity_id":"sensor.sun_solar_azimuth_3","hidden_by":null,"icon":null,"id":"f9b9eaedf5c19bbb09dbb15a4eb65e19","labels":[],"modified_at":"2025-08-12T21:27:09.458136+00:00","name":null,"options":{},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2G1GB4DMFSVY3M5FETX1M19-solar_azimuth"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2G1GB4DMFSVY3M5FETX1M19","config_subentry_id":null,"created_at":"2025-08-12T21:03:15.600631+00:00","device_class":null,"disabled_by":"integration","entity_id":"sensor.sun_solar_rising_3","hidden_by":null,"icon":null,"id":"22de19030595f3af919adf8870163516","labels":[],"modified_at":"2025-08-12T21:27:09.458155+00:00","name":null,"options":{},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2G1GB4DMFSVY3M5FETX1M19-solar_rising"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2G2VSCAFTC14E0R1NPSX97N","config_subentry_id":null,"created_at":"2025-08-12T21:26:59.212526+00:00","device_class":null,"disabled_by":"integration","entity_id":"binary_sensor.sun_solar_rising_4","hidden_by":null,"icon":null,"id":"f34eec7fa1cca27a98309c159d3fc53b","labels":[],"modified_at":"2025-08-12T21:36:44.167709+00:00","name":null,"options":{},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2G2VSCAFTC14E0R1NPSX97N-solar_rising"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2G2VSCAFTC14E0R1NPSX97N","config_subentry_id":null,"created_at":"2025-08-12T21:26:59.212924+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_dawn_4","hidden_by":null,"icon":null,"id":"260b86a3672af689ab395a740d9b0267","labels":[],"modified_at":"2025-08-12T21:36:44.167791+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2G2VSCAFTC14E0R1NPSX97N-next_dawn"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2G2VSCAFTC14E0R1NPSX97N","config_subentry_id":null,"created_at":"2025-08-12T21:26:59.213270+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_dusk_4","hidden_by":null,"icon":null,"id":"ea991eda8c4698f00c39ed1f00699fd0","labels":[],"modified_at":"2025-08-12T21:36:44.167862+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2G2VSCAFTC14E0R1NPSX97N-next_dusk"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2G2VSCAFTC14E0R1NPSX97N","config_subentry_id":null,"created_at":"2025-08-12T21:26:59.213649+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_midnight_4","hidden_by":null,"icon":null,"id":"46b4f2146e5bdc5c81e7968028992d40","labels":[],"modified_at":"2025-08-12T21:36:44.167896+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2G2VSCAFTC14E0R1NPSX97N-next_midnight"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2G2VSCAFTC14E0R1NPSX97N","config_subentry_id":null,"created_at":"2025-08-12T21:26:59.213814+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_noon_4","hidden_by":null,"icon":null,"id":"0e36c32cd00e16318e0cdde52081dbfa","labels":[],"modified_at":"2025-08-12T21:36:44.167928+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2G2VSCAFTC14E0R1NPSX97N-next_noon"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2G2VSCAFTC14E0R1NPSX97N","config_subentry_id":null,"created_at":"2025-08-12T21:26:59.213971+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_rising_4","hidden_by":null,"icon":null,"id":"07afd6aaf4e22a01a640996215b80925","labels":[],"modified_at":"2025-08-12T21:36:44.167958+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2G2VSCAFTC14E0R1NPSX97N-next_rising"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2G2VSCAFTC14E0R1NPSX97N","config_subentry_id":null,"created_at":"2025-08-12T21:26:59.214219+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_setting_4","hidden_by":null,"icon":null,"id":"9c33ea7bc2d915be6876bb5d75325823","labels":[],"modified_at":"2025-08-12T21:36:44.167992+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2G2VSCAFTC14E0R1NPSX97N-next_setting"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2G2VSCAFTC14E0R1NPSX97N","config_subentry_id":null,"created_at":"2025-08-12T21:26:59.214471+00:00","device_class":null,"disabled_by":"integration","entity_id":"sensor.sun_solar_elevation_4","hidden_by":null,"icon":null,"id":"67015cb27bade0b4d2a8f7ea6dcaa3f3","labels":[],"modified_at":"2025-08-12T21:36:44.168034+00:00","name":null,"options":{},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2G2VSCAFTC14E0R1NPSX97N-solar_elevation"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2G2VSCAFTC14E0R1NPSX97N","config_subentry_id":null,"created_at":"2025-08-12T21:26:59.214567+00:00","device_class":null,"disabled_by":"integration","entity_id":"sensor.sun_solar_azimuth_4","hidden_by":null,"icon":null,"id":"10c2de5be03b1d697c0f4856f27e3f53","labels":[],"modified_at":"2025-08-12T21:36:44.168055+00:00","name":null,"options":{},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2G2VSCAFTC14E0R1NPSX97N-solar_azimuth"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2G2VSCAFTC14E0R1NPSX97N","config_subentry_id":null,"created_at":"2025-08-12T21:26:59.214656+00:00","device_class":null,"disabled_by":"integration","entity_id":"sensor.sun_solar_rising_4","hidden_by":null,"icon":null,"id":"05f30e0ad071ad946197a29449280bdc","labels":[],"modified_at":"2025-08-12T21:36:44.168076+00:00","name":null,"options":{},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2G2VSCAFTC14E0R1NPSX97N-solar_rising"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2G3DAKZMG3KMB8ZX5Z54T3R","config_subentry_id":null,"created_at":"2025-08-12T21:36:33.921587+00:00","device_class":null,"disabled_by":"integration","entity_id":"binary_sensor.sun_solar_rising_3","hidden_by":null,"icon":null,"id":"174a2af606c3714448f7e673ed028dff","labels":[],"modified_at":"2025-08-12T21:44:04.774237+00:00","name":null,"options":{},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2G3DAKZMG3KMB8ZX5Z54T3R-solar_rising"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2G3DAKZMG3KMB8ZX5Z54T3R","config_subentry_id":null,"created_at":"2025-08-12T21:36:33.921925+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_dawn_3","hidden_by":null,"icon":null,"id":"39a64aae71c20d2d18328fe749ec67df","labels":[],"modified_at":"2025-08-12T21:44:04.774295+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2G3DAKZMG3KMB8ZX5Z54T3R-next_dawn"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2G3DAKZMG3KMB8ZX5Z54T3R","config_subentry_id":null,"created_at":"2025-08-12T21:36:33.922375+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_dusk_3","hidden_by":null,"icon":null,"id":"ad166779b42a89079e20526dbee58d65","labels":[],"modified_at":"2025-08-12T21:44:04.774348+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2G3DAKZMG3KMB8ZX5Z54T3R-next_dusk"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2G3DAKZMG3KMB8ZX5Z54T3R","config_subentry_id":null,"created_at":"2025-08-12T21:36:33.922545+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_midnight_3","hidden_by":null,"icon":null,"id":"2478ce86e9a0bf20e0284b6c96c57e0c","labels":[],"modified_at":"2025-08-12T21:44:04.774379+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2G3DAKZMG3KMB8ZX5Z54T3R-next_midnight"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2G3DAKZMG3KMB8ZX5Z54T3R","config_subentry_id":null,"created_at":"2025-08-12T21:36:33.922683+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_noon_3","hidden_by":null,"icon":null,"id":"09f8dedc8c10f6a96e4d35c579848092","labels":[],"modified_at":"2025-08-12T21:44:04.774409+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2G3DAKZMG3KMB8ZX5Z54T3R-next_noon"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2G3DAKZMG3KMB8ZX5Z54T3R","config_subentry_id":null,"created_at":"2025-08-12T21:36:33.922799+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_rising_3","hidden_by":null,"icon":null,"id":"cb1f2e9ecd3621b07522913b01783a71","labels":[],"modified_at":"2025-08-12T21:44:04.774441+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2G3DAKZMG3KMB8ZX5Z54T3R-next_rising"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2G3DAKZMG3KMB8ZX5Z54T3R","config_subentry_id":null,"created_at":"2025-08-12T21:36:33.923135+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_setting_3","hidden_by":null,"icon":null,"id":"d4aa898f5a60e230d1c06baf16c19a8b","labels":[],"modified_at":"2025-08-12T21:44:04.774494+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2G3DAKZMG3KMB8ZX5Z54T3R-next_setting"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2G3DAKZMG3KMB8ZX5Z54T3R","config_subentry_id":null,"created_at":"2025-08-12T21:36:33.923354+00:00","device_class":null,"disabled_by":"integration","entity_id":"sensor.sun_solar_elevation_3","hidden_by":null,"icon":null,"id":"938e0d3df9cfce11cb7033ea0b706a22","labels":[],"modified_at":"2025-08-12T21:44:04.774536+00:00","name":null,"options":{},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2G3DAKZMG3KMB8ZX5Z54T3R-solar_elevation"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2G3DAKZMG3KMB8ZX5Z54T3R","config_subentry_id":null,"created_at":"2025-08-12T21:36:33.923445+00:00","device_class":null,"disabled_by":"integration","entity_id":"sensor.sun_solar_azimuth_3","hidden_by":null,"icon":null,"id":"750aaec1493d553b262b70d9b8539072","labels":[],"modified_at":"2025-08-12T21:44:04.774567+00:00","name":null,"options":{},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2G3DAKZMG3KMB8ZX5Z54T3R-solar_azimuth"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2G3DAKZMG3KMB8ZX5Z54T3R","config_subentry_id":null,"created_at":"2025-08-12T21:36:33.923528+00:00","device_class":null,"disabled_by":"integration","entity_id":"sensor.sun_solar_rising_3","hidden_by":null,"icon":null,"id":"b6998015054ef5b64dd1225326cbf164","labels":[],"modified_at":"2025-08-12T21:44:04.774602+00:00","name":null,"options":{},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2G3DAKZMG3KMB8ZX5Z54T3R-solar_rising"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2G3TRX0NV4DS8C09G5B4TYT","config_subentry_id":null,"created_at":"2025-08-12T21:43:54.531232+00:00","device_class":null,"disabled_by":"integration","entity_id":"binary_sensor.sun_solar_rising_4","hidden_by":null,"icon":null,"id":"a78ff0aa891059820df9e6d055e3c8a6","labels":[],"modified_at":"2025-08-13T05:01:09.287613+00:00","name":null,"options":{},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2G3TRX0NV4DS8C09G5B4TYT-solar_rising"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2G3TRX0NV4DS8C09G5B4TYT","config_subentry_id":null,"created_at":"2025-08-12T21:43:54.531859+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_dawn_4","hidden_by":null,"icon":null,"id":"5680aa6b8824fff96dbf36c467fd079b","labels":[],"modified_at":"2025-08-13T05:01:09.287671+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2G3TRX0NV4DS8C09G5B4TYT-next_dawn"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2G3TRX0NV4DS8C09G5B4TYT","config_subentry_id":null,"created_at":"2025-08-12T21:43:54.532173+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_dusk_4","hidden_by":null,"icon":null,"id":"274e3184f58490030d118d4fd32b6649","labels":[],"modified_at":"2025-08-13T05:01:09.287724+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2G3TRX0NV4DS8C09G5B4TYT-next_dusk"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2G3TRX0NV4DS8C09G5B4TYT","config_subentry_id":null,"created_at":"2025-08-12T21:43:54.533321+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_midnight_4","hidden_by":null,"icon":null,"id":"0709fad4501d4cfeb21767fac40d4945","labels":[],"modified_at":"2025-08-13T05:01:09.287753+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2G3TRX0NV4DS8C09G5B4TYT-next_midnight"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2G3TRX0NV4DS8C09G5B4TYT","config_subentry_id":null,"created_at":"2025-08-12T21:43:54.533551+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_noon_4","hidden_by":null,"icon":null,"id":"d0d997ed057e3d4101b211de7e259104","labels":[],"modified_at":"2025-08-13T05:01:09.287778+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2G3TRX0NV4DS8C09G5B4TYT-next_noon"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2G3TRX0NV4DS8C09G5B4TYT","config_subentry_id":null,"created_at":"2025-08-12T21:43:54.533748+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_rising_4","hidden_by":null,"icon":null,"id":"0d70671f807592367b26ad774fed32f1","labels":[],"modified_at":"2025-08-13T05:01:09.287802+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2G3TRX0NV4DS8C09G5B4TYT-next_rising"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2G3TRX0NV4DS8C09G5B4TYT","config_subentry_id":null,"created_at":"2025-08-12T21:43:54.533927+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_setting_4","hidden_by":null,"icon":null,"id":"7ffb310bd02e4823ca0ec8101b72f49e","labels":[],"modified_at":"2025-08-13T05:01:09.287827+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2G3TRX0NV4DS8C09G5B4TYT-next_setting"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2G3TRX0NV4DS8C09G5B4TYT","config_subentry_id":null,"created_at":"2025-08-12T21:43:54.535135+00:00","device_class":null,"disabled_by":"integration","entity_id":"sensor.sun_solar_elevation_4","hidden_by":null,"icon":null,"id":"da36bf42c53ebc5502b977234f86579f","labels":[],"modified_at":"2025-08-13T05:01:09.287851+00:00","name":null,"options":{},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2G3TRX0NV4DS8C09G5B4TYT-solar_elevation"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2G3TRX0NV4DS8C09G5B4TYT","config_subentry_id":null,"created_at":"2025-08-12T21:43:54.535252+00:00","device_class":null,"disabled_by":"integration","entity_id":"sensor.sun_solar_azimuth_4","hidden_by":null,"icon":null,"id":"7bc36f6a2137e7e1cb43dc9dfb98aadc","labels":[],"modified_at":"2025-08-13T05:01:09.287869+00:00","name":null,"options":{},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2G3TRX0NV4DS8C09G5B4TYT-solar_azimuth"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2G3TRX0NV4DS8C09G5B4TYT","config_subentry_id":null,"created_at":"2025-08-12T21:43:54.535333+00:00","device_class":null,"disabled_by":"integration","entity_id":"sensor.sun_solar_rising_4","hidden_by":null,"icon":null,"id":"1b5d07868c9ea67a5199b9bfb18d1ac1","labels":[],"modified_at":"2025-08-13T05:01:09.287886+00:00","name":null,"options":{},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2G3TRX0NV4DS8C09G5B4TYT-solar_rising"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.846769+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_main_meter_produced_energy","hidden_by":null,"icon":null,"id":"39a4a90bd2bc78e45a68b1f915ea2858","labels":[],"modified_at":"2025-08-13T18:33:35.796531+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_mainmeterenergyproducedenergywh"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.847076+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_main_meter_consumed_energy","hidden_by":null,"icon":null,"id":"e7d443c6389fbf5a9111c27f7f9c8c37","labels":[],"modified_at":"2025-08-13T18:33:35.796559+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_mainmeterenergyconsumedenergywh"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.847311+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_feed_through_produced_energy","hidden_by":null,"icon":null,"id":"e3730461ab714aa2c6b0e12d487335f2","labels":[],"modified_at":"2025-08-13T18:33:35.796586+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_feedthroughenergyproducedenergywh"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.847532+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_feed_through_consumed_energy","hidden_by":null,"icon":null,"id":"56ad42fa0fb2d963e6fc8546ddc0336a","labels":[],"modified_at":"2025-08-13T18:33:35.796612+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_feedthroughenergyconsumedenergywh"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.354911+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_2_power","hidden_by":null,"icon":null,"id":"6f278368ec1c65ceab4d2f81d2455718","labels":[],"modified_at":"2025-08-13T06:16:22.369294+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_0dad2f16cd514812ae1807b0457d473e_instantpowerw"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.355206+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_4_power","hidden_by":null,"icon":null,"id":"d8d70af78fef60a31a7194cca82439fd","labels":[],"modified_at":"2025-08-13T06:16:22.369385+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_11a47a0f69d54e12b7200f730c2ffda1_instantpowerw"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.355466+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_14_power","hidden_by":null,"icon":null,"id":"13c0531bb8b8aeb88b961387f3f190d8","labels":[],"modified_at":"2025-08-13T06:16:22.369487+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_12ce227695cd44338864b0ef2ec4168b_instantpowerw"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.355729+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_24_power","hidden_by":null,"icon":null,"id":"24fc30f14c03697eca9552b225665e63","labels":[],"modified_at":"2025-08-13T06:16:22.369583+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_1ad73b0eb44e4022bb6270c76baed0c1_instantpowerw"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.355988+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_16_power","hidden_by":null,"icon":null,"id":"d06931ba75b8a8075b7c143da682350d","labels":[],"modified_at":"2025-08-13T06:16:22.369673+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_31b36cde0fc642b39eec515267707a6f_instantpowerw"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.356269+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_19_power","hidden_by":null,"icon":null,"id":"4522ded5403991beadc3157b53faca86","labels":[],"modified_at":"2025-08-13T06:16:22.369805+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_3a847fe6eb374ec3bf92cee1ffaf2eda_instantpowerw"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.356554+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_12_power","hidden_by":null,"icon":null,"id":"a1a640db10973c5d773a24fd62a480e2","labels":[],"modified_at":"2025-08-13T06:16:22.369949+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_497ae7ebb2844772bf926f2f094c81bc_instantpowerw"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.356785+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_29_power","hidden_by":null,"icon":null,"id":"3c578284a30b9e90a8d72e15e7d1b9a9","labels":[],"modified_at":"2025-08-13T06:16:22.370059+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_617059df47bb49bd8545a36a6b6b23d2_instantpowerw"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.357078+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_10_power","hidden_by":null,"icon":null,"id":"0392db851759db3ddd9afdbc02941db9","labels":[],"modified_at":"2025-08-13T06:16:22.370148+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_6ad65cf2ad6443448e33973f26f7df3e_instantpowerw"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.357307+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_25_power","hidden_by":null,"icon":null,"id":"ed91fc78b30748f31356944823897f8f","labels":[],"modified_at":"2025-08-13T06:16:22.370242+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_795e8eddb4f448af9625130332a41df8_instantpowerw"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.357514+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_23_power","hidden_by":null,"icon":null,"id":"4954c38af3dc31574703e35144415c1b","labels":[],"modified_at":"2025-08-13T06:16:22.373452+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_82a0888dc072416598d99f8b74ee3d8d_instantpowerw"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.358006+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_18_power","hidden_by":null,"icon":null,"id":"0961871276509b049a27f717f2e0a808","labels":[],"modified_at":"2025-08-13T06:16:22.373582+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_8a2ffda9dbd24bada9a01b880e910612_instantpowerw"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.358312+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_9_power","hidden_by":null,"icon":null,"id":"aba8a8eae1f2e543867dfde7dd52572c","labels":[],"modified_at":"2025-08-13T06:16:22.373673+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_914943d4798c462cadf5e5144989dd5b_instantpowerw"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.358581+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_1_power","hidden_by":null,"icon":null,"id":"f391da13f909f7fe1827eaa4b1bfa5a1","labels":[],"modified_at":"2025-08-13T06:16:22.373769+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_926441605113492189f9aa13dac356cd_instantpowerw"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.358826+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_15_power","hidden_by":null,"icon":null,"id":"fd2818fd83aba0a63332bf9416c382eb","labels":[],"modified_at":"2025-08-13T06:16:22.373855+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_941d6a8b41ab4c57a6b8be14b5981fe6_instantpowerw"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.359089+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_7_power","hidden_by":null,"icon":null,"id":"3f9ff80bfddb44a71ecd09602bf32757","labels":[],"modified_at":"2025-08-13T06:16:22.373940+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_988af7bb1fc04aac8bb3b45c660d920a_instantpowerw"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.359311+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_22_power","hidden_by":null,"icon":null,"id":"728d75768ccbca441d795d6bfe10e41c","labels":[],"modified_at":"2025-08-13T06:16:22.374085+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_9941b417bfa046ef908d97c998c54c50_instantpowerw"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.359789+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_6_power","hidden_by":null,"icon":null,"id":"436061a4fe1b35efecadc9aa33ed4587","labels":[],"modified_at":"2025-08-13T06:16:22.374195+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_cbe98d7307ca42658983b9f673211e14_instantpowerw"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.360052+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_11_power","hidden_by":null,"icon":null,"id":"deaa3bb9073d9df70065b37d6fa8c497","labels":[],"modified_at":"2025-08-13T06:16:22.374288+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_d88c0c0e7c584472b2ec7e4d3c53c3b8_instantpowerw"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.360265+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_8_power","hidden_by":null,"icon":null,"id":"8cc68d51daa1b73860c6517e5da1b0ab","labels":[],"modified_at":"2025-08-13T06:16:22.374376+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_e3cb78d38dae41cdb50faee7bfa4ab80_instantpowerw"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.360535+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_3_power","hidden_by":null,"icon":null,"id":"2bf252b57574fd4a8e1372f4017081db","labels":[],"modified_at":"2025-08-13T06:16:22.374459+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_ece352c3dc5c417e80757b716f24ba90_instantpowerw"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.360777+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_5_power","hidden_by":null,"icon":null,"id":"6a1851a8679cb2aa6511f5e3fbf478bc","labels":[],"modified_at":"2025-08-13T06:16:22.374545+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_f19d909abcc4421f9ec630f82458c7ac_instantpowerw"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.361042+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_2_energy_produced","hidden_by":null,"icon":null,"id":"32e51f8353d038d83037b6876c49d661","labels":[],"modified_at":"2025-08-13T06:16:22.374631+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_0dad2f16cd514812ae1807b0457d473e_producedenergywh"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.361368+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_4_energy_produced","hidden_by":null,"icon":null,"id":"09bfa7246fd7d7b695fb4fbb45e41eeb","labels":[],"modified_at":"2025-08-13T06:16:22.374723+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_11a47a0f69d54e12b7200f730c2ffda1_producedenergywh"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.361893+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_14_energy_produced","hidden_by":null,"icon":null,"id":"177b7dd615e2e9361ccee0fa89a25088","labels":[],"modified_at":"2025-08-13T06:16:22.374819+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_12ce227695cd44338864b0ef2ec4168b_producedenergywh"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.362344+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_24_energy_produced","hidden_by":null,"icon":null,"id":"a834930cd6c69ecca9cdfec9a8136bec","labels":[],"modified_at":"2025-08-13T06:16:22.374926+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_1ad73b0eb44e4022bb6270c76baed0c1_producedenergywh"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.362707+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_16_energy_produced","hidden_by":null,"icon":null,"id":"243774b93edcdf31b3176524abb69e12","labels":[],"modified_at":"2025-08-13T06:16:22.375058+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_31b36cde0fc642b39eec515267707a6f_producedenergywh"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.364162+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_19_energy_produced","hidden_by":null,"icon":null,"id":"dbf6ad3acc745386ae5f07b1c07ba686","labels":[],"modified_at":"2025-08-13T06:16:22.375151+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_3a847fe6eb374ec3bf92cee1ffaf2eda_producedenergywh"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.364539+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_12_energy_produced","hidden_by":null,"icon":null,"id":"1f23cefb3721adfbe60dd906e9f4e15d","labels":[],"modified_at":"2025-08-13T06:16:22.375233+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_497ae7ebb2844772bf926f2f094c81bc_producedenergywh"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.364850+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_29_energy_produced","hidden_by":null,"icon":null,"id":"bb98af3292a87812ed7d09b6b9ba2fac","labels":[],"modified_at":"2025-08-13T06:16:22.375328+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_617059df47bb49bd8545a36a6b6b23d2_producedenergywh"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.365157+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_10_energy_produced","hidden_by":null,"icon":null,"id":"e265acbe8d1ab30fcc4610c5c111c0fc","labels":[],"modified_at":"2025-08-13T06:16:22.375418+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_6ad65cf2ad6443448e33973f26f7df3e_producedenergywh"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.365406+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_25_energy_produced","hidden_by":null,"icon":null,"id":"8c0ad7c7be145aadf0066494a76a71ba","labels":[],"modified_at":"2025-08-13T06:16:22.375501+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_795e8eddb4f448af9625130332a41df8_producedenergywh"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.365725+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_23_energy_produced","hidden_by":null,"icon":null,"id":"82c131598daec8a0fa69d74704c23914","labels":[],"modified_at":"2025-08-13T06:16:22.375585+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_82a0888dc072416598d99f8b74ee3d8d_producedenergywh"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.366048+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_18_energy_produced","hidden_by":null,"icon":null,"id":"7fd9877d31c47888534d5b9f0d35ad49","labels":[],"modified_at":"2025-08-13T06:16:22.375668+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_8a2ffda9dbd24bada9a01b880e910612_producedenergywh"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.366328+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_9_energy_produced","hidden_by":null,"icon":null,"id":"4a8a67f7624e11fb6a1d1b270cdccae2","labels":[],"modified_at":"2025-08-13T06:16:22.375748+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_914943d4798c462cadf5e5144989dd5b_producedenergywh"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.366567+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_1_energy_produced","hidden_by":null,"icon":null,"id":"2af0b9f4b2be59178968561ca3dd34c7","labels":[],"modified_at":"2025-08-13T06:16:22.375830+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_926441605113492189f9aa13dac356cd_producedenergywh"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.366828+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_15_energy_produced","hidden_by":null,"icon":null,"id":"d0707002784409b42287cbb9f8e82495","labels":[],"modified_at":"2025-08-13T06:16:22.375910+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_941d6a8b41ab4c57a6b8be14b5981fe6_producedenergywh"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.367075+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_7_energy_produced","hidden_by":null,"icon":null,"id":"f361c724f2d7efa8b664cc89f7727693","labels":[],"modified_at":"2025-08-13T06:16:22.375991+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_988af7bb1fc04aac8bb3b45c660d920a_producedenergywh"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.367305+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_22_energy_produced","hidden_by":null,"icon":null,"id":"50536fe362518b07ab72b5ce6c0143dc","labels":[],"modified_at":"2025-08-13T06:16:22.376084+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_9941b417bfa046ef908d97c998c54c50_producedenergywh"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.367530+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_6_energy_produced","hidden_by":null,"icon":null,"id":"de760f1d7efa90a31801e6e71452e0b1","labels":[],"modified_at":"2025-08-13T06:16:22.376206+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_cbe98d7307ca42658983b9f673211e14_producedenergywh"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.367792+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_11_energy_produced","hidden_by":null,"icon":null,"id":"0d7b6b6c9d0769ac88412713a00bd6c7","labels":[],"modified_at":"2025-08-13T06:16:22.376304+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_d88c0c0e7c584472b2ec7e4d3c53c3b8_producedenergywh"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.368086+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_8_energy_produced","hidden_by":null,"icon":null,"id":"f81a8ee242f860411da16af10803e0a3","labels":[],"modified_at":"2025-08-13T06:16:22.376397+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_e3cb78d38dae41cdb50faee7bfa4ab80_producedenergywh"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.368330+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_3_energy_produced","hidden_by":null,"icon":null,"id":"a076b9af709576d957dc9d3dc4c820b9","labels":[],"modified_at":"2025-08-13T06:16:22.376489+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_ece352c3dc5c417e80757b716f24ba90_producedenergywh"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.368604+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_5_energy_produced","hidden_by":null,"icon":null,"id":"3628639aab2e9b732bad717f39d6f448","labels":[],"modified_at":"2025-08-13T06:16:22.376599+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_f19d909abcc4421f9ec630f82458c7ac_producedenergywh"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.370370+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_2_energy_consumed","hidden_by":null,"icon":null,"id":"fc9328016980de23d891107f520ce68b","labels":[],"modified_at":"2025-08-13T06:16:22.376706+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_0dad2f16cd514812ae1807b0457d473e_consumedenergywh"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.371078+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_4_energy_consumed","hidden_by":null,"icon":null,"id":"b71b52d1559398e14749ce7248ea2836","labels":[],"modified_at":"2025-08-13T06:16:22.376794+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_11a47a0f69d54e12b7200f730c2ffda1_consumedenergywh"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.371525+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_14_energy_consumed","hidden_by":null,"icon":null,"id":"f1c8ee54ff6b0378b4080faf0eeedf01","labels":[],"modified_at":"2025-08-13T06:16:22.376877+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_12ce227695cd44338864b0ef2ec4168b_consumedenergywh"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.372188+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_24_energy_consumed","hidden_by":null,"icon":null,"id":"a8955196e29d742e1674113fd8b722e6","labels":[],"modified_at":"2025-08-13T06:16:22.376970+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_1ad73b0eb44e4022bb6270c76baed0c1_consumedenergywh"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.372658+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_16_energy_consumed","hidden_by":null,"icon":null,"id":"442031aa423df9ee3c4a65c2555efa95","labels":[],"modified_at":"2025-08-13T06:16:22.377065+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_31b36cde0fc642b39eec515267707a6f_consumedenergywh"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.372934+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_19_energy_consumed","hidden_by":null,"icon":null,"id":"a06899850663410df4f9a5882e1170c2","labels":[],"modified_at":"2025-08-13T06:16:22.377148+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_3a847fe6eb374ec3bf92cee1ffaf2eda_consumedenergywh"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.373499+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_12_energy_consumed","hidden_by":null,"icon":null,"id":"71ff69b4c01446061dc85e5be44d374f","labels":[],"modified_at":"2025-08-13T06:16:22.377249+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_497ae7ebb2844772bf926f2f094c81bc_consumedenergywh"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.374067+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_29_energy_consumed","hidden_by":null,"icon":null,"id":"f88f0bb821a531d1b91ff3dcb70982bd","labels":[],"modified_at":"2025-08-13T06:16:22.377359+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_617059df47bb49bd8545a36a6b6b23d2_consumedenergywh"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.374421+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_10_energy_consumed","hidden_by":null,"icon":null,"id":"4aea712b7a3c65bb91efb4ef1f8bcc1b","labels":[],"modified_at":"2025-08-13T06:16:22.377440+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_6ad65cf2ad6443448e33973f26f7df3e_consumedenergywh"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.374750+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_25_energy_consumed","hidden_by":null,"icon":null,"id":"4388f8739eff0fa07ae78869bb016c0d","labels":[],"modified_at":"2025-08-13T06:16:22.377526+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_795e8eddb4f448af9625130332a41df8_consumedenergywh"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.377174+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_23_energy_consumed","hidden_by":null,"icon":null,"id":"4675924bffc46341689652940859df1b","labels":[],"modified_at":"2025-08-13T06:16:22.377637+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_82a0888dc072416598d99f8b74ee3d8d_consumedenergywh"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.377794+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_18_energy_consumed","hidden_by":null,"icon":null,"id":"c0757fc972d663e30859f335a672a22b","labels":[],"modified_at":"2025-08-13T06:16:22.377728+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_8a2ffda9dbd24bada9a01b880e910612_consumedenergywh"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.378302+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_9_energy_consumed","hidden_by":null,"icon":null,"id":"298ca219b03f718e784acd22039a0054","labels":[],"modified_at":"2025-08-13T06:16:22.377831+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_914943d4798c462cadf5e5144989dd5b_consumedenergywh"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.378741+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_1_energy_consumed","hidden_by":null,"icon":null,"id":"881fde59e63f7fb03dc507d771061cdb","labels":[],"modified_at":"2025-08-13T06:16:22.377909+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_926441605113492189f9aa13dac356cd_consumedenergywh"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.379167+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_15_energy_consumed","hidden_by":null,"icon":null,"id":"5e7f40ec4b4140a740aaa4909fee3ce5","labels":[],"modified_at":"2025-08-13T06:16:22.377984+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_941d6a8b41ab4c57a6b8be14b5981fe6_consumedenergywh"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.379525+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_7_energy_consumed","hidden_by":null,"icon":null,"id":"75d5cfe69d36e522f5fce53f5ab6d389","labels":[],"modified_at":"2025-08-13T06:16:22.378070+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_988af7bb1fc04aac8bb3b45c660d920a_consumedenergywh"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.379941+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_22_energy_consumed","hidden_by":null,"icon":null,"id":"87ff1aa5b01bc4bf22aa467181ffeb24","labels":[],"modified_at":"2025-08-13T06:16:22.378146+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_9941b417bfa046ef908d97c998c54c50_consumedenergywh"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.380325+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_6_energy_consumed","hidden_by":null,"icon":null,"id":"5c86ca8d74aa45812c0655554649f8ca","labels":[],"modified_at":"2025-08-13T06:16:22.378228+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_cbe98d7307ca42658983b9f673211e14_consumedenergywh"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.380627+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_11_energy_consumed","hidden_by":null,"icon":null,"id":"2976b6143e3a79eee5e58facdec12f1f","labels":[],"modified_at":"2025-08-13T06:16:22.378305+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_d88c0c0e7c584472b2ec7e4d3c53c3b8_consumedenergywh"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.381059+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_8_energy_consumed","hidden_by":null,"icon":null,"id":"a232b386c1b3c406cbe2c123c22735e7","labels":[],"modified_at":"2025-08-13T06:16:22.378381+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_e3cb78d38dae41cdb50faee7bfa4ab80_consumedenergywh"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.381395+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_3_energy_consumed","hidden_by":null,"icon":null,"id":"f3e86f348f4182629a3b5fb73b2791aa","labels":[],"modified_at":"2025-08-13T06:16:22.378465+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_ece352c3dc5c417e80757b716f24ba90_consumedenergywh"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-13T05:58:30.381721+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_5_energy_consumed","hidden_by":null,"icon":null,"id":"0c3f8c06d74af3d8b9cf1e5b0808f5a7","labels":[],"modified_at":"2025-08-13T06:16:22.381142+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755065782.3878288,"platform":"span_panel","unique_id":"span_nj-2316-005k6_f19d909abcc4421f9ec630f82458c7ac_consumedenergywh"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2GWV2T4WST7QXWQ837WF92S","config_subentry_id":null,"created_at":"2025-08-13T05:00:59.079529+00:00","device_class":null,"disabled_by":"integration","entity_id":"binary_sensor.sun_solar_rising_3","hidden_by":null,"icon":null,"id":"79ffc85f20341dc8315573f11d6024a9","labels":[],"modified_at":"2025-08-13T05:20:07.934348+00:00","name":null,"options":{},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2GWV2T4WST7QXWQ837WF92S-solar_rising"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2GWV2T4WST7QXWQ837WF92S","config_subentry_id":null,"created_at":"2025-08-13T05:00:59.079934+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_dawn_3","hidden_by":null,"icon":null,"id":"f4ceb73a0891adfb87b52c649b0dca71","labels":[],"modified_at":"2025-08-13T05:20:07.934414+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2GWV2T4WST7QXWQ837WF92S-next_dawn"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2GWV2T4WST7QXWQ837WF92S","config_subentry_id":null,"created_at":"2025-08-13T05:00:59.080339+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_dusk_3","hidden_by":null,"icon":null,"id":"7f8dbb64edc30391701878556b2d6709","labels":[],"modified_at":"2025-08-13T05:20:07.934468+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2GWV2T4WST7QXWQ837WF92S-next_dusk"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2GWV2T4WST7QXWQ837WF92S","config_subentry_id":null,"created_at":"2025-08-13T05:00:59.080501+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_midnight_3","hidden_by":null,"icon":null,"id":"63ce9cf6461ed3da8e7a3915e7e3156c","labels":[],"modified_at":"2025-08-13T05:20:07.934500+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2GWV2T4WST7QXWQ837WF92S-next_midnight"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2GWV2T4WST7QXWQ837WF92S","config_subentry_id":null,"created_at":"2025-08-13T05:00:59.080642+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_noon_3","hidden_by":null,"icon":null,"id":"55f890bde8f724c864d714badb06af20","labels":[],"modified_at":"2025-08-13T05:20:07.934532+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2GWV2T4WST7QXWQ837WF92S-next_noon"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2GWV2T4WST7QXWQ837WF92S","config_subentry_id":null,"created_at":"2025-08-13T05:00:59.080844+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_rising_3","hidden_by":null,"icon":null,"id":"87951a3020e396019da53306333608bc","labels":[],"modified_at":"2025-08-13T05:20:07.934561+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2GWV2T4WST7QXWQ837WF92S-next_rising"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2GWV2T4WST7QXWQ837WF92S","config_subentry_id":null,"created_at":"2025-08-13T05:00:59.080985+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_setting_3","hidden_by":null,"icon":null,"id":"00a91b7d356470e414ba7a65333594d2","labels":[],"modified_at":"2025-08-13T05:20:07.934592+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2GWV2T4WST7QXWQ837WF92S-next_setting"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2GWV2T4WST7QXWQ837WF92S","config_subentry_id":null,"created_at":"2025-08-13T05:00:59.081132+00:00","device_class":null,"disabled_by":"integration","entity_id":"sensor.sun_solar_elevation_3","hidden_by":null,"icon":null,"id":"635b93010ea2b5f4c33a4b03aac058b0","labels":[],"modified_at":"2025-08-13T05:20:07.934620+00:00","name":null,"options":{},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2GWV2T4WST7QXWQ837WF92S-solar_elevation"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2GWV2T4WST7QXWQ837WF92S","config_subentry_id":null,"created_at":"2025-08-13T05:00:59.081214+00:00","device_class":null,"disabled_by":"integration","entity_id":"sensor.sun_solar_azimuth_3","hidden_by":null,"icon":null,"id":"5d18c4791c2e3faa2aff741795cfd174","labels":[],"modified_at":"2025-08-13T05:20:07.934641+00:00","name":null,"options":{},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2GWV2T4WST7QXWQ837WF92S-solar_azimuth"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2GWV2T4WST7QXWQ837WF92S","config_subentry_id":null,"created_at":"2025-08-13T05:00:59.081291+00:00","device_class":null,"disabled_by":"integration","entity_id":"sensor.sun_solar_rising_3","hidden_by":null,"icon":null,"id":"049ebbcf2f186c5df2e22f5ba855beff","labels":[],"modified_at":"2025-08-13T05:20:07.934662+00:00","name":null,"options":{},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2GWV2T4WST7QXWQ837WF92S-solar_rising"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2GXXTQT3SD7C7BPVK0P6J7W","config_subentry_id":null,"created_at":"2025-08-13T05:19:57.700145+00:00","device_class":null,"disabled_by":"integration","entity_id":"binary_sensor.sun_solar_rising_4","hidden_by":null,"icon":null,"id":"38ce820b5ac766f581e91f71cb26b33c","labels":[],"modified_at":"2025-08-13T05:47:39.871544+00:00","name":null,"options":{},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2GXXTQT3SD7C7BPVK0P6J7W-solar_rising"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2GXXTQT3SD7C7BPVK0P6J7W","config_subentry_id":null,"created_at":"2025-08-13T05:19:57.700547+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_dawn_4","hidden_by":null,"icon":null,"id":"817ab73a3e7a173a8392e4587670e0a2","labels":[],"modified_at":"2025-08-13T05:47:39.871600+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2GXXTQT3SD7C7BPVK0P6J7W-next_dawn"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2GXXTQT3SD7C7BPVK0P6J7W","config_subentry_id":null,"created_at":"2025-08-13T05:19:57.701702+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_dusk_4","hidden_by":null,"icon":null,"id":"cc5e3bac7c6fbac520ab55bff3aa00b1","labels":[],"modified_at":"2025-08-13T05:47:39.871651+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2GXXTQT3SD7C7BPVK0P6J7W-next_dusk"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2GXXTQT3SD7C7BPVK0P6J7W","config_subentry_id":null,"created_at":"2025-08-13T05:19:57.702061+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_midnight_4","hidden_by":null,"icon":null,"id":"77e21ca24c781aac45c36d54895b9bc9","labels":[],"modified_at":"2025-08-13T05:47:39.875265+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2GXXTQT3SD7C7BPVK0P6J7W-next_midnight"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2GXXTQT3SD7C7BPVK0P6J7W","config_subentry_id":null,"created_at":"2025-08-13T05:19:57.702265+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_noon_4","hidden_by":null,"icon":null,"id":"783ea87abe61d652c152d7fb91b49a11","labels":[],"modified_at":"2025-08-13T05:47:39.875333+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2GXXTQT3SD7C7BPVK0P6J7W-next_noon"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2GXXTQT3SD7C7BPVK0P6J7W","config_subentry_id":null,"created_at":"2025-08-13T05:19:57.702410+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_rising_4","hidden_by":null,"icon":null,"id":"1eeb329eebbd439c8eb5cd37deeacaa5","labels":[],"modified_at":"2025-08-13T05:47:39.875358+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2GXXTQT3SD7C7BPVK0P6J7W-next_rising"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2GXXTQT3SD7C7BPVK0P6J7W","config_subentry_id":null,"created_at":"2025-08-13T05:19:57.702538+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_setting_4","hidden_by":null,"icon":null,"id":"a91bf994408f5dcbb1eb8f8b10faf183","labels":[],"modified_at":"2025-08-13T05:47:39.875386+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2GXXTQT3SD7C7BPVK0P6J7W-next_setting"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2GXXTQT3SD7C7BPVK0P6J7W","config_subentry_id":null,"created_at":"2025-08-13T05:19:57.702666+00:00","device_class":null,"disabled_by":"integration","entity_id":"sensor.sun_solar_elevation_4","hidden_by":null,"icon":null,"id":"e23cca6a8b78f117b0051b2042d0f86c","labels":[],"modified_at":"2025-08-13T05:47:39.875410+00:00","name":null,"options":{},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2GXXTQT3SD7C7BPVK0P6J7W-solar_elevation"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2GXXTQT3SD7C7BPVK0P6J7W","config_subentry_id":null,"created_at":"2025-08-13T05:19:57.702790+00:00","device_class":null,"disabled_by":"integration","entity_id":"sensor.sun_solar_azimuth_4","hidden_by":null,"icon":null,"id":"9ba7076637d6a00e0d4548121ef1c7f5","labels":[],"modified_at":"2025-08-13T05:47:39.875429+00:00","name":null,"options":{},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2GXXTQT3SD7C7BPVK0P6J7W-solar_azimuth"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2GXXTQT3SD7C7BPVK0P6J7W","config_subentry_id":null,"created_at":"2025-08-13T05:19:57.702887+00:00","device_class":null,"disabled_by":"integration","entity_id":"sensor.sun_solar_rising_4","hidden_by":null,"icon":null,"id":"717fe89a1ba1325b623da7900b184bc3","labels":[],"modified_at":"2025-08-13T05:47:39.875446+00:00","name":null,"options":{},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2GXXTQT3SD7C7BPVK0P6J7W-solar_rising"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2GZG7XD3DQQSDY3SR568B10","config_subentry_id":null,"created_at":"2025-08-13T05:47:29.586094+00:00","device_class":null,"disabled_by":"integration","entity_id":"binary_sensor.sun_solar_rising_3","hidden_by":null,"icon":null,"id":"dca731c2dba3ae6f3c7f008a9e9df1dd","labels":[],"modified_at":"2025-08-13T05:57:40.368538+00:00","name":null,"options":{},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2GZG7XD3DQQSDY3SR568B10-solar_rising"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2GZG7XD3DQQSDY3SR568B10","config_subentry_id":null,"created_at":"2025-08-13T05:47:29.586754+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_dawn_3","hidden_by":null,"icon":null,"id":"d40f6fdfc0a7c97e8cf473b5662a6575","labels":[],"modified_at":"2025-08-13T05:57:40.368596+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2GZG7XD3DQQSDY3SR568B10-next_dawn"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2GZG7XD3DQQSDY3SR568B10","config_subentry_id":null,"created_at":"2025-08-13T05:47:29.586967+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_dusk_3","hidden_by":null,"icon":null,"id":"68db514e118661737db332df3a43c319","labels":[],"modified_at":"2025-08-13T05:57:40.368649+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2GZG7XD3DQQSDY3SR568B10-next_dusk"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2GZG7XD3DQQSDY3SR568B10","config_subentry_id":null,"created_at":"2025-08-13T05:47:29.587168+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_midnight_3","hidden_by":null,"icon":null,"id":"96cfde9110d76aa20883b17211e9f5dd","labels":[],"modified_at":"2025-08-13T05:57:40.368677+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2GZG7XD3DQQSDY3SR568B10-next_midnight"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2GZG7XD3DQQSDY3SR568B10","config_subentry_id":null,"created_at":"2025-08-13T05:47:29.587309+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_noon_3","hidden_by":null,"icon":null,"id":"df1aca0e454480117159f698a8c2c975","labels":[],"modified_at":"2025-08-13T05:57:40.368707+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2GZG7XD3DQQSDY3SR568B10-next_noon"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2GZG7XD3DQQSDY3SR568B10","config_subentry_id":null,"created_at":"2025-08-13T05:47:29.587424+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_rising_3","hidden_by":null,"icon":null,"id":"0fe3afda2c3fdc725346d703eaed64de","labels":[],"modified_at":"2025-08-13T05:57:40.368730+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2GZG7XD3DQQSDY3SR568B10-next_rising"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2GZG7XD3DQQSDY3SR568B10","config_subentry_id":null,"created_at":"2025-08-13T05:47:29.587531+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_setting_3","hidden_by":null,"icon":null,"id":"1895dc3db432e1f41b87e99c2982e810","labels":[],"modified_at":"2025-08-13T05:57:40.368756+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2GZG7XD3DQQSDY3SR568B10-next_setting"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2GZG7XD3DQQSDY3SR568B10","config_subentry_id":null,"created_at":"2025-08-13T05:47:29.587639+00:00","device_class":null,"disabled_by":"integration","entity_id":"sensor.sun_solar_elevation_3","hidden_by":null,"icon":null,"id":"94add00b4aefbcfc6201746626b51c33","labels":[],"modified_at":"2025-08-13T05:57:40.368779+00:00","name":null,"options":{},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2GZG7XD3DQQSDY3SR568B10-solar_elevation"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2GZG7XD3DQQSDY3SR568B10","config_subentry_id":null,"created_at":"2025-08-13T05:47:29.587712+00:00","device_class":null,"disabled_by":"integration","entity_id":"sensor.sun_solar_azimuth_3","hidden_by":null,"icon":null,"id":"f0d169f4fb9a7ac39768774084e5a9b8","labels":[],"modified_at":"2025-08-13T05:57:40.368797+00:00","name":null,"options":{},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2GZG7XD3DQQSDY3SR568B10-solar_azimuth"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2GZG7XD3DQQSDY3SR568B10","config_subentry_id":null,"created_at":"2025-08-13T05:47:29.587792+00:00","device_class":null,"disabled_by":"integration","entity_id":"sensor.sun_solar_rising_3","hidden_by":null,"icon":null,"id":"1d1aad97fe4904e6a9a2664d1dc37ff0","labels":[],"modified_at":"2025-08-13T05:57:40.368815+00:00","name":null,"options":{},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2GZG7XD3DQQSDY3SR568B10-solar_rising"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.848144+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_dsm_state","hidden_by":null,"icon":null,"id":"2872edc50cb06034bd8698d9795163f4","labels":[],"modified_at":"2025-08-13T18:33:35.796693+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_dsm_state"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.856065+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_2_energy_produced","hidden_by":null,"icon":null,"id":"59c46751d19a82cbc215f55279c12d40","labels":[],"modified_at":"2025-08-13T18:33:35.797408+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_0dad2f16cd514812ae1807b0457d473e_energy_produced"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.861680+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_2_energy_consumed","hidden_by":null,"icon":null,"id":"4246c2b41a7ebfd03ef9d1b80071b08a","labels":[],"modified_at":"2025-08-13T18:33:35.798043+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_0dad2f16cd514812ae1807b0457d473e_energy_consumed"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.856325+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_4_energy_produced","hidden_by":null,"icon":null,"id":"e29101d955996df1dd911fde6717c41e","labels":[],"modified_at":"2025-08-13T18:33:35.797440+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_11a47a0f69d54e12b7200f730c2ffda1_energy_produced"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.861913+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_4_energy_consumed","hidden_by":null,"icon":null,"id":"6c5c344821fa7b0a33becafa109fe34b","labels":[],"modified_at":"2025-08-13T18:33:35.798072+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_11a47a0f69d54e12b7200f730c2ffda1_energy_consumed"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.856548+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_14_energy_produced","hidden_by":null,"icon":null,"id":"3e42c516247a80ec4996644412abb0fd","labels":[],"modified_at":"2025-08-13T18:33:35.797475+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_12ce227695cd44338864b0ef2ec4168b_energy_produced"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.862168+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_14_energy_consumed","hidden_by":null,"icon":null,"id":"2bca07aa17268d6d3d363e1106a76267","labels":[],"modified_at":"2025-08-13T18:33:35.798100+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_12ce227695cd44338864b0ef2ec4168b_energy_consumed"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.856773+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_24_energy_produced","hidden_by":null,"icon":null,"id":"167ac6c41849570d1eb73216a510d219","labels":[],"modified_at":"2025-08-13T18:33:35.797504+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_1ad73b0eb44e4022bb6270c76baed0c1_energy_produced"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.862562+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_24_energy_consumed","hidden_by":null,"icon":null,"id":"0e504ce59b846665357ec6dae085d277","labels":[],"modified_at":"2025-08-13T18:33:35.798131+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_1ad73b0eb44e4022bb6270c76baed0c1_energy_consumed"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.857115+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_16_energy_produced","hidden_by":null,"icon":null,"id":"b47595a0294a4759cc77ff2531c4ba9b","labels":[],"modified_at":"2025-08-13T18:33:35.797536+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_31b36cde0fc642b39eec515267707a6f_energy_produced"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.862808+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_16_energy_consumed","hidden_by":null,"icon":null,"id":"aeaac733055d39ff545f5ba8b3bb1b73","labels":[],"modified_at":"2025-08-13T18:33:35.798162+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_31b36cde0fc642b39eec515267707a6f_energy_consumed"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.857445+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_19_energy_produced","hidden_by":null,"icon":null,"id":"c5e05e41a30c7e069807907d5c6409a5","labels":[],"modified_at":"2025-08-13T18:33:35.797565+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_3a847fe6eb374ec3bf92cee1ffaf2eda_energy_produced"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.863047+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_19_energy_consumed","hidden_by":null,"icon":null,"id":"4d7e04331b157c2f26404dc3f9aaa615","labels":[],"modified_at":"2025-08-13T18:33:35.798191+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_3a847fe6eb374ec3bf92cee1ffaf2eda_energy_consumed"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.857673+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_12_energy_produced","hidden_by":null,"icon":null,"id":"0272c2bf8e251ed731c922765c3d637e","labels":[],"modified_at":"2025-08-13T18:33:35.797593+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_497ae7ebb2844772bf926f2f094c81bc_energy_produced"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.863264+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_12_energy_consumed","hidden_by":null,"icon":null,"id":"8a5ffbf98b570681a1d19f0db9879c3c","labels":[],"modified_at":"2025-08-13T18:33:35.798222+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_497ae7ebb2844772bf926f2f094c81bc_energy_consumed"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.857926+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_29_energy_produced","hidden_by":null,"icon":null,"id":"80f934b48bb12287fefc2d5cb94d7a23","labels":[],"modified_at":"2025-08-13T18:33:35.797620+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_617059df47bb49bd8545a36a6b6b23d2_energy_produced"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.863473+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_29_energy_consumed","hidden_by":null,"icon":null,"id":"a55bc4957f5a9f867f78ec956258d2b8","labels":[],"modified_at":"2025-08-13T18:33:35.798252+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_617059df47bb49bd8545a36a6b6b23d2_energy_consumed"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.858153+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_10_energy_produced","hidden_by":null,"icon":null,"id":"6d4ce66978fc745e3d7518f9e2cb656b","labels":[],"modified_at":"2025-08-13T18:33:35.797646+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_6ad65cf2ad6443448e33973f26f7df3e_energy_produced"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.863717+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_10_energy_consumed","hidden_by":null,"icon":null,"id":"c02eeee0efa2d8dc800631e4735aa8fb","labels":[],"modified_at":"2025-08-13T18:33:35.798283+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_6ad65cf2ad6443448e33973f26f7df3e_energy_consumed"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.858377+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_25_energy_produced","hidden_by":null,"icon":null,"id":"75b3c104df62c71835a913a4087504d3","labels":[],"modified_at":"2025-08-13T18:33:35.797672+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_795e8eddb4f448af9625130332a41df8_energy_produced"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.863962+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_25_energy_consumed","hidden_by":null,"icon":null,"id":"fd39fc4560fe37b7ca633143c4a03e9e","labels":[],"modified_at":"2025-08-13T18:33:35.798309+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_795e8eddb4f448af9625130332a41df8_energy_consumed"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.858599+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_23_energy_produced","hidden_by":null,"icon":null,"id":"77a41ee53f84c7845c3116236c7601a8","labels":[],"modified_at":"2025-08-13T18:33:35.797697+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_82a0888dc072416598d99f8b74ee3d8d_energy_produced"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.864296+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_23_energy_consumed","hidden_by":null,"icon":null,"id":"e353fb054e16a2dec2caf02b1a47a966","labels":[],"modified_at":"2025-08-13T18:33:35.798335+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_82a0888dc072416598d99f8b74ee3d8d_energy_consumed"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.858841+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_18_energy_produced","hidden_by":null,"icon":null,"id":"043ec695cb01e9a4e6780cd38c169058","labels":[],"modified_at":"2025-08-13T18:33:35.797722+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_8a2ffda9dbd24bada9a01b880e910612_energy_produced"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.864559+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_18_energy_consumed","hidden_by":null,"icon":null,"id":"e18513b455c038f0b05d932bdcf7b67c","labels":[],"modified_at":"2025-08-13T18:33:35.798362+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_8a2ffda9dbd24bada9a01b880e910612_energy_consumed"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.859114+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_9_energy_produced","hidden_by":null,"icon":null,"id":"22031cf7346617e30b511bc13790cbae","labels":[],"modified_at":"2025-08-13T18:33:35.797747+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_914943d4798c462cadf5e5144989dd5b_energy_produced"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.864845+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_9_energy_consumed","hidden_by":null,"icon":null,"id":"c9f89eefdb7a2b566971421f1c276b7e","labels":[],"modified_at":"2025-08-13T18:33:35.798388+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_914943d4798c462cadf5e5144989dd5b_energy_consumed"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.859325+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_1_energy_produced","hidden_by":null,"icon":null,"id":"3092401c9a306ab11e8040736c656aa3","labels":[],"modified_at":"2025-08-13T18:33:35.797773+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_926441605113492189f9aa13dac356cd_energy_produced"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.865142+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_1_energy_consumed","hidden_by":null,"icon":null,"id":"d0a3f19b639207da35f7acf1cf7e7283","labels":[],"modified_at":"2025-08-13T18:33:35.798415+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_926441605113492189f9aa13dac356cd_energy_consumed"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.859527+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_15_energy_produced","hidden_by":null,"icon":null,"id":"b530002eea85e3048d9bacc61d44aff5","labels":[],"modified_at":"2025-08-13T18:33:35.797799+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_941d6a8b41ab4c57a6b8be14b5981fe6_energy_produced"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.865537+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_15_energy_consumed","hidden_by":null,"icon":null,"id":"b0b2e1997bb9a966b0d71a86941f3bf6","labels":[],"modified_at":"2025-08-13T18:33:35.798440+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_941d6a8b41ab4c57a6b8be14b5981fe6_energy_consumed"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.859726+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_7_energy_produced","hidden_by":null,"icon":null,"id":"d61e80a2fda55dd3b356fca08a0ec056","labels":[],"modified_at":"2025-08-13T18:33:35.797826+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_988af7bb1fc04aac8bb3b45c660d920a_energy_produced"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.865776+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_7_energy_consumed","hidden_by":null,"icon":null,"id":"d153a0c02b42a7c68d832b1d61a436db","labels":[],"modified_at":"2025-08-13T18:33:35.798466+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_988af7bb1fc04aac8bb3b45c660d920a_energy_consumed"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.859934+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_22_energy_produced","hidden_by":null,"icon":null,"id":"e22bc83806bb963a4fbc907b7c41d81d","labels":[],"modified_at":"2025-08-13T18:33:35.797854+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_9941b417bfa046ef908d97c998c54c50_energy_produced"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.866012+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_22_energy_consumed","hidden_by":null,"icon":null,"id":"c685eb15006fd0f4c44eb5e61ecfb85e","labels":[],"modified_at":"2025-08-13T18:33:35.798491+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_9941b417bfa046ef908d97c998c54c50_energy_consumed"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.860157+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_6_energy_produced","hidden_by":null,"icon":null,"id":"e919e8e8fa77722525187367d4df187b","labels":[],"modified_at":"2025-08-13T18:33:35.797880+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_cbe98d7307ca42658983b9f673211e14_energy_produced"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.866225+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_6_energy_consumed","hidden_by":null,"icon":null,"id":"10f145f39f0f89f1df8b7a604d109c82","labels":[],"modified_at":"2025-08-13T18:33:35.798516+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_cbe98d7307ca42658983b9f673211e14_energy_consumed"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.860358+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_11_energy_produced","hidden_by":null,"icon":null,"id":"553a1b67f5ff12a958169a5e07f8ed48","labels":[],"modified_at":"2025-08-13T18:33:35.797909+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_d88c0c0e7c584472b2ec7e4d3c53c3b8_energy_produced"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.867620+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_11_energy_consumed","hidden_by":null,"icon":null,"id":"740ac5b2118dd2db977a41cfa3dfdc1b","labels":[],"modified_at":"2025-08-13T18:33:35.798542+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_d88c0c0e7c584472b2ec7e4d3c53c3b8_energy_consumed"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.860615+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_8_energy_produced","hidden_by":null,"icon":null,"id":"c2e14efd6d8c62251d5439cd885ba61b","labels":[],"modified_at":"2025-08-13T18:33:35.797935+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_e3cb78d38dae41cdb50faee7bfa4ab80_energy_produced"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.867933+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_8_energy_consumed","hidden_by":null,"icon":null,"id":"11caad665334ce41b1af4a8e22dfb1d4","labels":[],"modified_at":"2025-08-13T18:33:35.798568+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_e3cb78d38dae41cdb50faee7bfa4ab80_energy_consumed"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.861138+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_3_energy_produced","hidden_by":null,"icon":null,"id":"9f5b15cd56cb026ca6e0e51e5ed30405","labels":[],"modified_at":"2025-08-13T18:33:35.797961+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_ece352c3dc5c417e80757b716f24ba90_energy_produced"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.868264+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_3_energy_consumed","hidden_by":null,"icon":null,"id":"0481386b52c47f80cd8f17cf9d0ee784","labels":[],"modified_at":"2025-08-13T18:33:35.798593+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_ece352c3dc5c417e80757b716f24ba90_energy_consumed"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.861419+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_5_energy_produced","hidden_by":null,"icon":null,"id":"d714b3dbec1fe7a4af374bbb5bf068cd","labels":[],"modified_at":"2025-08-13T18:33:35.798013+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_f19d909abcc4421f9ec630f82458c7ac_energy_produced"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.868489+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_circuit_5_energy_consumed","hidden_by":null,"icon":null,"id":"dcc12ab989b60d5e9ae9cdb04b108b21","labels":[],"modified_at":"2025-08-13T18:33:35.798618+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_f19d909abcc4421f9ec630f82458c7ac_energy_consumed"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H02JC3PVKEJT3VG6PS4V8R","config_subentry_id":null,"created_at":"2025-08-13T05:57:30.116575+00:00","device_class":null,"disabled_by":"integration","entity_id":"binary_sensor.sun_solar_rising_4","hidden_by":null,"icon":null,"id":"1018173771df791492694fe0e5b18d29","labels":[],"modified_at":"2025-08-13T06:17:25.186746+00:00","name":null,"options":{},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2H02JC3PVKEJT3VG6PS4V8R-solar_rising"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H02JC3PVKEJT3VG6PS4V8R","config_subentry_id":null,"created_at":"2025-08-13T05:57:30.116933+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_dawn_4","hidden_by":null,"icon":null,"id":"01eb4ffd8b8479ddf02ef2c77252bde0","labels":[],"modified_at":"2025-08-13T06:17:25.186808+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2H02JC3PVKEJT3VG6PS4V8R-next_dawn"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H02JC3PVKEJT3VG6PS4V8R","config_subentry_id":null,"created_at":"2025-08-13T05:57:30.117164+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_dusk_4","hidden_by":null,"icon":null,"id":"f5abf0b046ae478484cf95437521eb19","labels":[],"modified_at":"2025-08-13T06:17:25.186862+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2H02JC3PVKEJT3VG6PS4V8R-next_dusk"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H02JC3PVKEJT3VG6PS4V8R","config_subentry_id":null,"created_at":"2025-08-13T05:57:30.117339+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_midnight_4","hidden_by":null,"icon":null,"id":"4c5a238a35411eeab0351f727c88b22c","labels":[],"modified_at":"2025-08-13T06:17:25.186894+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2H02JC3PVKEJT3VG6PS4V8R-next_midnight"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H02JC3PVKEJT3VG6PS4V8R","config_subentry_id":null,"created_at":"2025-08-13T05:57:30.117524+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_noon_4","hidden_by":null,"icon":null,"id":"1d15413e81cf68c9245a2f7788a8760d","labels":[],"modified_at":"2025-08-13T06:17:25.186920+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2H02JC3PVKEJT3VG6PS4V8R-next_noon"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H02JC3PVKEJT3VG6PS4V8R","config_subentry_id":null,"created_at":"2025-08-13T05:57:30.117670+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_rising_4","hidden_by":null,"icon":null,"id":"f472440a1d80225d10736ad4c25fa2de","labels":[],"modified_at":"2025-08-13T06:17:25.186946+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2H02JC3PVKEJT3VG6PS4V8R-next_rising"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H02JC3PVKEJT3VG6PS4V8R","config_subentry_id":null,"created_at":"2025-08-13T05:57:30.117781+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_setting_4","hidden_by":null,"icon":null,"id":"cc24299c995052f15a80775358b4ee73","labels":[],"modified_at":"2025-08-13T06:17:25.186972+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2H02JC3PVKEJT3VG6PS4V8R-next_setting"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H02JC3PVKEJT3VG6PS4V8R","config_subentry_id":null,"created_at":"2025-08-13T05:57:30.117898+00:00","device_class":null,"disabled_by":"integration","entity_id":"sensor.sun_solar_elevation_4","hidden_by":null,"icon":null,"id":"c3b2706d7f4f83dc512e3a37a75b41ed","labels":[],"modified_at":"2025-08-13T06:17:25.187011+00:00","name":null,"options":{},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2H02JC3PVKEJT3VG6PS4V8R-solar_elevation"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H02JC3PVKEJT3VG6PS4V8R","config_subentry_id":null,"created_at":"2025-08-13T05:57:30.117977+00:00","device_class":null,"disabled_by":"integration","entity_id":"sensor.sun_solar_azimuth_4","hidden_by":null,"icon":null,"id":"b0147de22a66a69f8d34378dc7e17402","labels":[],"modified_at":"2025-08-13T06:17:25.187031+00:00","name":null,"options":{},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2H02JC3PVKEJT3VG6PS4V8R-solar_azimuth"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H02JC3PVKEJT3VG6PS4V8R","config_subentry_id":null,"created_at":"2025-08-13T05:57:30.118054+00:00","device_class":null,"disabled_by":"integration","entity_id":"sensor.sun_solar_rising_4","hidden_by":null,"icon":null,"id":"12c6087bf57eed82358c26e1277957ab","labels":[],"modified_at":"2025-08-13T06:17:25.187051+00:00","name":null,"options":{},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2H02JC3PVKEJT3VG6PS4V8R-solar_rising"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H16QEC8NYDACSS5HKKBK0P","config_subentry_id":null,"created_at":"2025-08-13T06:17:14.960099+00:00","device_class":null,"disabled_by":"integration","entity_id":"binary_sensor.sun_solar_rising_3","hidden_by":null,"icon":null,"id":"251a913611eef62473899e93857bc6a6","labels":[],"modified_at":"2025-08-13T06:39:26.301689+00:00","name":null,"options":{},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2H16QEC8NYDACSS5HKKBK0P-solar_rising"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H16QEC8NYDACSS5HKKBK0P","config_subentry_id":null,"created_at":"2025-08-13T06:17:14.960885+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_dawn_3","hidden_by":null,"icon":null,"id":"e3a04fcef6e2db217bfdeeae60fb391d","labels":[],"modified_at":"2025-08-13T06:39:26.301751+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2H16QEC8NYDACSS5HKKBK0P-next_dawn"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H16QEC8NYDACSS5HKKBK0P","config_subentry_id":null,"created_at":"2025-08-13T06:17:14.961265+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_dusk_3","hidden_by":null,"icon":null,"id":"3cbdbdfc1abefe75fe4d892e3cf64d80","labels":[],"modified_at":"2025-08-13T06:39:26.301809+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2H16QEC8NYDACSS5HKKBK0P-next_dusk"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H16QEC8NYDACSS5HKKBK0P","config_subentry_id":null,"created_at":"2025-08-13T06:17:14.961529+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_midnight_3","hidden_by":null,"icon":null,"id":"00dfa3ab6ccd5eaf9e5147a96c935e1d","labels":[],"modified_at":"2025-08-13T06:39:26.301843+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2H16QEC8NYDACSS5HKKBK0P-next_midnight"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H16QEC8NYDACSS5HKKBK0P","config_subentry_id":null,"created_at":"2025-08-13T06:17:14.961752+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_noon_3","hidden_by":null,"icon":null,"id":"caf85e286365736093b531c2861c71bb","labels":[],"modified_at":"2025-08-13T06:39:26.301871+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2H16QEC8NYDACSS5HKKBK0P-next_noon"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H16QEC8NYDACSS5HKKBK0P","config_subentry_id":null,"created_at":"2025-08-13T06:17:14.961925+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_rising_3","hidden_by":null,"icon":null,"id":"e268fa4fed95f8c24b76f298cbaea1cd","labels":[],"modified_at":"2025-08-13T06:39:26.301899+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2H16QEC8NYDACSS5HKKBK0P-next_rising"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H16QEC8NYDACSS5HKKBK0P","config_subentry_id":null,"created_at":"2025-08-13T06:17:14.962128+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_setting_3","hidden_by":null,"icon":null,"id":"66663ff4e0bd8aeb459a2047cdda3a8c","labels":[],"modified_at":"2025-08-13T06:39:26.301927+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2H16QEC8NYDACSS5HKKBK0P-next_setting"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H16QEC8NYDACSS5HKKBK0P","config_subentry_id":null,"created_at":"2025-08-13T06:17:14.962559+00:00","device_class":null,"disabled_by":"integration","entity_id":"sensor.sun_solar_elevation_3","hidden_by":null,"icon":null,"id":"a4e60d8228a7ed17159184f20473c971","labels":[],"modified_at":"2025-08-13T06:39:26.301954+00:00","name":null,"options":{},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2H16QEC8NYDACSS5HKKBK0P-solar_elevation"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H16QEC8NYDACSS5HKKBK0P","config_subentry_id":null,"created_at":"2025-08-13T06:17:14.962717+00:00","device_class":null,"disabled_by":"integration","entity_id":"sensor.sun_solar_azimuth_3","hidden_by":null,"icon":null,"id":"fcf0c57027bfd2890a34c5919b632da2","labels":[],"modified_at":"2025-08-13T06:39:26.301974+00:00","name":null,"options":{},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2H16QEC8NYDACSS5HKKBK0P-solar_azimuth"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H16QEC8NYDACSS5HKKBK0P","config_subentry_id":null,"created_at":"2025-08-13T06:17:14.962831+00:00","device_class":null,"disabled_by":"integration","entity_id":"sensor.sun_solar_rising_3","hidden_by":null,"icon":null,"id":"8153b8bd3a25445975d3146d218e531f","labels":[],"modified_at":"2025-08-13T06:39:26.302005+00:00","name":null,"options":{},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2H16QEC8NYDACSS5HKKBK0P-solar_rising"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.847749+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_current_run_config","hidden_by":null,"icon":null,"id":"ac9bc34e569a63ad053dacbdd9549dae","labels":[],"modified_at":"2025-08-13T18:33:35.796638+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_currentrunconfig"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.847943+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_dsm_grid_state","hidden_by":null,"icon":null,"id":"0a539e2fb02dac5b417c060775cd4728","labels":[],"modified_at":"2025-08-13T18:33:35.796666+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_dsmgridstate"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.848347+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_main_relay_state","hidden_by":null,"icon":null,"id":"c06669d4e6696af19fd74a336663d105","labels":[],"modified_at":"2025-08-13T18:33:35.796721+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_mainrelaystate"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-13T06:51:57.848548+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_software_version","hidden_by":null,"icon":null,"id":"101ef63a034e44c67da48baadb00b205","labels":[],"modified_at":"2025-08-13T18:33:35.796747+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_softwarever"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H17FA5Y4A1B4B7FYE2XTR8","config_subentry_id":null,"created_at":"2025-08-12T17:45:22.281057+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_span_storage_battery_percentage","hidden_by":null,"icon":null,"id":"6be7cb7cc9d6f5933cf2e7f36e13bd86","labels":[],"modified_at":"2025-08-13T06:39:26.313722+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_battery_level"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H17FA5Y4A1B4B7FYE2XTR8","config_subentry_id":null,"created_at":"2025-08-12T19:40:10.269712+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_battery_level","hidden_by":null,"icon":null,"id":"8c00b165490505a17569ddc992e651e6","labels":[],"modified_at":"2025-08-13T06:39:26.314449+00:00","name":null,"options":{"sensor":{"suggested_display_precision":0},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_storage_battery_percentage"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H17FA5Y4A1B4B7FYE2XTR8","config_subentry_id":null,"created_at":"2025-08-12T04:23:25.346800+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.solar_produced_energy","hidden_by":null,"icon":null,"id":"f0ae2604d7b06e848c82e361292362b9","labels":[],"modified_at":"2025-08-13T06:39:26.314574+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_solar_produced_energy"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H17FA5Y4A1B4B7FYE2XTR8","config_subentry_id":null,"created_at":"2025-08-12T04:23:25.347695+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.solar_current_power","hidden_by":null,"icon":null,"id":"27e0d89028f47e829a7035ac098547d6","labels":[],"modified_at":"2025-08-13T06:39:26.314599+00:00","name":null,"options":{"sensor":{"suggested_display_precision":0},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_solar_current_power"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H17FA5Y4A1B4B7FYE2XTR8","config_subentry_id":null,"created_at":"2025-08-12T04:23:25.344402+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.solar_consumed_energy","hidden_by":null,"icon":null,"id":"6aa5e34ebfb46a5ef14cb66500a3d502","labels":[],"modified_at":"2025-08-13T06:39:26.314624+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_solar_consumed_energy"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H2F1HB23ZT9V5HFE1WFN0H","config_subentry_id":null,"created_at":"2025-08-13T06:39:16.017655+00:00","device_class":null,"disabled_by":"integration","entity_id":"binary_sensor.sun_solar_rising_4","hidden_by":null,"icon":null,"id":"94bad047a177dc645f82f0371c929e4e","labels":[],"modified_at":"2025-08-13T06:50:57.044798+00:00","name":null,"options":{},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2H2F1HB23ZT9V5HFE1WFN0H-solar_rising"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H2F1HB23ZT9V5HFE1WFN0H","config_subentry_id":null,"created_at":"2025-08-13T06:39:16.018092+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_dawn_4","hidden_by":null,"icon":null,"id":"9c7d1effffa805021e990bc92a4efa34","labels":[],"modified_at":"2025-08-13T06:50:57.044857+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2H2F1HB23ZT9V5HFE1WFN0H-next_dawn"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H2F1HB23ZT9V5HFE1WFN0H","config_subentry_id":null,"created_at":"2025-08-13T06:39:16.018362+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_dusk_4","hidden_by":null,"icon":null,"id":"a6d61cea5488a150538a89a1959b39df","labels":[],"modified_at":"2025-08-13T06:50:57.044908+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2H2F1HB23ZT9V5HFE1WFN0H-next_dusk"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H2F1HB23ZT9V5HFE1WFN0H","config_subentry_id":null,"created_at":"2025-08-13T06:39:16.018540+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_midnight_4","hidden_by":null,"icon":null,"id":"c9a455cf8447bde506fe78d9086048b7","labels":[],"modified_at":"2025-08-13T06:50:57.044938+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2H2F1HB23ZT9V5HFE1WFN0H-next_midnight"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H2F1HB23ZT9V5HFE1WFN0H","config_subentry_id":null,"created_at":"2025-08-13T06:39:16.018695+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_noon_4","hidden_by":null,"icon":null,"id":"e9bdb2bddcf129a37ee350b61cc97710","labels":[],"modified_at":"2025-08-13T06:50:57.044965+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2H2F1HB23ZT9V5HFE1WFN0H-next_noon"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H2F1HB23ZT9V5HFE1WFN0H","config_subentry_id":null,"created_at":"2025-08-13T06:39:16.018827+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_rising_4","hidden_by":null,"icon":null,"id":"9366e806f6f62211e3499731bde1a017","labels":[],"modified_at":"2025-08-13T06:50:57.045030+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2H2F1HB23ZT9V5HFE1WFN0H-next_rising"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H2F1HB23ZT9V5HFE1WFN0H","config_subentry_id":null,"created_at":"2025-08-13T06:39:16.018965+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_setting_4","hidden_by":null,"icon":null,"id":"516f34ef6b3b663819506117d872f892","labels":[],"modified_at":"2025-08-13T06:50:57.045067+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2H2F1HB23ZT9V5HFE1WFN0H-next_setting"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H2F1HB23ZT9V5HFE1WFN0H","config_subentry_id":null,"created_at":"2025-08-13T06:39:16.019167+00:00","device_class":null,"disabled_by":"integration","entity_id":"sensor.sun_solar_elevation_4","hidden_by":null,"icon":null,"id":"c5b80a1ab12d37a3b6366162162a721c","labels":[],"modified_at":"2025-08-13T06:50:57.045094+00:00","name":null,"options":{},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2H2F1HB23ZT9V5HFE1WFN0H-solar_elevation"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H2F1HB23ZT9V5HFE1WFN0H","config_subentry_id":null,"created_at":"2025-08-13T06:39:16.019261+00:00","device_class":null,"disabled_by":"integration","entity_id":"sensor.sun_solar_azimuth_4","hidden_by":null,"icon":null,"id":"c3da101875f600454550a00bd3f74326","labels":[],"modified_at":"2025-08-13T06:50:57.045118+00:00","name":null,"options":{},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2H2F1HB23ZT9V5HFE1WFN0H-solar_azimuth"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H2F1HB23ZT9V5HFE1WFN0H","config_subentry_id":null,"created_at":"2025-08-13T06:39:16.019338+00:00","device_class":null,"disabled_by":"integration","entity_id":"sensor.sun_solar_rising_4","hidden_by":null,"icon":null,"id":"57bf31a77ceab87fcfdfd025e4ba52b1","labels":[],"modified_at":"2025-08-13T06:50:57.045137+00:00","name":null,"options":{},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2H2F1HB23ZT9V5HFE1WFN0H-solar_rising"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H3443174B991E3DASPT0NG","config_subentry_id":null,"created_at":"2025-08-13T06:50:46.755490+00:00","device_class":null,"disabled_by":"integration","entity_id":"binary_sensor.sun_solar_rising_3","hidden_by":null,"icon":null,"id":"554e5121c324802b21c72e8d2be2f894","labels":[],"modified_at":"2025-08-13T18:33:35.795067+00:00","name":null,"options":{},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2H3443174B991E3DASPT0NG-solar_rising"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H3443174B991E3DASPT0NG","config_subentry_id":null,"created_at":"2025-08-13T06:50:46.755866+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_dawn_3","hidden_by":null,"icon":null,"id":"661bcc7573e2750f084cc2357d96e485","labels":[],"modified_at":"2025-08-13T18:33:35.795159+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2H3443174B991E3DASPT0NG-next_dawn"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H3443174B991E3DASPT0NG","config_subentry_id":null,"created_at":"2025-08-13T06:50:46.756122+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_dusk_3","hidden_by":null,"icon":null,"id":"199c4add7595639d5e53f93fab96e504","labels":[],"modified_at":"2025-08-13T18:33:35.795241+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2H3443174B991E3DASPT0NG-next_dusk"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H3443174B991E3DASPT0NG","config_subentry_id":null,"created_at":"2025-08-13T06:50:46.756297+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_midnight_3","hidden_by":null,"icon":null,"id":"e89df3d27548f7bd009ddb1501ea504b","labels":[],"modified_at":"2025-08-13T18:33:35.795289+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2H3443174B991E3DASPT0NG-next_midnight"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H3443174B991E3DASPT0NG","config_subentry_id":null,"created_at":"2025-08-13T06:50:46.756446+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_noon_3","hidden_by":null,"icon":null,"id":"7554b29751183245fd16117d2156da05","labels":[],"modified_at":"2025-08-13T18:33:35.795333+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2H3443174B991E3DASPT0NG-next_noon"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H3443174B991E3DASPT0NG","config_subentry_id":null,"created_at":"2025-08-13T06:50:46.756595+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_rising_3","hidden_by":null,"icon":null,"id":"392c67733b4496db737771d3f4d770bd","labels":[],"modified_at":"2025-08-13T18:33:35.795375+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2H3443174B991E3DASPT0NG-next_rising"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H3443174B991E3DASPT0NG","config_subentry_id":null,"created_at":"2025-08-13T06:50:46.756754+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.sun_next_setting_3","hidden_by":null,"icon":null,"id":"c155cee515fae18f0897b2b0d24a189d","labels":[],"modified_at":"2025-08-13T18:33:35.795417+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2H3443174B991E3DASPT0NG-next_setting"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H3443174B991E3DASPT0NG","config_subentry_id":null,"created_at":"2025-08-13T06:50:46.756881+00:00","device_class":null,"disabled_by":"integration","entity_id":"sensor.sun_solar_elevation_3","hidden_by":null,"icon":null,"id":"6e60a1251b960f803577018d9345f19a","labels":[],"modified_at":"2025-08-13T18:33:35.795459+00:00","name":null,"options":{},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2H3443174B991E3DASPT0NG-solar_elevation"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H3443174B991E3DASPT0NG","config_subentry_id":null,"created_at":"2025-08-13T06:50:46.756967+00:00","device_class":null,"disabled_by":"integration","entity_id":"sensor.sun_solar_azimuth_3","hidden_by":null,"icon":null,"id":"621a57ac2c6c76cedb09ef94d9517d16","labels":[],"modified_at":"2025-08-13T18:33:35.795493+00:00","name":null,"options":{},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2H3443174B991E3DASPT0NG-solar_azimuth"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H3443174B991E3DASPT0NG","config_subentry_id":null,"created_at":"2025-08-13T06:50:46.757057+00:00","device_class":null,"disabled_by":"integration","entity_id":"sensor.sun_solar_rising_3","hidden_by":null,"icon":null,"id":"00875f338b59b024420c5357bdcc2ec2","labels":[],"modified_at":"2025-08-13T18:33:35.795522+00:00","name":null,"options":{},"orphaned_timestamp":null,"platform":"sun","unique_id":"01K2H3443174B991E3DASPT0NG-solar_rising"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-11T23:51:22.685988+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_dsm_grid_state_2","hidden_by":null,"icon":null,"id":"8c0badf74e85a0ed71da34e9e2338867","labels":[],"modified_at":"2025-08-13T18:33:35.799187+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_dsm_grid_state"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-11T23:51:22.686152+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_current_run_config_2","hidden_by":null,"icon":null,"id":"92bdb118ff17ee4c5a13d598ac59bfe0","labels":[],"modified_at":"2025-08-13T18:33:35.799215+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_current_run_config"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-11T23:51:22.686598+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_main_relay_state_2","hidden_by":null,"icon":null,"id":"3d2d7f4baebc2383561e15fc73667b43","labels":[],"modified_at":"2025-08-13T18:33:35.799244+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_main_relay_state"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-11T23:51:22.686759+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_unmapped_tab_32_power","hidden_by":"integration","icon":null,"id":"95f8a6df6eadc89f3de62419847a4632","labels":[],"modified_at":"2025-08-13T18:33:35.799272+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_unmapped_tab_32_power"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-11T23:51:22.686977+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_unmapped_tab_32_energy_produced","hidden_by":"integration","icon":null,"id":"36cbeaba0d5e51544152a2aba31d71cb","labels":[],"modified_at":"2025-08-13T18:33:35.799304+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_unmapped_tab_32_energy_produced"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-11T23:51:22.687170+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_unmapped_tab_32_energy_consumed","hidden_by":"integration","icon":null,"id":"d18d4aae6c49fc66e39d5d3561819bae","labels":[],"modified_at":"2025-08-13T18:33:35.799331+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_unmapped_tab_32_energy_consumed"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-11T23:51:22.687355+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_unmapped_tab_27_power","hidden_by":"integration","icon":null,"id":"4453178cdc7092161efe3fb2add352f3","labels":[],"modified_at":"2025-08-13T18:33:35.799356+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_unmapped_tab_27_power"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-11T23:51:22.687527+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_unmapped_tab_27_energy_produced","hidden_by":"integration","icon":null,"id":"e9bcb88fc4082d600566326a434197c3","labels":[],"modified_at":"2025-08-13T18:33:35.799380+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_unmapped_tab_27_energy_produced"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-11T23:51:22.687708+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_unmapped_tab_27_energy_consumed","hidden_by":"integration","icon":null,"id":"ebd4e3edd06bc2ca6d3b99ba0c358bc3","labels":[],"modified_at":"2025-08-13T18:33:35.799404+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_unmapped_tab_27_energy_consumed"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-11T23:51:22.687892+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_unmapped_tab_28_power","hidden_by":"integration","icon":null,"id":"e096a7edd49d1bc813d0fd559900126b","labels":[],"modified_at":"2025-08-13T18:33:35.799433+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_unmapped_tab_28_power"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-11T23:51:22.688064+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_unmapped_tab_28_energy_produced","hidden_by":"integration","icon":null,"id":"5787f118bef84918da4fbce586aad558","labels":[],"modified_at":"2025-08-13T18:33:35.799470+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_unmapped_tab_28_energy_produced"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-11T23:51:22.688287+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_unmapped_tab_28_energy_consumed","hidden_by":"integration","icon":null,"id":"272c6854685062e4f9dece096719d927","labels":[],"modified_at":"2025-08-13T18:33:35.799496+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_unmapped_tab_28_energy_consumed"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-11T23:51:22.688497+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_unmapped_tab_30_power","hidden_by":"integration","icon":null,"id":"aec5e903c72b184cbb32d615b91f225c","labels":[],"modified_at":"2025-08-13T18:33:35.799521+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_unmapped_tab_30_power"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-11T23:51:22.688649+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_unmapped_tab_30_energy_produced","hidden_by":"integration","icon":null,"id":"d099b87aa03296bef5c4f800abd6d0d8","labels":[],"modified_at":"2025-08-13T18:33:35.799555+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_unmapped_tab_30_energy_produced"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-11T23:51:22.688797+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_unmapped_tab_30_energy_consumed","hidden_by":"integration","icon":null,"id":"38326d39091077ebe11e2f9db42fe020","labels":[],"modified_at":"2025-08-13T18:33:35.799590+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_unmapped_tab_30_energy_consumed"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-11T23:51:22.689036+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_software_version_2","hidden_by":null,"icon":null,"id":"57d3c3fd41a9b0a1a60fe1f6bfa2690c","labels":[],"modified_at":"2025-08-13T18:33:35.801042+00:00","name":null,"options":{"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_software_version"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-11T23:51:23.240202+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_main_meter_produced_energy_2","hidden_by":null,"icon":null,"id":"d115ab405005de3067e168c7f3a1362c","labels":[],"modified_at":"2025-08-13T18:33:35.801126+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_main_meter_produced_energy"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-11T23:51:23.240246+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_main_meter_consumed_energy_2","hidden_by":null,"icon":null,"id":"37950008f2ffea0670e4253f0b09abe9","labels":[],"modified_at":"2025-08-13T18:33:35.801174+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_main_meter_consumed_energy"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-11T23:51:23.240282+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_feed_through_produced_energy_2","hidden_by":null,"icon":null,"id":"ac8d0473ad359313af7e2f09495ff6d5","labels":[],"modified_at":"2025-08-13T18:33:35.801222+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_feed_through_produced_energy"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":"01K2H368V5D1QEK6361QD3THDX","config_subentry_id":null,"created_at":"2025-08-11T23:51:23.240319+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.span_panel_feed_through_consumed_energy_2","hidden_by":null,"icon":null,"id":"cf929697425caec1d2cbd87b3ce554b6","labels":[],"modified_at":"2025-08-13T18:33:35.801256+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":null,"platform":"span_panel","unique_id":"span_nj-2316-005k6_feed_through_consumed_energy"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-12T04:16:23.168620+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.solar_inverter_instant_power","hidden_by":null,"icon":null,"id":"bbbf0b8e433395ddaecdd05c6915ebf3","labels":[],"modified_at":"2025-08-15T17:18:58.727114+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755278338.7276366,"platform":"span_panel","unique_id":"span_nj-2316-005k6_synthetic_30_32_solar_inverter_instant_power"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-12T04:16:23.168989+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.solar_inverter_energy_produced","hidden_by":null,"icon":null,"id":"35cb5d091a68b0ef168b9e403b4828f2","labels":[],"modified_at":"2025-08-15T17:18:58.727141+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755278338.7276366,"platform":"span_panel","unique_id":"span_nj-2316-005k6_synthetic_30_32_solar_inverter_energy_produced"}, - {"aliases":[],"area_id":null,"categories":{},"config_entry_id":null,"config_subentry_id":null,"created_at":"2025-08-12T04:16:23.169246+00:00","device_class":null,"disabled_by":null,"entity_id":"sensor.solar_inverter_energy_consumed","hidden_by":null,"icon":null,"id":"8a414d91c061798fc15d13c213930a7f","labels":[],"modified_at":"2025-08-15T17:18:58.727167+00:00","name":null,"options":{"sensor":{"suggested_display_precision":2},"conversation":{"should_expose":false}},"orphaned_timestamp":1755278338.7276366,"platform":"span_panel","unique_id":"span_nj-2316-005k6_synthetic_30_32_solar_inverter_energy_consumed"} - ] - } -} diff --git a/tests/providers/integration_data_provider.py b/tests/providers/integration_data_provider.py deleted file mode 100644 index d1730426..00000000 --- a/tests/providers/integration_data_provider.py +++ /dev/null @@ -1,229 +0,0 @@ -"""Integration Data Provider for bridging simulation data with SPAN Panel integration. - -This module provides data using the integration's actual processing logic, -ensuring all data flows through the same code paths as production. -""" - -from typing import Any -from unittest.mock import MagicMock - -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant - -from custom_components.span_panel.coordinator import SpanPanelCoordinator -from tests.factories.span_panel_simulation_factory import SpanPanelSimulationFactory - - -class IntegrationDataProvider: - """Provides data using integration's actual processing logic.""" - - def __init__(self): - """Initialize the data provider.""" - self._simulation_factory = SpanPanelSimulationFactory() - - async def create_coordinator_with_simulation_data( - self, - hass: HomeAssistant, - config_entry: ConfigEntry, - simulation_variations: dict | None = None, - scenario_name: str | None = None - ) -> SpanPanelCoordinator: - """Create coordinator with simulation data processed through integration. - - Args: - hass: Home Assistant instance - config_entry: Configuration entry with naming flags - simulation_variations: Direct simulation variations to apply - scenario_name: Named scenario from simulation factory - - Returns: - SpanPanelCoordinator with realistic simulation data - - """ - # Get simulation data - if scenario_name: - sim_data = await self._simulation_factory.get_panel_data_for_scenario(scenario_name) - else: - sim_data = await self._simulation_factory.get_realistic_panel_data( - variations=simulation_variations - ) - - # Create a mock span panel client that returns simulation data - mock_span_panel = self._create_mock_span_panel_from_sim_data(sim_data) - - # Create coordinator using integration's actual initialization - coordinator = SpanPanelCoordinator(hass, mock_span_panel, config_entry) - - # Set data as if it came from real API calls - coordinator.data = mock_span_panel - - return coordinator - - def _create_mock_span_panel_from_sim_data(self, sim_data: dict[str, Any]) -> MagicMock: - """Convert simulation data to coordinator's expected format. - - This ensures the data structure matches exactly what the coordinator expects - by using the same attribute names and structure as the real SpanPanel client. - - Args: - sim_data: Dictionary containing simulation data from span-panel-api - - Returns: - MagicMock configured to match SpanPanel client interface - - """ - mock_panel = MagicMock() - - # Extract data from simulation response - circuits_data = sim_data['circuits'] - panel_state = sim_data['panel_state'] - status_data = sim_data['status'] - storage_data = sim_data['storage'] - - # Set panel-level attributes from panel_state - mock_panel.id = getattr(panel_state, 'panel_id', 'test_panel_123') - mock_panel.name = "Simulation Panel" - mock_panel.model = getattr(panel_state, 'panel_model', '32A') - mock_panel.firmware_version = getattr(panel_state, 'firmware_version', '1.2.3') - mock_panel.main_breaker_size = 200 - - # Power and energy data from panel_state - mock_panel.instant_grid_power_w = getattr(panel_state, 'instant_grid_power_w', 0) - mock_panel.instant_load_power_w = getattr(panel_state, 'instant_load_power_w', 0) - mock_panel.instant_production_power_w = getattr(panel_state, 'instant_production_power_w', 0) - mock_panel.feedthrough_power = getattr(panel_state, 'feedthrough_power_w', 0) - - # Environmental data - mock_panel.env_temp_c = getattr(panel_state, 'env_temp_c', 25.0) - mock_panel.uptime_s = getattr(panel_state, 'uptime_s', 86400) - - # Status data - mock_panel.door_state = getattr(status_data, 'door_state', 'CLOSED') - mock_panel.main_relay_state = getattr(status_data, 'main_relay_state', 'CLOSED') - - # DSM data from panel_state - mock_panel.dsmCurrentRms = getattr(panel_state, 'dsm_current_rms', [120.5, 118.3]) - mock_panel.dsmVoltageRms = getattr(panel_state, 'dsm_voltage_rms', [245.6, 244.1]) - mock_panel.grid_sample_start_ms = getattr(panel_state, 'grid_sample_start_ms', 1234567890123) - - # Convert circuits data to the format the integration expects - mock_circuits = [] - for _circuit_id, circuit_data in circuits_data.circuits.additional_properties.items(): - circuit_mock = MagicMock() - circuit_mock.id = circuit_data.id - circuit_mock.name = circuit_data.name - circuit_mock.instant_power_w = circuit_data.instant_power_w - circuit_mock.produced_energy_wh = circuit_data.produced_energy_wh - circuit_mock.consumed_energy_wh = circuit_data.consumed_energy_wh - circuit_mock.relay_state = circuit_data.relay_state - circuit_mock.priority = circuit_data.priority - circuit_mock.tabs = circuit_data.tabs - circuit_mock.is_user_controllable = circuit_data.is_user_controllable - circuit_mock.is_sheddable = circuit_data.is_sheddable - circuit_mock.is_never_backup = circuit_data.is_never_backup - - # Add properties that integration might expect - circuit_mock.is_main = circuit_data.id == "main" or "main" in circuit_data.name.lower() - circuit_mock.breaker_size = 20 # Default breaker size - - mock_circuits.append(circuit_mock) - - mock_panel.circuits = mock_circuits - - # Storage/battery data if available - if storage_data: - mock_panel.battery_soe = getattr(storage_data, 'soe', 0.5) - mock_panel.max_energy_kwh = getattr(storage_data, 'max_energy_kwh', 10.0) - - return mock_panel - - async def create_config_entry_with_flags( - self, - naming_flags: dict[str, Any], - entry_id: str = "test_entry_id" - ) -> ConfigEntry: - """Create a config entry with specific naming flags. - - Args: - naming_flags: Dictionary of naming configuration flags - entry_id: Unique identifier for the config entry - - Returns: - ConfigEntry configured with the specified flags - - """ - # Import here to avoid circular imports - from tests.common import create_mock_config_entry - - config_entry = create_mock_config_entry() - config_entry.entry_id = entry_id - config_entry.options = { - **config_entry.options, - **naming_flags - } - - return config_entry - - async def create_full_integration_setup( - self, - hass: HomeAssistant, - naming_flags: dict[str, Any], - simulation_variations: dict | None = None, - scenario_name: str | None = None - ) -> tuple[SpanPanelCoordinator, ConfigEntry]: - """Create a complete integration setup with simulation data. - - Args: - hass: Home Assistant instance - naming_flags: Entity naming configuration flags - simulation_variations: Direct simulation variations - scenario_name: Named simulation scenario - - Returns: - Tuple of (coordinator, config_entry) ready for testing - - """ - # Create config entry with naming flags - config_entry = await self.create_config_entry_with_flags(naming_flags) - - # Create coordinator with simulation data - coordinator = await self.create_coordinator_with_simulation_data( - hass, - config_entry, - simulation_variations=simulation_variations, - scenario_name=scenario_name - ) - - return coordinator, config_entry - - async def get_circuit_data_for_naming_tests( - self, - circuit_types: list[str] | None = None - ) -> dict[str, dict]: - """Get circuit data specifically formatted for naming pattern tests. - - Args: - circuit_types: List of circuit types to include (lights, ev_chargers, etc.) - - Returns: - Dictionary with circuit data formatted for naming tests - - """ - # Get all circuit details - circuit_details = await self._simulation_factory.get_circuit_details() - - if circuit_types: - # Filter by circuit types - circuit_ids_by_type = await self._simulation_factory.get_circuit_ids_by_type() - included_ids = set() - for circuit_type in circuit_types: - if circuit_type in circuit_ids_by_type: - included_ids.update(circuit_ids_by_type[circuit_type]) - - circuit_details = { - circuit_id: details - for circuit_id, details in circuit_details.items() - if circuit_id in included_ids - } - - return circuit_details diff --git a/tests/scripts/generate_migration_test_config.py b/tests/scripts/generate_migration_test_config.py deleted file mode 100755 index f6030b9f..00000000 --- a/tests/scripts/generate_migration_test_config.py +++ /dev/null @@ -1,1060 +0,0 @@ -#!/usr/bin/env python3 -"""Generate complete sensor YAML configuration using real migration registry data. - -This script tests the v2 migration process by: -1. Loading real entity registry data from v1.0.10 installation -2. Normalizing unique_ids as in migration Phase 1 -3. Generating YAML in migration mode with registry entity_id lookups -4. Validating that named circuits get proper power values - -This script closely mimics the production migration process to validate that -the v2 migration will work correctly with real user data. -""" - -import asyncio -import json -from pathlib import Path -import shutil -import sys -import tempfile -from typing import Any -from unittest.mock import AsyncMock, MagicMock - -import yaml - -# Add the span project to the path -project_root = Path(__file__).resolve().parents[2] # Go up 2 levels to project root -sys.path.insert(0, str(project_root)) - -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er - -# Import span integration components -from custom_components.span_panel.const import DOMAIN -from custom_components.span_panel.migration import migrate_config_entry_to_synthetic_sensors - - -class MigrationTestHarness: - """Test harness for migration testing with real registry data.""" - - def __init__(self): - """Initialize the migration test harness.""" - self.test_storage_dir: Path | None = None - self.original_config_entry_id = "01K2JBCXNSB9Q489RMG3XTMTJE" - self.device_identifier = "nj-2316-005k6" - self.entity_registry: er.EntityRegistry | None = None - self.mock_hass: HomeAssistant | None = None - - async def setup_test_environment(self) -> tuple[HomeAssistant, ConfigEntry]: - """Set up isolated test environment with real registry data.""" - print("🔧 Setting up migration test environment...") - - # Create temporary storage - self.test_storage_dir = Path(tempfile.mkdtemp(prefix="span_migration_test_")) - print(f"📁 Test storage: {self.test_storage_dir}") - - # Copy migration storage to test location - source_dir = project_root / "tests" / "migration_storage" / "1_0_10" - for file_name in ["core.entity_registry", "core.device_registry", "core.config_entries"]: - shutil.copy(source_dir / file_name, self.test_storage_dir / file_name) - - # Create mock Home Assistant environment - mock_hass = self._create_mock_hass() - - # Load real config entry - config_entry = self._load_test_config_entry() - - # Setup entity and device registries - await self._setup_registries(mock_hass) - - print(f"✅ Test environment ready with config entry: {config_entry.entry_id}") - return mock_hass, config_entry - - def _create_mock_hass(self) -> HomeAssistant: - """Create a mock Home Assistant instance.""" - mock_hass = MagicMock(spec=HomeAssistant) - mock_hass.data = {} - mock_hass.config = MagicMock() - mock_hass.config.config_dir = str(self.test_storage_dir) - - # Add required bus for EntityRegistry - mock_bus = MagicMock() - mock_bus.async_listen = MagicMock() - mock_hass.bus = mock_bus - - # Add config_entries manager - mock_config_entries = MagicMock() - mock_config_entries.async_update_entry = AsyncMock() - mock_hass.config_entries = mock_config_entries - - # Add event loop for StorageManager - import asyncio - mock_hass.loop = asyncio.get_event_loop() - - # Add async_add_executor_job for StorageManager file operations - mock_hass.async_add_executor_job = AsyncMock() - - return mock_hass - - def _load_test_config_entry(self) -> ConfigEntry: - """Load the test config entry from migration storage.""" - config_entries_file = self.test_storage_dir / "core.config_entries" - with open(config_entries_file) as f: - config_data = json.load(f) - - # Find the SPAN Panel config entry - span_entry_data = None - for entry in config_data["data"]["entries"]: - if entry["domain"] == "span_panel": - span_entry_data = entry - break - - if not span_entry_data: - raise ValueError("No SPAN Panel config entry found in migration data") - - # Create ConfigEntry object - config_entry = MagicMock(spec=ConfigEntry) - config_entry.entry_id = span_entry_data["entry_id"] - config_entry.domain = span_entry_data["domain"] - config_entry.data = span_entry_data["data"] - config_entry.options = span_entry_data["options"] - config_entry.title = span_entry_data["title"] - config_entry.unique_id = span_entry_data["unique_id"] - config_entry.version = span_entry_data.get("version", 1) # Default to version 1 for migration - - return config_entry - - async def _setup_registries(self, mock_hass: HomeAssistant) -> None: - """Set up real entity registry from copied data that can be modified.""" - - # Create a real EntityRegistry instance that operates on our test storage - entity_registry_file = self.test_storage_dir / "core.entity_registry" - - # Load the registry data (pre-cleaned to prevent unique_id collisions) - with open(entity_registry_file) as f: - registry_data = json.load(f) - - # Create a mock EntityRegistry that behaves like the real one - self.entity_registry = MagicMock(spec=er.EntityRegistry) - self.entity_registry.entities = {} - - # Load entities from the test data - span_entities = [] - for entity_data in registry_data["data"]["entities"]: - if entity_data.get("platform") == "span_panel": - # Create mock entity entry with the essential properties - entry = MagicMock() - entry.entity_id = entity_data["entity_id"] - entry.unique_id = entity_data["unique_id"] - entry.platform = entity_data["platform"] - entry.domain = entity_data["entity_id"].split(".")[0] - entry.config_entry_id = entity_data["config_entry_id"] - entry.device_id = entity_data.get("device_id") - entry.area_id = entity_data.get("area_id") - entry.capabilities = entity_data.get("capabilities") - entry.supported_features = entity_data.get("supported_features", 0) - entry.device_class = entity_data.get("device_class") - entry.unit_of_measurement = entity_data.get("unit_of_measurement") - entry.original_name = entity_data.get("original_name") - entry.original_icon = entity_data.get("original_icon") - entry.entity_category = entity_data.get("entity_category") - - self.entity_registry.entities[entity_data["entity_id"]] = entry - span_entities.append(entry) - - # Create a custom save method that writes back to our test file - async def save_to_test_file(): - """Save registry changes back to test file.""" - # Convert entities back to the storage format - entities_data = [] - for entity in self.entity_registry.entities.values(): - entity_dict = { - "entity_id": entity.entity_id, - "unique_id": entity.unique_id, - "platform": entity.platform, - "domain": entity.domain, - "config_entry_id": entity.config_entry_id, - "device_id": entity.device_id, - "area_id": entity.area_id, - "capabilities": entity.capabilities, - "supported_features": entity.supported_features, - "device_class": entity.device_class, - "unit_of_measurement": entity.unit_of_measurement, - "original_name": entity.original_name, - "original_icon": entity.original_icon, - "entity_category": entity.entity_category, - # Add other required fields with defaults - "aliases": [], - "categories": {}, - "config_subentry_id": None, - "created_at": "2025-08-13T18:34:39.000000+00:00", - "disabled_by": None, - "hidden_by": None, - "icon": None, - "id": f"test_id_{hash(entity.entity_id) % 100000}", - "has_entity_name": False, - "labels": [], - "modified_at": "2025-08-13T18:34:39.000000+00:00", - "name": None, - "options": {}, - "previous_unique_id": None, - "suggested_object_id": None, - "translation_key": None - } - entities_data.append(entity_dict) - - # Preserve the original structure but update entities - registry_data["data"]["entities"] = entities_data - - # Write back to test file - with open(entity_registry_file, 'w') as f: - json.dump(registry_data, f, indent=2) - - print(f"💾 Saved {len(entities_data)} entities to test registry") - - # Replace the registry's save method with our custom one - self.entity_registry.async_schedule_save = lambda: asyncio.create_task(save_to_test_file()) - - # Add the async_update_entity method that the migration uses - def async_update_entity(entity_id, new_unique_id=None, **kwargs): - """Update entity in registry.""" - if entity_id in self.entity_registry.entities: - entity = self.entity_registry.entities[entity_id] - if new_unique_id: - print(f"🔄 Updating {entity_id}: {entity.unique_id} -> {new_unique_id}") - entity.unique_id = new_unique_id - for key, value in kwargs.items(): - setattr(entity, key, value) - return entity - return None - - self.entity_registry.async_update_entity = async_update_entity - - # Add the async_get_entity_id method that synthetic sensors uses for lookups - def async_get_entity_id(domain, platform, unique_id): - """Get entity_id by unique_id - critical for migration behavior.""" - for entity in self.entity_registry.entities.values(): - if (entity.domain == domain and - entity.platform == platform and - entity.unique_id == unique_id): - print(f"🔍 Registry lookup: unique_id '{unique_id}' -> entity_id '{entity.entity_id}'") - return entity.entity_id - print(f"🔍 Registry lookup: unique_id '{unique_id}' -> NOT FOUND") - return None - - self.entity_registry.async_get_entity_id = async_get_entity_id - - # Add entities that will cause collisions (simulate native SPAN integration entities) - # These have DIFFERENT unique_ids but SAME entity_ids as what synthetic sensors will try to create - collision_entities = [ - ("sensor.span_panel_circuit_2_power", "span_native_circuit_2_power"), - ("sensor.span_panel_circuit_4_power", "span_native_circuit_4_power"), - ("sensor.span_panel_circuit_14_power", "span_native_circuit_14_power"), - ] - - for entity_id, unique_id in collision_entities: - collision_entry = MagicMock() - collision_entry.entity_id = entity_id - collision_entry.unique_id = unique_id # Different unique_id! - collision_entry.platform = "span_panel" - collision_entry.domain = "sensor" - collision_entry.config_entry_id = self.original_config_entry_id - collision_entry.device_id = None - collision_entry.area_id = None - collision_entry.capabilities = None - collision_entry.supported_features = 0 - collision_entry.device_class = "power" - collision_entry.unit_of_measurement = "W" - collision_entry.original_name = None - collision_entry.original_icon = None - collision_entry.entity_category = None - self.entity_registry.entities[entity_id] = collision_entry - print(f"🎯 Added collision entity: {entity_id} (unique_id: {unique_id})") - - # Add the async_get_or_create method that handles collision detection - def async_get_or_create(domain, platform, unique_id, suggested_object_id=None, **kwargs): - """Get existing entity or create new one with collision detection.""" - # First check if entity already exists by unique_id - for entity in self.entity_registry.entities.values(): - if (entity.domain == domain and - entity.platform == platform and - entity.unique_id == unique_id): - print(f"🔍 Found existing entity by unique_id: {entity.entity_id}") - return entity - - # Entity doesn't exist by unique_id, need to create new one - if suggested_object_id: - base_entity_id = f"{domain}.{suggested_object_id}" - else: - # Fallback to using unique_id as object_id - base_entity_id = f"{domain}.{unique_id}" - - # Check for entity_id collision and generate suffix if needed - final_entity_id = base_entity_id - suffix = 1 - while final_entity_id in self.entity_registry.entities: - suffix += 1 - final_entity_id = f"{base_entity_id}_{suffix}" - existing_entity = self.entity_registry.entities[base_entity_id] - print(f"🚨 COLLISION DETECTED: {base_entity_id} exists (unique_id: {existing_entity.unique_id}), trying {final_entity_id}") - - # Create new entity entry - entry = MagicMock() - entry.entity_id = final_entity_id - entry.unique_id = unique_id - entry.platform = platform - entry.domain = domain - entry.config_entry_id = kwargs.get('config_entry_id') - entry.device_id = kwargs.get('device_id') - entry.area_id = kwargs.get('area_id') - entry.capabilities = kwargs.get('capabilities') - entry.supported_features = kwargs.get('supported_features', 0) - entry.device_class = kwargs.get('device_class') - entry.unit_of_measurement = kwargs.get('unit_of_measurement') - entry.original_name = kwargs.get('original_name') - entry.original_icon = kwargs.get('original_icon') - entry.entity_category = kwargs.get('entity_category') - - # Add to registry - self.entity_registry.entities[final_entity_id] = entry - print(f"✅ Created new entity: {final_entity_id} (unique_id: {unique_id})") - return entry - - self.entity_registry.async_get_or_create = async_get_or_create - - # Setup the async_get function to return our real registry - def get_entity_registry(hass): - return self.entity_registry - - # Patch the er.async_get function - er.async_get = get_entity_registry - - # Add the async_entries_for_config_entry function - def async_entries_for_config_entry(registry, config_entry_id): - """Get entities for a config entry.""" - return [entity for entity in registry.entities.values() - if entity.config_entry_id == config_entry_id] - - er.async_entries_for_config_entry = async_entries_for_config_entry - mock_hass.data["entity_registry"] = self.entity_registry - - print(f"📊 Loaded {len(span_entities)} SPAN Panel entities into real EntityRegistry") - print(f"🔧 Registry can now be modified and saved back to: {entity_registry_file}") - - def extract_circuits_from_registry(self, mock_hass: HomeAssistant, config_entry_id: str) -> dict[str, dict[str, Any]]: - """Extract circuit information from the entity registry.""" - entity_registry = mock_hass.data["entity_registry"] - circuits = {} - - for entity in entity_registry.entities.values(): - if (entity.config_entry_id == config_entry_id and - entity.platform == "span_panel" and - entity.domain == "sensor" and - "_instantPowerW" in entity.unique_id): - - # Parse circuit ID from unique_id: span_nj-2316-005k6_{circuit_id}_instantPowerW - parts = entity.unique_id.split("_") - if len(parts) >= 4: - circuit_id = parts[2] # Extract the UUID circuit ID - - # Extract circuit name from original_name - # e.g., "Span Panel Lights Dining Room Power" -> "Lights Dining Room" - circuit_name = entity.original_name - if circuit_name: - circuit_name = circuit_name.replace("Span Panel ", "").replace(" Power", "") - else: - circuit_name = f"Circuit {circuit_id[:8]}" - - circuits[circuit_id] = { - "name": circuit_name, - "entity_id": entity.entity_id, - "unique_id": entity.unique_id, - "power": 150.0 + (hash(circuit_id) % 100) # Generate realistic power value - } - - print(f"🔌 Extracted {len(circuits)} circuits from registry") - return circuits - - def extract_panel_sensors_from_registry(self, mock_hass: HomeAssistant, config_entry_id: str) -> dict[str, dict[str, Any]]: - """Extract panel sensor information from the entity registry.""" - entity_registry = mock_hass.data["entity_registry"] - panel_sensors = {} - - # Define panel sensor mappings - panel_sensor_mappings = { - "instantGridPowerW": {"name": "Current Power", "value": 2500.0, "unit": "W"}, - "feedthroughPowerW": {"name": "Feed Through Power", "value": 800.0, "unit": "W"}, - "mainMeterEnergy.producedEnergyWh": {"name": "Main Meter Produced Energy", "value": 15000.0, "unit": "Wh"}, - "mainMeterEnergy.consumedEnergyWh": {"name": "Main Meter Consumed Energy", "value": 22000.0, "unit": "Wh"}, - "feedthroughEnergy.producedEnergyWh": {"name": "Feed Through Produced Energy", "value": 8000.0, "unit": "Wh"}, - "feedthroughEnergy.consumedEnergyWh": {"name": "Feed Through Consumed Energy", "value": 12000.0, "unit": "Wh"} - } - - for entity in entity_registry.entities.values(): - if (entity.config_entry_id == config_entry_id and - entity.platform == "span_panel" and - entity.domain == "sensor"): - - # Check if this is a panel sensor - for api_key, sensor_info in panel_sensor_mappings.items(): - if api_key in entity.unique_id: - panel_sensors[api_key] = { - "name": sensor_info["name"], - "entity_id": entity.entity_id, - "unique_id": entity.unique_id, - "value": sensor_info["value"], - "unit": sensor_info["unit"] - } - break - - print(f"⚡ Extracted {len(panel_sensors)} panel sensors from registry") - return panel_sensors - - async def create_realistic_coordinator_and_panel(self, circuits: dict[str, dict[str, Any]], - panel_sensors: dict[str, dict[str, Any]]) -> tuple[Any, Any]: - """Create realistic coordinator and panel data using simulation factory.""" - - print("🏭 Creating realistic panel data using simulation factory...") - - # Import the simulation factory - from tests.test_factories.span_panel_simulation_factory import SpanPanelSimulationFactory - - # Get realistic panel data from simulation using registry-specific config - simulation_config = "migration_test_config" - print(f"📋 Using simulation config: {simulation_config} (matches registry circuit IDs)") - - try: - # Create simulation factory and get realistic data - simulation_factory = SpanPanelSimulationFactory() - mock_responses = await simulation_factory.get_realistic_panel_data( - config_name=simulation_config, - host=self.device_identifier - ) - - print(f"✅ Got simulation data with {len(mock_responses['circuits'].circuits.additional_properties)} circuits") - - # Create mock span panel with realistic simulation data - mock_span_panel = MagicMock() - mock_span_panel.status.serial_number = self.device_identifier - - # Convert simulation panel_state to mock panel data - panel_state = mock_responses["panel_state"] - mock_panel_data = MagicMock() - mock_panel_data.instantGridPowerW = panel_state.instant_grid_power_w - mock_panel_data.feedthroughPowerW = panel_state.feedthrough_power_w - - # Add energy data - if hasattr(panel_state, 'main_meter_energy'): - mock_panel_data.mainMeterEnergyProducedWh = panel_state.main_meter_energy.produced_energy_wh - mock_panel_data.mainMeterEnergyConsumedWh = panel_state.main_meter_energy.consumed_energy_wh - else: - mock_panel_data.mainMeterEnergyProducedWh = 15000.0 - mock_panel_data.mainMeterEnergyConsumedWh = 22000.0 - - # Add feedthrough energy data - if hasattr(panel_state, 'feedthrough_energy'): - mock_panel_data.feedthroughEnergyProducedWh = panel_state.feedthrough_energy.produced_energy_wh - mock_panel_data.feedthroughEnergyConsumedWh = panel_state.feedthrough_energy.consumed_energy_wh - else: - mock_panel_data.feedthroughEnergyProducedWh = 8000.0 - mock_panel_data.feedthroughEnergyConsumedWh = 12000.0 - - mock_span_panel.panel = mock_panel_data - - # Use simulation circuits directly since they now match registry IDs - circuits_data = mock_responses["circuits"] - circuit_dict = {} - - # Extract circuits from the simulation data structure - for circuit_id, circuit in circuits_data.circuits.additional_properties.items(): - circuit_mock = MagicMock() - circuit_mock.id = circuit.id - circuit_mock.name = circuit.name - circuit_mock.instantPowerW = circuit.instant_power_w - circuit_mock.producedEnergyWh = circuit.produced_energy_wh - circuit_mock.consumedEnergyWh = circuit.consumed_energy_wh - circuit_mock.relayState = circuit.relay_state.value if hasattr(circuit.relay_state, 'value') else circuit.relay_state - circuit_mock.priority = circuit.priority.value if hasattr(circuit.priority, 'value') else circuit.priority - circuit_mock.isUserControllable = circuit.is_user_controllable - circuit_mock.tabs = list(circuit.tabs) if circuit.tabs else [] - - circuit_dict[circuit_id] = circuit_mock - - # Log the circuit details for debugging - print(f"🔌 Circuit {circuit.name} ({circuit_id[:8]}...): tabs={circuit.tabs}, power={circuit.instant_power_w}W") - - mock_span_panel.circuits = circuit_dict - - print(f"✅ Created realistic panel with {len(circuit_dict)} circuits with proper tabs data") - - except Exception as e: - print(f"❌ Failed to create realistic simulation data: {e}") - print("🔄 Falling back to basic mock data...") - # Fallback to basic mock data if simulation fails - return await self.create_basic_mock_coordinator_and_panel(circuits, panel_sensors) - - # Create mock coordinator - mock_coordinator = MagicMock() - mock_coordinator.data = mock_span_panel - mock_coordinator.config_entry = MagicMock() - mock_coordinator.config_entry.options = { - "energy_reporting_grace_period": 15, - "enable_solar_circuit": True, - "enable_battery_percentage": True, - } - mock_coordinator.config_entry.entry_id = self.original_config_entry_id - mock_coordinator.config_entry.title = self.device_identifier - mock_coordinator.config_entry.data = {"device_name": self.device_identifier} - - print(f"🏗️ Created realistic coordinator with {len(circuit_dict)} circuits") - return mock_coordinator, mock_span_panel - - async def create_basic_mock_coordinator_and_panel(self, circuits: dict[str, dict[str, Any]], - panel_sensors: dict[str, dict[str, Any]]) -> tuple[Any, Any]: - """Create basic mock coordinator and panel data as fallback.""" - - # Create mock span panel - mock_span_panel = MagicMock() - mock_span_panel.status.serial_number = self.device_identifier - - # Create mock panel data - mock_panel_data = MagicMock() - mock_panel_data.instantGridPowerW = panel_sensors.get("instantGridPowerW", {}).get("value", 2500.0) - mock_panel_data.feedthroughPowerW = panel_sensors.get("feedthroughPowerW", {}).get("value", 800.0) - mock_panel_data.mainMeterEnergyProducedWh = panel_sensors.get("mainMeterEnergy.producedEnergyWh", {}).get("value", 15000.0) - mock_panel_data.mainMeterEnergyConsumedWh = panel_sensors.get("mainMeterEnergy.consumedEnergyWh", {}).get("value", 22000.0) - mock_panel_data.feedthroughEnergyProducedWh = panel_sensors.get("feedthroughEnergy.producedEnergyWh", {}).get("value", 8000.0) - mock_panel_data.feedthroughEnergyConsumedWh = panel_sensors.get("feedthroughEnergy.consumedEnergyWh", {}).get("value", 12000.0) - - mock_span_panel.panel = mock_panel_data - - # Create mock circuits with basic tab data (assign some circuits to have tabs) - circuit_dict = {} - tab_counter = 1 - for circuit_id, circuit_info in circuits.items(): - circuit_mock = MagicMock() - circuit_mock.id = circuit_id - circuit_mock.name = circuit_info["name"] - circuit_mock.instantPowerW = circuit_info["power"] - circuit_mock.producedEnergyWh = circuit_info["power"] * 24 # Simulate daily energy - circuit_mock.consumedEnergyWh = circuit_info["power"] * 36 # Simulate daily energy - circuit_mock.relayState = "CLOSED" - circuit_mock.priority = "NICE_TO_HAVE" - circuit_mock.isUserControllable = True - # Assign tabs to circuits - some 120V (single tab) and some 240V (dual tabs) - if tab_counter % 5 == 0: # Every 5th circuit gets 240V (dual tabs) - circuit_mock.tabs = [tab_counter, tab_counter + 1] - tab_counter += 2 - else: # Most circuits get 120V (single tab) - circuit_mock.tabs = [tab_counter] - tab_counter += 1 - - circuit_dict[circuit_id] = circuit_mock - - mock_span_panel.circuits = circuit_dict - - # Create mock coordinator - mock_coordinator = MagicMock() - mock_coordinator.data = mock_span_panel - mock_coordinator.config_entry = MagicMock() - mock_coordinator.config_entry.options = { - "energy_reporting_grace_period": 15, - "enable_solar_circuit": True, - "enable_battery_percentage": True, - } - mock_coordinator.config_entry.entry_id = self.original_config_entry_id - mock_coordinator.config_entry.title = self.device_identifier - mock_coordinator.config_entry.data = {"device_name": self.device_identifier} - - print(f"🏗️ Created basic mock coordinator with {len(circuit_dict)} circuits") - return mock_coordinator, mock_span_panel - - async def print_unique_id_summary(self, mock_hass: HomeAssistant, config_entry_id: str) -> None: - """Print a summary of unique_ids for debugging normalization.""" - entity_registry = mock_hass.data["entity_registry"] - span_entities = [e for e in entity_registry.entities.values() - if e.config_entry_id == config_entry_id and e.platform == "span_panel"] - - # Group by sensor type - panel_sensors = [] - circuit_sensors = [] - - for entity in span_entities: - if entity.domain == "sensor": - if any(panel_key in entity.unique_id for panel_key in - ["instantGridPowerW", "feedthroughPowerW", "mainMeterEnergy", "feedthroughEnergy"]): - panel_sensors.append(entity) - else: - circuit_sensors.append(entity) - - print(f" Panel sensors ({len(panel_sensors)}):") - for entity in panel_sensors[:3]: # Show first 3 - print(f" - {entity.unique_id}") - if len(panel_sensors) > 3: - print(f" ... and {len(panel_sensors) - 3} more") - - print(f" Circuit sensors ({len(circuit_sensors)}):") - for entity in circuit_sensors[:3]: # Show first 3 - print(f" - {entity.unique_id}") - if len(circuit_sensors) > 3: - print(f" ... and {len(circuit_sensors) - 3} more") - - async def save_registry_changes(self) -> None: - """Force save of registry changes to test file.""" - if self.entity_registry: - await self.entity_registry.async_schedule_save() - print("💾 Registry changes saved to test file") - - def cleanup(self): - """Clean up test environment.""" - if self.test_storage_dir and self.test_storage_dir.exists(): - shutil.rmtree(self.test_storage_dir) - print(f"🧹 Cleaned up test storage: {self.test_storage_dir}") - - -async def validate_migration_yaml(yaml_content: str, mock_hass: HomeAssistant, - config_entry: ConfigEntry) -> bool: - """Validate the generated YAML content.""" - print("🔍 Validating generated YAML...") - - try: - yaml_data = yaml.safe_load(yaml_content) - except yaml.YAMLError as e: - print(f"❌ YAML parsing error: {e}") - return False - - # Validate basic structure - required_keys = ["version", "global_settings", "sensors"] - for key in required_keys: - if key not in yaml_data: - print(f"❌ Missing required key: {key}") - return False - - sensors = yaml_data["sensors"] - entity_registry = mock_hass.data["entity_registry"] - - # Count sensors by type - panel_sensors = {k: v for k, v in sensors.items() if "circuit" not in k} - circuit_sensors = {k: v for k, v in sensors.items() if "circuit" in k and "_power" in k} - energy_sensors = {k: v for k, v in sensors.items() if "energy" in k} - - print("📊 Validation results:") - print(f" Panel sensors: {len(panel_sensors)}") - print(f" Circuit power sensors: {len(circuit_sensors)}") - print(f" Energy sensors: {len(energy_sensors)}") - print(f" Total sensors: {len(sensors)}") - - # Validate circuit sensors have realistic power values - circuit_power_issues = 0 - for sensor_key, sensor_config in circuit_sensors.items(): - if "state_template" in sensor_config: - template = sensor_config["state_template"] - if "0" in template and "|default(0)" in template: - print(f"⚠️ Circuit sensor {sensor_key} may have zero power default") - circuit_power_issues += 1 - - if circuit_power_issues == 0: - print("✅ All circuit sensors have proper power configuration") - else: - print(f"⚠️ {circuit_power_issues} circuit sensors may have power issues") - - # Validate entity ID preservation - preserved_entity_ids = 0 - for _sensor_key, sensor_config in sensors.items(): - if "entity_id" in sensor_config: - entity_id = sensor_config["entity_id"] - # Check if this entity_id exists in the original registry - if entity_id in entity_registry.entities: - preserved_entity_ids += 1 - - print(f"✅ Preserved {preserved_entity_ids} entity IDs from original registry") - - return True - - -async def generate_migration_yaml(): # noqa: C901 - """Generate YAML using migration registry data.""" - - print("🚀 Starting migration YAML generation test...") - - harness = MigrationTestHarness() - - try: - # Setup test environment - mock_hass, config_entry = await harness.setup_test_environment() - - # Set migration mode flag - mock_hass.data[DOMAIN] = {config_entry.entry_id: {"migration_mode": True}} - - # Extract circuit and panel data from registry - circuits = harness.extract_circuits_from_registry(mock_hass, config_entry.entry_id) - panel_sensors = harness.extract_panel_sensors_from_registry(mock_hass, config_entry.entry_id) - - # Phase 1: Normalize unique_ids (actual migration step) - print("🔄 Phase 1: Normalizing unique_ids...") - print("📋 Before normalization:") - await harness.print_unique_id_summary(mock_hass, config_entry.entry_id) - - # Actually perform the unique_id normalization like the real migration - normalization_success = await migrate_config_entry_to_synthetic_sensors(mock_hass, config_entry) - - if normalization_success: - print("✅ Unique_id normalization completed successfully") - # Save the normalized registry back to the test file - await harness.save_registry_changes() - else: - print("❌ Unique_id normalization failed") - - print("📋 After normalization:") - await harness.print_unique_id_summary(mock_hass, config_entry.entry_id) - - # Create realistic coordinator and panel data using simulation factory - mock_coordinator, mock_span_panel = await harness.create_realistic_coordinator_and_panel(circuits, panel_sensors) - device_name = harness.device_identifier - - # Phase 2: Use the production synthetic sensor setup path with REAL migration logic - print("Phase 2: Setting up synthetic sensors using production code path...") - - from custom_components.span_panel.synthetic_named_circuits import ( - generate_named_circuit_sensors, - ) - from custom_components.span_panel.synthetic_panel_circuits import generate_panel_sensors - - # Mock the storage system to avoid migration issues - storage_data = {} - - class MockStore: - def __init__(self, hass, version: int, key: str, *, encoder=None, decoder=None): - self.hass = hass - self.version = version - self.key = key - self.encoder = encoder - self.decoder = decoder - self._data = storage_data.get(key, {}) - - async def async_load(self): - """Load data from mock storage.""" - return self._data.copy() if self._data else None - - async def async_save(self, data): - """Save data to mock storage.""" - storage_data[self.key] = data.copy() if data else {} - self._data = data.copy() if data else {} - - async def async_remove(self): - """Remove data from mock storage.""" - if self.key in storage_data: - del storage_data[self.key] - self._data = {} - - # Mock the synthetic sensors storage manager to avoid migration issues - class MockSyntheticStorageManager: - def __init__(self, hass, config_entry_id): - self.hass = hass - self.config_entry_id = config_entry_id - self.sensors = {} - self.global_settings = {} - - async def async_load(self): - """Mock load that always succeeds.""" - return True - - async def async_save(self): - """Mock save that always succeeds.""" - return True - - async def async_export_yaml(self, sensor_set_id=None): - """Generate YAML content from the test data.""" - yaml_content = f"""version: '1.0' -global_settings: - device_identifier: {device_name} - variables: - energy_grace_period_minutes: "15" - -sensors:""" - - # Add panel sensors - panel_sensors = { - "span_nj-2316-005k6_current_power": { - "name": "Current Power", - "entity_id": "sensor.span_panel_current_power", - "formula": "{{ states('sensor.span_panel_current_power') | default(0) }}", - "unit_of_measurement": "W", - "device_class": "power" - }, - "span_nj-2316-005k6_feed_through_power": { - "name": "Feed Through Power", - "entity_id": "sensor.span_panel_feed_through_power", - "formula": "{{ states('sensor.span_panel_feed_through_power') | default(0) }}", - "unit_of_measurement": "W", - "device_class": "power" - }, - "span_nj-2316-005k6_main_meter_produced_energy": { - "name": "Main Meter Produced Energy", - "entity_id": "sensor.span_panel_main_meter_produced_energy", - "formula": "{{ states('sensor.span_panel_main_meter_produced_energy') | default(0) }}", - "unit_of_measurement": "Wh", - "device_class": "energy" - }, - "span_nj-2316-005k6_main_meter_consumed_energy": { - "name": "Main Meter Consumed Energy", - "entity_id": "sensor.span_panel_main_meter_consumed_energy", - "formula": "{{ states('sensor.span_panel_main_meter_consumed_energy') | default(0) }}", - "unit_of_measurement": "Wh", - "device_class": "energy" - }, - "span_nj-2316-005k6_feed_through_produced_energy": { - "name": "Feed Through Produced Energy", - "entity_id": "sensor.span_panel_feed_through_produced_energy", - "formula": "{{ states('sensor.span_panel_feed_through_produced_energy') | default(0) }}", - "unit_of_measurement": "Wh", - "device_class": "energy" - }, - "span_nj-2316-005k6_feed_through_consumed_energy": { - "name": "Feed Through Consumed Energy", - "entity_id": "sensor.span_panel_feed_through_consumed_energy", - "formula": "{{ states('sensor.span_panel_feed_through_consumed_energy') | default(0) }}", - "unit_of_measurement": "Wh", - "device_class": "energy" - } - } - - # Add circuit sensors for each circuit - circuit_sensors = {} - for circuit_id, circuit_info in circuits.items(): - # Power sensor - power_unique_id = f"span_nj-2316-005k6_{circuit_id}_power" - circuit_sensors[power_unique_id] = { - "name": f"{circuit_info['name']} Power", - "entity_id": f"sensor.span_panel_circuit_{circuit_id[:8]}_power", - "formula": f"{{{{ states('sensor.span_panel_circuit_{circuit_id[:8]}_power') | default(0) }}}}", - "unit_of_measurement": "W", - "device_class": "power" - } - - # Energy sensors - for energy_type in ["energy_produced", "energy_consumed"]: - energy_unique_id = f"span_nj-2316-005k6_{circuit_id}_{energy_type}" - energy_name = f"{circuit_info['name']} {energy_type.replace('_', ' ').title()}" - circuit_sensors[energy_unique_id] = { - "name": energy_name, - "entity_id": f"sensor.span_panel_circuit_{circuit_id[:8]}_{energy_type}", - "formula": f"{{{{ states('sensor.span_panel_circuit_{circuit_id[:8]}_{energy_type}') | default(0) }}}}", - "unit_of_measurement": "Wh", - "device_class": "energy" - } - - # Combine all sensors - all_sensors = {**panel_sensors, **circuit_sensors} - - # Add sensors to YAML - for unique_id, sensor_config in all_sensors.items(): - yaml_content += f""" - "{unique_id}": - name: "{sensor_config['name']}" - entity_id: "{sensor_config['entity_id']}" - formula: "{sensor_config['formula']}" - unit_of_measurement: "{sensor_config['unit_of_measurement']}" - device_class: "{sensor_config['device_class']}" """ - - return yaml_content - - # Call the REAL SPAN integration migration logic - print("🔧 Calling REAL SPAN integration migration logic...") - - # Generate panel sensors using REAL migration mode - panel_configs, panel_backing_entities, panel_globals, panel_mappings = await generate_panel_sensors( - hass=mock_hass, - coordinator=mock_coordinator, - span_panel=mock_span_panel, - device_name=device_name, - migration_mode=True # This is the key - enables registry lookups! - ) - - # Generate circuit sensors using REAL migration mode - circuit_configs, circuit_backing_entities, circuit_globals, circuit_mappings = await generate_named_circuit_sensors( - hass=mock_hass, - coordinator=mock_coordinator, - span_panel=mock_span_panel, - device_name=device_name, - migration_mode=True # This is the key - enables registry lookups! - ) - - # Combine all configurations - all_sensors = {**panel_configs, **circuit_configs} - - # Generate YAML manually since we're not using storage manager - yaml_content = f"""version: '1.0' -global_settings: - device_identifier: {device_name} - variables: - energy_grace_period_minutes: "15" - -sensors:""" - - for unique_id, sensor_config in all_sensors.items(): - yaml_content += f""" - "{unique_id}":""" - for key, value in sensor_config.items(): - if isinstance(value, str): - # Escape quotes in string values and ensure proper YAML formatting - escaped_value = value.replace('"', '\\"') - yaml_content += f""" - {key}: "{escaped_value}" """ - elif isinstance(value, dict): - # Handle nested dictionaries (like metadata, attributes) - yaml_content += f""" - {key}:""" - for nested_key, nested_value in value.items(): - if isinstance(nested_value, str): - escaped_nested = nested_value.replace('"', '\\"') - yaml_content += f""" - {nested_key}: "{escaped_nested}" """ - else: - yaml_content += f""" - {nested_key}: {nested_value} """ - else: - yaml_content += f""" - {key}: {value} """ - - # Phase 3: Save and validate results - output_file = '/tmp/span_migration_test_config.yaml' - with open(output_file, 'w', encoding='utf-8') as f: - f.write(yaml_content) - - print(f"Migration test YAML saved to: {output_file}") - print(f"YAML size: {len(yaml_content)} characters") - - # Phase 4: Test collision detection by trying to register synthetic sensors - print("🧪 Phase 4: Testing collision detection with synthetic sensors...") - - # Import the synthetic sensors package to test collision handling - try: - from ha_synthetic_sensors import async_setup_integration - - # Mock the async_add_entities callback - added_entities = [] - def mock_add_entities(entities): - added_entities.extend(entities) - for entity in entities: - print(f"📝 Synthetic sensor registered: {entity.entity_id} (unique_id: {entity.unique_id})") - - # Try to set up synthetic sensors with the generated YAML - # This should trigger collision detection for conflicting entity_ids - result = await async_setup_integration( - hass=mock_hass, - config_entry=config_entry, - async_add_entities=mock_add_entities, - yaml_content=yaml_content, - sensor_set_id=f"{device_name}_sensors" - ) - - if result: - print(f"✅ Synthetic sensors setup completed with {len(added_entities)} entities") - - # Check for entities with _2 suffixes (collision resolution) - collision_resolved = [e for e in added_entities if e.entity_id.endswith('_2')] - if collision_resolved: - print(f"🔧 Collision resolution detected: {len(collision_resolved)} entities got _2 suffixes") - for entity in collision_resolved[:3]: # Show first 3 - print(f" • {entity.entity_id} (unique_id: {entity.unique_id})") - else: - print("⚠️ No collision resolution detected - this might indicate the test needs adjustment") - else: - print("❌ Synthetic sensors setup failed") - - except Exception as e: - print(f"⚠️ Collision testing skipped due to error: {e}") - print(" This is expected if synthetic sensors package isn't available in test environment") - - # Validate the generated YAML - validation_success = await validate_migration_yaml(yaml_content, mock_hass, config_entry) - - # Parse YAML to get sensor counts for summary - try: - yaml_data = yaml.safe_load(yaml_content) - all_sensors = yaml_data.get("sensors", {}) - panel_sensor_count = len([k for k in all_sensors if "circuit" not in k]) - circuit_sensor_count = len([k for k in all_sensors if "circuit" in k and "_power" in k]) - total_sensor_count = len(all_sensors) - except Exception: - # Fallback counts if YAML parsing fails - panel_sensor_count = 6 - circuit_sensor_count = 22 - total_sensor_count = 72 - - # Save summary - summary_file = '/tmp/span_migration_test_summary.txt' - with open(summary_file, 'w', encoding='utf-8') as f: - f.write("SPAN Panel Migration Test Summary\n") - f.write("=" * 40 + "\n\n") - f.write(f"Test Config Entry: {config_entry.entry_id}\n") - f.write(f"Device Identifier: {harness.device_identifier}\n") - f.write("Migration Mode: True\n") - f.write(f"Normalization Success: {normalization_success}\n") - f.write(f"Validation Success: {validation_success}\n\n") - - f.write("Migration Results:\n") - f.write(" ✅ Registry copied to test environment\n") - f.write(" ✅ Unique_id normalization performed\n") - f.write(" ✅ Registry changes saved back to test file\n") - f.write(" ✅ YAML generation completed\n\n") - - f.write("Sensor Counts:\n") - f.write(f" Panel sensors: {panel_sensor_count}\n") - f.write(f" Circuit sensors: {circuit_sensor_count}\n") - f.write(f" Total sensors: {total_sensor_count}\n\n") - - f.write("Registry Analysis:\n") - f.write(f" Circuits extracted: {len(circuits)}\n") - f.write(f" Panel sensors extracted: {len(panel_sensors)}\n\n") - - f.write("Generated Circuit Sensors:\n") - circuit_names = [k for k in all_sensors if "circuit" in k and "_power" in k][:10] - for key in circuit_names: - f.write(f" - {key}\n") - if circuit_sensor_count > 10: - f.write(f" ... and {circuit_sensor_count - 10} more\n") - - print(f"Test summary saved to: {summary_file}") - - # Show preview of generated YAML - print("\n" + "=" * 60) - print("MIGRATION TEST YAML PREVIEW (first 1000 characters):") - print("=" * 60) - print(yaml_content[:1000]) - if len(yaml_content) > 1000: - print("...") - print("=" * 60) - - return yaml_content, all_sensors, validation_success - - except Exception as e: - print(f"Error in migration test: {e}") - import traceback - traceback.print_exc() - return None, None, False - - finally: - # Cleanup - harness.cleanup() - - -if __name__ == "__main__": - yaml_content, sensor_configs, validation_success = asyncio.run(generate_migration_yaml()) - - if yaml_content and validation_success: - print("\nMigration test completed successfully!") - print("Check /tmp/span_migration_test_config.yaml for the full configuration") - print("Check /tmp/span_migration_test_summary.txt for a detailed summary") - print("The v2 migration process appears to be working correctly") - elif yaml_content: - print("\nMigration test completed with validation issues!") - print("Check /tmp/span_migration_test_config.yaml for the generated configuration") - print("Check /tmp/span_migration_test_summary.txt for details") - print("Review the validation output above for specific issues") - else: - print("\nMigration test failed!") - print("Check the error output above for debugging information") - sys.exit(1) diff --git a/tests/test_binary_sensor_platform.py b/tests/test_binary_sensor_platform.py new file mode 100644 index 00000000..5c8ac2b8 --- /dev/null +++ b/tests/test_binary_sensor_platform.py @@ -0,0 +1,196 @@ +"""Tests for Span Panel binary sensor entities and setup.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock + +from custom_components.span_panel.binary_sensor import ( + BESS_CONNECTED_SENSOR, + BINARY_SENSORS, + EVSE_BINARY_SENSORS, + GRID_ISLANDABLE_SENSOR, + SpanEvseBinarySensor, + SpanPanelBinarySensor, + async_setup_entry, +) +from custom_components.span_panel.const import ( + DOMAIN, + PANEL_STATUS, + SYSTEM_DOOR_STATE, +) +from homeassistant.core import HomeAssistant + +from .factories import ( + SpanBatterySnapshotFactory, + SpanEvseSnapshotFactory, + SpanPanelSnapshotFactory, +) + +from pytest_homeassistant_custom_component.common import MockConfigEntry + + +def _make_coordinator(snapshot) -> MagicMock: + """Create a coordinator-like mock for binary sensor tests.""" + coordinator = MagicMock() + coordinator.data = snapshot + coordinator.panel_offline = False + coordinator.last_update_success = True + coordinator.config_entry = MockConfigEntry( + domain=DOMAIN, + data={}, + options={}, + title="SPAN Panel", + unique_id=snapshot.serial_number, + ) + coordinator.async_request_refresh = AsyncMock() + return coordinator + + +def test_panel_status_sensor_stays_available_when_panel_goes_offline() -> None: + """The panel status binary sensor stays available and flips off when offline.""" + snapshot = SpanPanelSnapshotFactory.create() + coordinator = _make_coordinator(snapshot) + description = next(desc for desc in BINARY_SENSORS if desc.key == PANEL_STATUS) + entity = SpanPanelBinarySensor(coordinator, description) + entity.async_write_ha_state = MagicMock() + + coordinator.panel_offline = False + entity._handle_coordinator_update() + assert entity.available is True + assert entity.is_on is True + + coordinator.panel_offline = True + entity._handle_coordinator_update() + assert entity.available is True + assert entity.is_on is False + + +def test_hardware_status_sensor_shows_unknown_when_panel_offline() -> None: + """Door/ethernet/wifi sensors stay available but report unknown offline.""" + snapshot = SpanPanelSnapshotFactory.create() + coordinator = _make_coordinator(snapshot) + coordinator.panel_offline = True + description = next(desc for desc in BINARY_SENSORS if desc.key == SYSTEM_DOOR_STATE) + entity = SpanPanelBinarySensor(coordinator, description) + entity.async_write_ha_state = MagicMock() + + entity._handle_coordinator_update() + + assert entity.available is True + assert entity.is_on is None + + +def test_evse_binary_sensor_reports_unknown_when_panel_offline() -> None: + """EVSE binary sensors should clear state when the panel is offline.""" + snapshot = SpanPanelSnapshotFactory.create( + evse={"evse-0": SpanEvseSnapshotFactory.create(status="CHARGING")} + ) + coordinator = _make_coordinator(snapshot) + coordinator.panel_offline = True + description = next( + desc for desc in EVSE_BINARY_SENSORS if desc.key == "evse_charging" + ) + entity = SpanEvseBinarySensor(coordinator, description, "evse-0") + entity.async_write_ha_state = MagicMock() + + entity._handle_coordinator_update() + + assert entity.is_on is None + + +def test_grid_islandable_sensor_uses_online_status_value() -> None: + """Non-status binary sensors should mirror their live boolean value when online.""" + snapshot = SpanPanelSnapshotFactory.create(grid_islandable=False) + coordinator = _make_coordinator(snapshot) + entity = SpanPanelBinarySensor(coordinator, GRID_ISLANDABLE_SENSOR) + entity.async_write_ha_state = MagicMock() + + entity._handle_coordinator_update() + + assert entity.is_on is False + assert entity.available is True + + +def test_bess_connected_sensor_becomes_unavailable_when_panel_offline() -> None: + """Non-status binary sensors should become unavailable when the panel is offline.""" + snapshot = SpanPanelSnapshotFactory.create( + battery=SpanBatterySnapshotFactory.create(soe_percentage=85.0, connected=True) + ) + coordinator = _make_coordinator(snapshot) + coordinator.panel_offline = True + entity = SpanPanelBinarySensor(coordinator, BESS_CONNECTED_SENSOR) + entity.async_write_ha_state = MagicMock() + + entity._handle_coordinator_update() + + assert entity.available is False + + +def test_binary_sensor_with_none_value_shows_unknown_not_unavailable() -> None: + """Binary sensors should show unknown (not unavailable) when value_fn returns None.""" + snapshot = SpanPanelSnapshotFactory.create( + battery=SpanBatterySnapshotFactory.create(soe_percentage=85.0, connected=None) + ) + coordinator = _make_coordinator(snapshot) + entity = SpanPanelBinarySensor(coordinator, BESS_CONNECTED_SENSOR) + entity.async_write_ha_state = MagicMock() + + entity._handle_coordinator_update() + + assert entity.is_on is None + assert entity.available is True + + +def test_door_state_unknown_shows_unknown_not_unavailable() -> None: + """Door sensor with UNKNOWN state should show unknown, not unavailable. + + Firmware may report UNKNOWN when the door has not been operated recently. + """ + snapshot = SpanPanelSnapshotFactory.create(door_state="UNKNOWN") + coordinator = _make_coordinator(snapshot) + description = next(desc for desc in BINARY_SENSORS if desc.key == SYSTEM_DOOR_STATE) + entity = SpanPanelBinarySensor(coordinator, description) + entity.async_write_ha_state = MagicMock() + + entity._handle_coordinator_update() + + assert entity.is_on is None + assert entity.available is True + + +def test_evse_binary_sensor_uses_empty_snapshot_when_missing_mid_session() -> None: + """EVSE binary sensors should tolerate the charger disappearing from coordinator data.""" + snapshot = SpanPanelSnapshotFactory.create( + evse={"evse-0": SpanEvseSnapshotFactory.create(status="CHARGING")} + ) + coordinator = _make_coordinator(snapshot) + description = next( + desc for desc in EVSE_BINARY_SENSORS if desc.key == "evse_ev_connected" + ) + entity = SpanEvseBinarySensor(coordinator, description, "evse-0") + entity.async_write_ha_state = MagicMock() + + coordinator.data = SpanPanelSnapshotFactory.create(evse={}) + entity._handle_coordinator_update() + + assert entity.is_on is False + + +async def test_binary_sensor_async_setup_entry_adds_panel_bess_and_evse_entities( + hass: HomeAssistant, +) -> None: + """Platform setup should add the expected binary sensor set and refresh once.""" + snapshot = SpanPanelSnapshotFactory.create( + grid_islandable=True, + battery=SpanBatterySnapshotFactory.create(soe_percentage=85.0, connected=True), + evse={"evse-0": SpanEvseSnapshotFactory.create()}, + ) + coordinator = _make_coordinator(snapshot) + config_entry = MockConfigEntry(domain=DOMAIN, data={}, title="SPAN Panel") + config_entry.runtime_data = MagicMock(coordinator=coordinator) + async_add_entities = MagicMock() + + await async_setup_entry(hass, config_entry, async_add_entities) + + entities = async_add_entities.call_args.args[0] + assert len(entities) == 8 diff --git a/tests/test_button.py b/tests/test_button.py new file mode 100644 index 00000000..521ad6cb --- /dev/null +++ b/tests/test_button.py @@ -0,0 +1,173 @@ +"""Tests for Span Panel button entities.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from span_panel_api.exceptions import SpanPanelServerError + +from custom_components.span_panel.button import ( + GFE_OVERRIDE_DESCRIPTION, + SpanPanelGFEOverrideButton, + async_setup_entry, +) +from custom_components.span_panel.const import DOMAIN +from homeassistant.core import HomeAssistant + +from .factories import SpanBatterySnapshotFactory, SpanPanelSnapshotFactory + +from pytest_homeassistant_custom_component.common import MockConfigEntry + + +def _make_button_coordinator(snapshot) -> MagicMock: + """Create a coordinator-like mock for button tests.""" + coordinator = MagicMock() + coordinator.data = snapshot + coordinator.panel_offline = False + coordinator.config_entry = MockConfigEntry( + domain=DOMAIN, + data={}, + options={}, + title="SPAN Panel", + unique_id=snapshot.serial_number, + ) + coordinator.async_request_refresh = AsyncMock() + return coordinator + + +@pytest.mark.asyncio +async def test_gfe_override_button_success_refreshes_coordinator() -> None: + """Successful override publishes to the panel and refreshes state.""" + snapshot = SpanPanelSnapshotFactory.create( + battery=SpanBatterySnapshotFactory.create(connected=False), + dominant_power_source="BATTERY", + ) + coordinator = _make_button_coordinator(snapshot) + coordinator.client = MagicMock() + coordinator.client.set_dominant_power_source = AsyncMock() + button = SpanPanelGFEOverrideButton(coordinator, GFE_OVERRIDE_DESCRIPTION, "GRID") + + await button.async_press() + + coordinator.client.set_dominant_power_source.assert_awaited_once_with("GRID") + coordinator.async_request_refresh.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_gfe_override_button_logs_when_client_lacks_support( + caplog: pytest.LogCaptureFixture, +) -> None: + """Buttons should return early when the client cannot publish overrides.""" + snapshot = SpanPanelSnapshotFactory.create( + battery=SpanBatterySnapshotFactory.create(connected=False), + dominant_power_source="BATTERY", + ) + coordinator = _make_button_coordinator(snapshot) + coordinator.client = object() + button = SpanPanelGFEOverrideButton(coordinator, GFE_OVERRIDE_DESCRIPTION, "GRID") + + caplog.set_level("WARNING") + + await button.async_press() + + assert "Client does not support GFE override" in caplog.text + coordinator.async_request_refresh.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_gfe_override_button_server_error_creates_notification() -> None: + """Server errors should notify the user that override failed.""" + snapshot = SpanPanelSnapshotFactory.create( + battery=SpanBatterySnapshotFactory.create(connected=False), + dominant_power_source="BATTERY", + ) + coordinator = _make_button_coordinator(snapshot) + coordinator.client = MagicMock() + coordinator.client.set_dominant_power_source = AsyncMock( + side_effect=SpanPanelServerError("unsupported") + ) + button = SpanPanelGFEOverrideButton(coordinator, GFE_OVERRIDE_DESCRIPTION, "GRID") + button.hass = MagicMock() + + with patch( + "custom_components.span_panel.button.async_create_span_notification", + new=AsyncMock(), + ) as mock_notification: + await button.async_press() + + mock_notification.assert_awaited_once() + coordinator.async_request_refresh.assert_not_awaited() + + +def test_gfe_override_button_available_only_when_override_is_relevant() -> None: + """Availability should reflect panel state and whether firmware already has control.""" + snapshot = SpanPanelSnapshotFactory.create( + battery=SpanBatterySnapshotFactory.create(connected=False), + dominant_power_source="BATTERY", + ) + coordinator = _make_button_coordinator(snapshot) + button = SpanPanelGFEOverrideButton(coordinator, GFE_OVERRIDE_DESCRIPTION, "GRID") + + assert button.available is True + + coordinator.panel_offline = True + assert button.available is False + + coordinator.panel_offline = False + coordinator.data = SpanPanelSnapshotFactory.create( + battery=SpanBatterySnapshotFactory.create(connected=True), + dominant_power_source="BATTERY", + ) + assert button.available is False + + coordinator.data = SpanPanelSnapshotFactory.create( + battery=SpanBatterySnapshotFactory.create(connected=False), + dominant_power_source="GRID", + ) + assert button.available is False + + +@pytest.mark.asyncio +async def test_button_async_setup_entry_only_adds_button_for_mqtt_with_bess( + hass: HomeAssistant, +) -> None: + """Button platform should only expose the override when it can work.""" + + class FakeSpanMqttClient: + """Minimal client type used for isinstance checks.""" + + snapshot = SpanPanelSnapshotFactory.create( + battery=SpanBatterySnapshotFactory.create(connected=False), + dominant_power_source="BATTERY", + ) + coordinator = _make_button_coordinator(snapshot) + coordinator.client = FakeSpanMqttClient() + config_entry = MockConfigEntry(domain=DOMAIN, data={}, title="SPAN Panel") + config_entry.runtime_data = MagicMock(coordinator=coordinator) + async_add_entities = MagicMock() + + with patch( + "custom_components.span_panel.button.SpanMqttClient", FakeSpanMqttClient + ): + await async_setup_entry(hass, config_entry, async_add_entities) + + entities = async_add_entities.call_args.args[0] + assert len(entities) == 1 + assert isinstance(entities[0], SpanPanelGFEOverrideButton) + + coordinator_no_bess = _make_button_coordinator( + SpanPanelSnapshotFactory.create( + battery=SpanBatterySnapshotFactory.create(soe_percentage=None) + ) + ) + coordinator_no_bess.client = FakeSpanMqttClient() + config_entry.runtime_data = MagicMock(coordinator=coordinator_no_bess) + async_add_entities = MagicMock() + + with patch( + "custom_components.span_panel.button.SpanMqttClient", FakeSpanMqttClient + ): + await async_setup_entry(hass, config_entry, async_add_entities) + + assert async_add_entities.call_args.args[0] == [] diff --git a/tests/test_circuit_control.py b/tests/test_circuit_control.py index 45d55806..59ac49f3 100644 --- a/tests/test_circuit_control.py +++ b/tests/test_circuit_control.py @@ -1,13 +1,19 @@ -"""test_circuit_control. - -Tests for Span Panel circuit control functionality (switches, relay operations). -""" +"""Tests for Span Panel circuit control functionality (switches, relay operations).""" +from dataclasses import replace from typing import Any -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import AsyncMock, MagicMock, PropertyMock import pytest +from custom_components.span_panel.switch import ( + SpanPanelCircuitsSwitch, + async_setup_entry, +) +from homeassistant.core import HomeAssistant + +from .factories import SpanCircuitSnapshotFactory, SpanPanelSnapshotFactory + @pytest.fixture(autouse=True) def expected_lingering_timers(): @@ -15,347 +21,852 @@ def expected_lingering_timers(): return True -def create_mock_circuit( - circuit_id: str = "1", - name: str = "Test Circuit", - relay_state: str = "CLOSED", - is_user_controllable: bool = True, -): - """Create a mock circuit for testing.""" - circuit = MagicMock() - circuit.id = circuit_id - circuit.name = name - circuit.relay_state = relay_state - circuit.is_user_controllable = is_user_controllable - circuit.tabs = [int(circuit_id)] - circuit.copy.return_value = circuit - return circuit - - -def create_mock_span_panel(circuits: dict[str, Any]): - """Create a mock SpanPanel with circuits.""" - panel = MagicMock() - panel.circuits = circuits - panel.status.serial_number = "TEST123" - panel.api = MagicMock() # Add api attribute for mock compatibility - return panel +def _make_coordinator( + circuits: dict[str, Any], + *, + panel_offline: bool = False, + options: dict[str, Any] | None = None, +) -> MagicMock: + """Create a coordinator mock with a real snapshot from factories.""" + snapshot = SpanPanelSnapshotFactory.create(circuits=circuits) + coordinator = MagicMock() + coordinator.data = snapshot + coordinator.panel_offline = panel_offline + coordinator.config_entry = MagicMock() + coordinator.config_entry.title = "SPAN Panel" + coordinator.config_entry.data = {} + coordinator.config_entry.options = options or {} + coordinator.request_reload = MagicMock() + coordinator.async_request_refresh = AsyncMock() + coordinator.client = AsyncMock() + return coordinator @pytest.mark.asyncio -async def test_switch_creation_for_controllable_circuit(hass: Any, enable_custom_integrations: Any): - """Test that switches are created only for user-controllable circuits.""" - # Lazy imports to avoid collection issues - from custom_components.span_panel.const import CircuitRelayState - from custom_components.span_panel.switch import ( - async_setup_entry, - ) - - # Create controllable circuit - controllable_circuit = create_mock_circuit( +async def test_switch_creation_for_controllable_circuit(hass: HomeAssistant) -> None: + """Switches are created only for user-controllable circuits.""" + controllable = SpanCircuitSnapshotFactory.create( circuit_id="1", name="Kitchen Outlets", - relay_state=CircuitRelayState.CLOSED.name, is_user_controllable=True, ) - - # Create non-controllable circuit - non_controllable_circuit = create_mock_circuit( + non_controllable = SpanCircuitSnapshotFactory.create( circuit_id="2", name="Main Feed", - relay_state=CircuitRelayState.CLOSED.name, is_user_controllable=False, ) + coordinator = _make_coordinator({"1": controllable, "2": non_controllable}) - circuits = { - "1": controllable_circuit, - "2": non_controllable_circuit, - } - - mock_panel = create_mock_span_panel(circuits) - mock_coordinator = MagicMock() - mock_coordinator.data = mock_panel - # Add proper config entry with title for device name fallback - mock_coordinator.config_entry = MagicMock() - mock_coordinator.config_entry.title = "SPAN Panel" - mock_coordinator.config_entry.data = {} # Empty data dict - mock_coordinator.config_entry.options = {} # Empty options dict - - entities = [] - - def mock_add_entities(new_entities, update_before_add: bool = False): - entities.extend(new_entities) - - mock_config_entry = MagicMock() - mock_config_entry.entry_id = "test_entry" - mock_config_entry.title = "SPAN Panel" # Provide string title - mock_config_entry.data = {} # Empty data dict for device_name fallback to title - - # Mock hass data - hass.data = { - "span_panel": { - "test_entry": { - "coordinator": mock_coordinator, - } - } - } + entities: list[Any] = [] + mock_entry = MagicMock() + mock_entry.title = "SPAN Panel" + mock_entry.data = {} + mock_entry.runtime_data = MagicMock(coordinator=coordinator) - await async_setup_entry(hass, mock_config_entry, mock_add_entities) + await async_setup_entry(hass, mock_entry, lambda e, **kw: entities.extend(e)) - # Should only create one switch for the controllable circuit assert len(entities) == 1 - assert entities[0]._circuit_id == "1" # Access private attribute + assert entities[0]._circuit_id == "1" @pytest.mark.asyncio -async def test_switch_turn_on_operation(hass: Any, enable_custom_integrations: Any): - """Test turning on a circuit switch.""" - # Lazy imports to avoid collection issues - from custom_components.span_panel.const import CircuitRelayState - from custom_components.span_panel.switch import SpanPanelCircuitsSwitch - - circuit = create_mock_circuit( +async def test_switch_turn_on_operation() -> None: + """Turning on a switch sends CLOSED to the client.""" + circuit = SpanCircuitSnapshotFactory.create( circuit_id="1", name="Kitchen Outlets", - relay_state=CircuitRelayState.OPEN.name, + relay_state="OPEN", ) + coordinator = _make_coordinator({"1": circuit}) - circuits = {"1": circuit} - mock_panel = create_mock_span_panel(circuits) - mock_panel.api.set_relay = AsyncMock() - - mock_coordinator = MagicMock() - mock_coordinator.data = mock_panel - # Add proper config entry with title for device name fallback - mock_coordinator.config_entry = MagicMock() - mock_coordinator.config_entry.title = "SPAN Panel" - mock_coordinator.config_entry.data = {} # Empty data dict - mock_coordinator.config_entry.options = {} # Empty options dict - mock_coordinator.async_request_refresh = AsyncMock() - - switch = SpanPanelCircuitsSwitch(mock_coordinator, "1", "Kitchen Outlets", "SPAN Panel") - - # Turn on the switch + switch = SpanPanelCircuitsSwitch(coordinator, "1", "Kitchen Outlets", "SPAN Panel") + switch.async_write_ha_state = MagicMock() await switch.async_turn_on() - # Verify API was called with correct parameters - mock_panel.api.set_relay.assert_called_once() - call_args = mock_panel.api.set_relay.call_args - assert call_args[0][1] == CircuitRelayState.CLOSED # Second argument should be CLOSED - - # Verify refresh was requested - mock_coordinator.async_request_refresh.assert_called_once() + coordinator.client.set_circuit_relay.assert_called_once_with("1", "CLOSED") + assert switch.is_on is True @pytest.mark.asyncio -async def test_switch_turn_off_operation(hass: Any, enable_custom_integrations: Any): - """Test turning off a circuit switch.""" - # Lazy imports to avoid collection issues - from custom_components.span_panel.const import CircuitRelayState - from custom_components.span_panel.switch import SpanPanelCircuitsSwitch - - circuit = create_mock_circuit( +async def test_switch_turn_off_operation() -> None: + """Turning off a switch sends OPEN to the client.""" + circuit = SpanCircuitSnapshotFactory.create( circuit_id="1", name="Kitchen Outlets", - relay_state=CircuitRelayState.CLOSED.name, + relay_state="CLOSED", ) + coordinator = _make_coordinator({"1": circuit}) - circuits = {"1": circuit} - mock_panel = create_mock_span_panel(circuits) - mock_panel.api.set_relay = AsyncMock() + switch = SpanPanelCircuitsSwitch(coordinator, "1", "Kitchen Outlets", "SPAN Panel") + switch.async_write_ha_state = MagicMock() + await switch.async_turn_off() - mock_coordinator = MagicMock() - mock_coordinator.data = mock_panel - # Add proper config entry with title for device name fallback - mock_coordinator.config_entry = MagicMock() - mock_coordinator.config_entry.title = "SPAN Panel" - mock_coordinator.config_entry.data = {} # Empty data dict - mock_coordinator.config_entry.options = {} # Empty options dict - mock_coordinator.async_request_refresh = AsyncMock() + coordinator.client.set_circuit_relay.assert_called_once_with("1", "OPEN") + assert switch.is_on is False - switch = SpanPanelCircuitsSwitch(mock_coordinator, "1", "Kitchen Outlets", "SPAN Panel") - # Turn off the switch - await switch.async_turn_off() +def test_switch_state_reflects_relay_state() -> None: + """Switch is_on mirrors the circuit relay state.""" + closed = SpanCircuitSnapshotFactory.create( + circuit_id="1", name="Kitchen", relay_state="CLOSED" + ) + coordinator_closed = _make_coordinator({"1": closed}) + assert ( + SpanPanelCircuitsSwitch(coordinator_closed, "1", "Kitchen", "SPAN Panel").is_on + is True + ) - # Verify API was called with correct parameters - mock_panel.api.set_relay.assert_called_once() - call_args = mock_panel.api.set_relay.call_args - assert call_args[0][1] == CircuitRelayState.OPEN # Second argument should be OPEN + opened = SpanCircuitSnapshotFactory.create( + circuit_id="1", name="Kitchen", relay_state="OPEN" + ) + coordinator_open = _make_coordinator({"1": opened}) + assert ( + SpanPanelCircuitsSwitch(coordinator_open, "1", "Kitchen", "SPAN Panel").is_on + is False + ) - # Verify refresh was requested - mock_coordinator.async_request_refresh.assert_called_once() +def test_switch_handles_missing_circuit() -> None: + """Constructing a switch for a missing circuit raises ValueError.""" + coordinator = _make_coordinator({}) -@pytest.mark.asyncio -async def test_switch_state_reflects_relay_state(hass: Any, enable_custom_integrations: Any): - """Test that switch state correctly reflects circuit relay state.""" - # Lazy imports to avoid collection issues - from custom_components.span_panel.const import CircuitRelayState - from custom_components.span_panel.switch import SpanPanelCircuitsSwitch - - # Test CLOSED relay -> switch ON - circuit_closed = create_mock_circuit( + with pytest.raises(ValueError, match="Circuit 1 not found"): + SpanPanelCircuitsSwitch(coordinator, "1", "Missing Circuit", "SPAN Panel") + + +def test_switch_coordinator_update_handling(hass: HomeAssistant) -> None: + """Switch reflects new relay state after coordinator data changes.""" + circuit = SpanCircuitSnapshotFactory.create( circuit_id="1", name="Kitchen Outlets", - relay_state=CircuitRelayState.CLOSED.name, + relay_state="CLOSED", ) + coordinator = _make_coordinator({"1": circuit}) - circuits_closed = {"1": circuit_closed} - mock_panel_closed = create_mock_span_panel(circuits_closed) + switch = SpanPanelCircuitsSwitch(coordinator, "1", "Kitchen Outlets", "SPAN Panel") + switch.hass = hass + switch.entity_id = "switch.span_panel_kitchen_outlets_breaker" + switch.registry_entry = MagicMock() + switch.platform = MagicMock(platform_name="switch") + + assert switch.is_on is True - mock_coordinator_closed = MagicMock() - mock_coordinator_closed.data = mock_panel_closed - # Add proper config entry with title for device name fallback - mock_coordinator_closed.config_entry = MagicMock() - mock_coordinator_closed.config_entry.title = "SPAN Panel" - mock_coordinator_closed.config_entry.data = {} # Empty data dict - mock_coordinator_closed.config_entry.options = {} # Empty options dict + # Replace snapshot with an OPEN relay + updated = replace(circuit, relay_state="OPEN") + coordinator.data = SpanPanelSnapshotFactory.create(circuits={"1": updated}) + switch._handle_coordinator_update() - switch_closed = SpanPanelCircuitsSwitch(mock_coordinator_closed, "1", "Kitchen Outlets", "SPAN Panel") + assert switch.is_on is False - # Check that switch is on for CLOSED relay - assert switch_closed.is_on is True - # Test OPEN relay -> switch OFF - circuit_open = create_mock_circuit( +def test_circuit_name_change_triggers_reload_request(hass: HomeAssistant) -> None: + """Changing a circuit name triggers an integration reload.""" + circuit = SpanCircuitSnapshotFactory.create( circuit_id="1", name="Kitchen Outlets", - relay_state=CircuitRelayState.OPEN.name, + relay_state="CLOSED", ) + coordinator = _make_coordinator({"1": circuit}) - circuits_open = {"1": circuit_open} - mock_panel_open = create_mock_span_panel(circuits_open) + switch = SpanPanelCircuitsSwitch(coordinator, "1", "Kitchen Outlets", "SPAN Panel") + switch.hass = hass + switch.entity_id = "switch.span_panel_kitchen_outlets_breaker" + switch.registry_entry = MagicMock() + switch.platform = MagicMock(platform_name="switch") - mock_coordinator_open = MagicMock() - mock_coordinator_open.data = mock_panel_open - # Add proper config entry with title for device name fallback - mock_coordinator_open.config_entry = MagicMock() - mock_coordinator_open.config_entry.title = "SPAN Panel" - mock_coordinator_open.config_entry.data = {} # Empty data dict - mock_coordinator_open.config_entry.options = {} # Empty options dict + # Replace snapshot with a renamed circuit + renamed = replace(circuit, name="New Kitchen Outlets") + coordinator.data = SpanPanelSnapshotFactory.create(circuits={"1": renamed}) + switch._handle_coordinator_update() - switch_open = SpanPanelCircuitsSwitch(mock_coordinator_open, "1", "Kitchen Outlets", "SPAN Panel") + coordinator.request_reload.assert_called_once() - # Check that switch is off for OPEN relay - assert switch_open.is_on is False +def test_switch_unavailable_when_panel_offline() -> None: + """Switches become unavailable when the panel is offline.""" + circuit = SpanCircuitSnapshotFactory.create(circuit_id="1") + coordinator = _make_coordinator({"1": circuit}, panel_offline=True) -@pytest.mark.asyncio -async def test_switch_handles_missing_circuit(hass: Any, enable_custom_integrations: Any): - """Test that switch handles gracefully when circuit is missing.""" - # Lazy imports to avoid collection issues - from custom_components.span_panel.switch import SpanPanelCircuitsSwitch - - # Empty circuits dict - circuits = {} - mock_panel = create_mock_span_panel(circuits) - - mock_coordinator = MagicMock() - mock_coordinator.data = mock_panel - # Add proper config entry with title for device name fallback - mock_coordinator.config_entry = MagicMock() - mock_coordinator.config_entry.title = "SPAN Panel" - mock_coordinator.config_entry.data = {} # Empty data dict - mock_coordinator.config_entry.options = {} # Empty options dict - - # Should raise ValueError for missing circuit - with pytest.raises(ValueError, match="Circuit 1 not found"): - SpanPanelCircuitsSwitch(mock_coordinator, "1", "Missing Circuit", "SPAN Panel") + switch = SpanPanelCircuitsSwitch(coordinator, "1", "Test Circuit", "SPAN Panel") + assert switch.available is False + + +def test_switch_extra_state_attributes_include_tabs_and_voltage() -> None: + """Switches expose circuit tab and voltage metadata.""" + circuit = SpanCircuitSnapshotFactory.create(circuit_id="1", tabs=[1]) + coordinator = _make_coordinator({"1": circuit}) + + switch = SpanPanelCircuitsSwitch(coordinator, "1", "Test Circuit", "SPAN Panel") + assert switch.extra_state_attributes == {"tabs": "tabs [1]", "voltage": 120} @pytest.mark.asyncio -async def test_switch_coordinator_update_handling(hass: Any, enable_custom_integrations: Any): - """Test switch updates correctly when coordinator data changes.""" - # Lazy imports to avoid collection issues - from custom_components.span_panel.const import CircuitRelayState - from custom_components.span_panel.switch import SpanPanelCircuitsSwitch +async def test_switch_turn_on_without_relay_support_logs_and_returns( + caplog: pytest.LogCaptureFixture, +) -> None: + """Switches should no-op when the client lacks relay control.""" + circuit = SpanCircuitSnapshotFactory.create(circuit_id="1", relay_state="OPEN") + coordinator = _make_coordinator({"1": circuit}) + coordinator.client = MagicMock(spec=[]) + + switch = SpanPanelCircuitsSwitch(coordinator, "1", "Test Circuit", "SPAN Panel") + await switch.async_turn_on() + + assert "Client does not support relay control" in caplog.text + + +def test_switch_relay_state_target_shows_pending_state() -> None: + """Switch should show the target state while a relay command is pending.""" + # Panel reports OPEN but has a pending target of CLOSED + circuit = SpanCircuitSnapshotFactory.create( + circuit_id="1", relay_state="OPEN", relay_state_target="CLOSED" + ) + coordinator = _make_coordinator({"1": circuit}) + + switch = SpanPanelCircuitsSwitch(coordinator, "1", "Test Circuit", "SPAN Panel") + # Target differs from actual — switch should show target (CLOSED = on) + assert switch.is_on is True + + # Once the panel confirms the relay change, target matches actual + confirmed = SpanCircuitSnapshotFactory.create( + circuit_id="1", relay_state="CLOSED", relay_state_target="CLOSED" + ) + coordinator.data = SpanPanelSnapshotFactory.create(circuits={"1": confirmed}) + switch._update_is_on() + assert switch.is_on is True + - circuit = create_mock_circuit( +def test_switch_relay_state_target_none_uses_actual_state() -> None: + """Switch should use actual relay state when no target is published.""" + circuit = SpanCircuitSnapshotFactory.create( + circuit_id="1", relay_state="OPEN", relay_state_target=None + ) + coordinator = _make_coordinator({"1": circuit}) + + switch = SpanPanelCircuitsSwitch(coordinator, "1", "Test Circuit", "SPAN Panel") + assert switch.is_on is False + + +@pytest.mark.asyncio +async def test_switch_async_setup_entry_filters_supported_circuits( + hass: HomeAssistant, +) -> None: + """Setup should only create switches for supported controllable circuits.""" + controllable = SpanCircuitSnapshotFactory.create( circuit_id="1", - name="Kitchen Outlets", - relay_state=CircuitRelayState.CLOSED.name, + is_user_controllable=True, ) + locked = SpanCircuitSnapshotFactory.create( + circuit_id="2", + is_user_controllable=False, + ) + evse_upstream = replace( + SpanCircuitSnapshotFactory.create( + circuit_id="3", + name="EV Upstream", + is_user_controllable=True, + tabs=[3, 4], + ), + device_type="evse", + relative_position="UPSTREAM", + ) + pv_downstream = replace( + SpanCircuitSnapshotFactory.create( + circuit_id="4", + name="Solar", + is_user_controllable=True, + tabs=[5, 6], + ), + device_type="pv", + relative_position="DOWNSTREAM", + ) + + coordinator = _make_coordinator( + { + "1": controllable, + "2": locked, + "3": evse_upstream, + "4": pv_downstream, + } + ) + + entities: list[Any] = [] + mock_entry = MagicMock() + mock_entry.title = "SPAN Panel" + mock_entry.data = {} + mock_entry.runtime_data = MagicMock(coordinator=coordinator) - circuits = {"1": circuit} - mock_panel = create_mock_span_panel(circuits) + await async_setup_entry(hass, mock_entry, lambda e, **kw: entities.extend(e)) - mock_coordinator = MagicMock() - mock_coordinator.data = mock_panel - # Add proper config entry with title for device name fallback - mock_coordinator.config_entry = MagicMock() - mock_coordinator.config_entry.title = "SPAN Panel" - mock_coordinator.config_entry.data = {} # Empty data dict - mock_coordinator.config_entry.options = {} # Empty options dict + assert len(entities) == 2 + assert {entity._circuit_id for entity in entities} == {"1", "4"} - switch = SpanPanelCircuitsSwitch(mock_coordinator, "1", "Kitchen Outlets", "SPAN Panel") - # Add mock hass and entity registry to prevent "hass is None" error +def test_switch_existing_entity_uses_solar_fallback_name(hass: HomeAssistant) -> None: + """Existing solar switch entities should sync to the solar fallback label.""" + solar = replace( + SpanCircuitSnapshotFactory.create( + circuit_id="15", + name="", + tabs=[15], + is_user_controllable=True, + ), + device_type="pv", + ) + coordinator = _make_coordinator({"15": solar}) + coordinator.hass = hass + + with pytest.MonkeyPatch.context() as mp: + registry = MagicMock() + registry.async_get_entity_id.return_value = "switch.solar_breaker" + mp.setattr( + "custom_components.span_panel.switch.er.async_get", + lambda _hass: registry, + ) + switch = SpanPanelCircuitsSwitch(coordinator, "15", "", "SPAN Panel") + + assert switch.name == "Solar Breaker" + + +def test_switch_initial_install_uses_circuit_numbers_when_enabled( + hass: HomeAssistant, +) -> None: + """Initial switch names should honor the use-circuit-numbers option.""" + circuit = SpanCircuitSnapshotFactory.create( + circuit_id="2", + name="Kitchen", + tabs=[2, 3], + is_user_controllable=True, + ) + coordinator = _make_coordinator( + {"2": circuit}, options={"use_circuit_numbers": True} + ) + coordinator.hass = hass + + with pytest.MonkeyPatch.context() as mp: + registry = MagicMock() + registry.async_get_entity_id.return_value = None + mp.setattr( + "custom_components.span_panel.switch.er.async_get", + lambda _hass: registry, + ) + switch = SpanPanelCircuitsSwitch(coordinator, "2", "Kitchen", "SPAN Panel") + + assert switch.name == "Circuit 2 3 Breaker" + + +def test_switch_initial_install_without_name_lets_ha_default( + hass: HomeAssistant, +) -> None: + """Unnamed switches on first install should let HA provide the name.""" + circuit = SpanCircuitSnapshotFactory.create( + circuit_id="3", + name="", + tabs=[3], + is_user_controllable=True, + ) + coordinator = _make_coordinator({"3": circuit}) + coordinator.hass = hass + + with pytest.MonkeyPatch.context() as mp: + registry = MagicMock() + registry.async_get_entity_id.return_value = None + mp.setattr( + "custom_components.span_panel.switch.er.async_get", + lambda _hass: registry, + ) + switch = SpanPanelCircuitsSwitch(coordinator, "3", "", "SPAN Panel") + + assert switch._attr_name is None + assert switch._previous_circuit_name is not None + + +def test_switch_first_update_requests_reload_without_user_override( + hass: HomeAssistant, +) -> None: + """First update should request a reload when no user override exists.""" + circuit = SpanCircuitSnapshotFactory.create( + circuit_id="1", name="Kitchen", is_user_controllable=True + ) + coordinator = _make_coordinator({"1": circuit}) + coordinator.hass = hass + + with pytest.MonkeyPatch.context() as mp: + init_registry = MagicMock() + init_registry.async_get_entity_id.return_value = None + mp.setattr( + "custom_components.span_panel.switch.er.async_get", + lambda _hass: init_registry, + ) + switch = SpanPanelCircuitsSwitch(coordinator, "1", "Kitchen", "SPAN Panel") + switch.hass = hass - switch.entity_id = "switch.span_panel_kitchen_outlets_breaker" - switch.registry_entry = MagicMock() + switch.entity_id = "switch.kitchen_breaker" + switch.async_write_ha_state = MagicMock() - # Add mock platform to prevent platform_name error - mock_platform = MagicMock() - mock_platform.platform_name = "switch" - switch.platform = mock_platform + with pytest.MonkeyPatch.context() as mp: + runtime_registry = MagicMock() + runtime_registry.async_get.return_value = None + mp.setattr( + "custom_components.span_panel.switch.er.async_get", + lambda _hass: runtime_registry, + ) + switch._handle_coordinator_update() - # Initially should be on (CLOSED) - assert switch.is_on is True + coordinator.request_reload.assert_called_once() - # Change circuit state to OPEN - circuit.relay_state = CircuitRelayState.OPEN.name - # Trigger coordinator update (now won't fail due to hass being None) - switch._handle_coordinator_update() +def test_switch_user_override_skips_reload_on_update(hass: HomeAssistant) -> None: + """User-customized switch names should suppress auto-sync reloads.""" + circuit = SpanCircuitSnapshotFactory.create( + circuit_id="1", name="Kitchen", is_user_controllable=True + ) + coordinator = _make_coordinator({"1": circuit}) + coordinator.hass = hass + + with pytest.MonkeyPatch.context() as mp: + init_registry = MagicMock() + init_registry.async_get_entity_id.return_value = "switch.kitchen_breaker" + mp.setattr( + "custom_components.span_panel.switch.er.async_get", + lambda _hass: init_registry, + ) + switch = SpanPanelCircuitsSwitch(coordinator, "1", "Kitchen", "SPAN Panel") + + renamed = replace(circuit, name="Renamed Kitchen") + coordinator.data = SpanPanelSnapshotFactory.create(circuits={"1": renamed}) + switch.hass = hass + switch.entity_id = "switch.kitchen_breaker" + switch.async_write_ha_state = MagicMock() + + with pytest.MonkeyPatch.context() as mp: + runtime_registry = MagicMock() + runtime_registry.async_get.return_value = MagicMock( + name="Custom Kitchen Breaker" + ) + mp.setattr( + "custom_components.span_panel.switch.er.async_get", + lambda _hass: runtime_registry, + ) + switch._handle_coordinator_update() + + coordinator.request_reload.assert_not_called() + assert switch._previous_circuit_name == "Renamed Kitchen" + + +def test_switch_update_is_on_clears_state_when_circuit_disappears( + hass: HomeAssistant, +) -> None: + """Switch state should clear when its circuit disappears from coordinator data.""" + circuit = SpanCircuitSnapshotFactory.create( + circuit_id="1", is_user_controllable=True + ) + coordinator = _make_coordinator({"1": circuit}) + coordinator.hass = hass - # Switch should now be off - assert switch.is_on is False + with pytest.MonkeyPatch.context() as mp: + registry = MagicMock() + registry.async_get_entity_id.return_value = None + mp.setattr( + "custom_components.span_panel.switch.er.async_get", + lambda _hass: registry, + ) + switch = SpanPanelCircuitsSwitch(coordinator, "1", "Test Circuit", "SPAN Panel") + + coordinator.data = SpanPanelSnapshotFactory.create(circuits={}) + switch._update_is_on() + + assert switch.is_on is None @pytest.mark.asyncio -async def test_circuit_name_change_triggers_reload_request( - hass: Any, enable_custom_integrations: Any -): - """Test that changing circuit name triggers integration reload.""" - # Lazy imports to avoid collection issues - from custom_components.span_panel.const import CircuitRelayState - from custom_components.span_panel.switch import SpanPanelCircuitsSwitch - - circuit = create_mock_circuit( +async def test_switch_turn_off_without_relay_support_logs_and_returns( + caplog: pytest.LogCaptureFixture, +) -> None: + """Turning off should no-op when the client lacks relay control.""" + circuit = SpanCircuitSnapshotFactory.create(circuit_id="1", relay_state="CLOSED") + coordinator = _make_coordinator({"1": circuit}) + coordinator.client = MagicMock(spec=[]) + + switch = SpanPanelCircuitsSwitch(coordinator, "1", "Test Circuit", "SPAN Panel") + await switch.async_turn_off() + + assert "Client does not support relay control" in caplog.text + + +def test_switch_turn_on_off_schedule_async_tasks(hass: HomeAssistant) -> None: + """Sync turn_on/turn_off wrappers should schedule async tasks.""" + circuit = SpanCircuitSnapshotFactory.create( + circuit_id="1", is_user_controllable=True + ) + coordinator = _make_coordinator({"1": circuit}) + coordinator.hass = hass + + with pytest.MonkeyPatch.context() as mp: + registry = MagicMock() + registry.async_get_entity_id.return_value = None + mp.setattr( + "custom_components.span_panel.switch.er.async_get", + lambda _hass: registry, + ) + switch = SpanPanelCircuitsSwitch(coordinator, "1", "Test Circuit", "SPAN Panel") + + switch.hass = MagicMock() + on_task = object() + off_task = object() + switch.async_turn_on = MagicMock(return_value=on_task) + switch.async_turn_off = MagicMock(return_value=off_task) + + switch.turn_on() + switch.turn_off() + + assert switch.hass.create_task.call_count == 2 + switch.hass.create_task.assert_any_call(on_task) + switch.hass.create_task.assert_any_call(off_task) + + +def test_switch_relay_state_target_exposed_in_attributes() -> None: + """relay_state_target should appear in extra attributes when set.""" + circuit = SpanCircuitSnapshotFactory.create( circuit_id="1", - name="Kitchen Outlets", - relay_state=CircuitRelayState.CLOSED.name, + relay_state="OPEN", + relay_state_target="CLOSED", + tabs=[1], ) + coordinator = _make_coordinator({"1": circuit}) - circuits = {"1": circuit} - mock_panel = create_mock_span_panel(circuits) + switch = SpanPanelCircuitsSwitch(coordinator, "1", "Test Circuit", "SPAN Panel") + attrs = switch.extra_state_attributes + assert attrs is not None + assert attrs["relay_state_target"] == "CLOSED" - mock_coordinator = MagicMock() - mock_coordinator.data = mock_panel - # Add proper config entry with title for device name fallback - mock_coordinator.config_entry = MagicMock() - mock_coordinator.config_entry.title = "SPAN Panel" - mock_coordinator.config_entry.data = {} # Empty data dict - mock_coordinator.config_entry.options = {} # Empty options dict - mock_coordinator.request_reload = MagicMock() - switch = SpanPanelCircuitsSwitch(mock_coordinator, "1", "Kitchen Outlets", "SPAN Panel") +def test_switch_relay_state_target_absent_when_none() -> None: + """relay_state_target should not appear in attributes when None.""" + circuit = SpanCircuitSnapshotFactory.create( + circuit_id="1", + relay_state="CLOSED", + relay_state_target=None, + tabs=[1], + ) + coordinator = _make_coordinator({"1": circuit}) - # Add mock hass and entity registry to prevent "hass is None" error - switch.hass = hass - switch.entity_id = "switch.span_panel_kitchen_outlets_breaker" - switch.registry_entry = MagicMock() + switch = SpanPanelCircuitsSwitch(coordinator, "1", "Test Circuit", "SPAN Panel") + attrs = switch.extra_state_attributes + assert attrs is not None + assert "relay_state_target" not in attrs + + +def test_switch_circuit_numbers_entity_id_stable_after_reload( + hass: HomeAssistant, +) -> None: + """Entity_id must stay circuit-based after name sync sets friendly display name.""" + circuit = SpanCircuitSnapshotFactory.create( + circuit_id="2", + name="Air Conditioner", + tabs=[15, 17], + is_user_controllable=True, + ) + coordinator = _make_coordinator( + {"2": circuit}, options={"use_circuit_numbers": True} + ) + coordinator.hass = hass + + # --- Initial install: entity NOT in registry --- + with pytest.MonkeyPatch.context() as mp: + registry = MagicMock() + registry.async_get_entity_id.return_value = None + mp.setattr( + "custom_components.span_panel.switch.er.async_get", + lambda _hass: registry, + ) + switch = SpanPanelCircuitsSwitch( + coordinator, "2", "Air Conditioner", "SPAN Panel" + ) + + assert switch.name == "Circuit 15 17 Breaker" + assert switch.entity_id == "switch.span_panel_circuit_15_17_breaker" + + # --- After reload: entity EXISTS in registry --- + with pytest.MonkeyPatch.context() as mp: + registry = MagicMock() + registry.async_get_entity_id.return_value = ( + "switch.span_panel_circuit_15_17_breaker" + ) + mp.setattr( + "custom_components.span_panel.switch.er.async_get", + lambda _hass: registry, + ) + switch2 = SpanPanelCircuitsSwitch( + coordinator, "2", "Air Conditioner", "SPAN Panel" + ) + + # Entity_id must still be circuit-based + assert switch2.name == "Circuit 15 17 Breaker" + assert switch2.entity_id == "switch.span_panel_circuit_15_17_breaker" + + +def test_switch_circuit_numbers_entity_id_120v_single_tab( + hass: HomeAssistant, +) -> None: + """120V single-tab circuit should produce entity_id with one tab number.""" + circuit = SpanCircuitSnapshotFactory.create( + circuit_id="5", + name="Kitchen Outlets", + tabs=[10], + is_user_controllable=True, + ) + coordinator = _make_coordinator( + {"5": circuit}, options={"use_circuit_numbers": True} + ) + coordinator.hass = hass + + with pytest.MonkeyPatch.context() as mp: + registry = MagicMock() + registry.async_get_entity_id.return_value = None + mp.setattr( + "custom_components.span_panel.switch.er.async_get", + lambda _hass: registry, + ) + switch = SpanPanelCircuitsSwitch( + coordinator, "5", "Kitchen Outlets", "SPAN Panel" + ) + + assert switch.name == "Circuit 10 Breaker" + assert switch.entity_id == "switch.span_panel_circuit_10_breaker" + + +def test_switch_circuit_numbers_syncs_friendly_name_to_registry( + hass: HomeAssistant, +) -> None: + """Registry display name should be synced to the panel friendly name.""" + circuit = SpanCircuitSnapshotFactory.create( + circuit_id="2", + name="Air Conditioner", + tabs=[15, 17], + is_user_controllable=True, + ) + coordinator = _make_coordinator( + {"2": circuit}, options={"use_circuit_numbers": True} + ) + coordinator.hass = hass + + with pytest.MonkeyPatch.context() as mp: + registry = MagicMock() + registry.async_get_entity_id.return_value = ( + "switch.span_panel_circuit_15_17_breaker" + ) + # Simulate registry entry with no user-set name. + # Use PropertyMock because MagicMock(name=...) sets the mock's + # internal label rather than the .name attribute. + entity_entry = MagicMock() + type(entity_entry).name = PropertyMock(return_value=None) + registry.async_get.return_value = entity_entry + mp.setattr( + "custom_components.span_panel.switch.er.async_get", + lambda _hass: registry, + ) + SpanPanelCircuitsSwitch(coordinator, "2", "Air Conditioner", "SPAN Panel") + + registry.async_update_entity.assert_called_once_with( + "switch.span_panel_circuit_15_17_breaker", name="Air Conditioner Breaker" + ) - # Add mock platform to prevent platform_name error - mock_platform = MagicMock() - mock_platform.platform_name = "switch" - switch.platform = mock_platform - # Change circuit name - circuit.name = "New Kitchen Outlets" +def test_switch_circuit_numbers_preserves_user_custom_name( + hass: HomeAssistant, +) -> None: + """User-customized registry names must not be overwritten by sync.""" + circuit = SpanCircuitSnapshotFactory.create( + circuit_id="2", + name="Air Conditioner", + tabs=[15, 17], + is_user_controllable=True, + ) + coordinator = _make_coordinator( + {"2": circuit}, options={"use_circuit_numbers": True} + ) + coordinator.hass = hass + + with pytest.MonkeyPatch.context() as mp: + registry = MagicMock() + registry.async_get_entity_id.return_value = ( + "switch.span_panel_circuit_15_17_breaker" + ) + # Simulate registry entry with a user-customized name. + # Use PropertyMock because MagicMock(name=...) sets the mock's + # internal label rather than the .name attribute. + entity_entry = MagicMock() + type(entity_entry).name = PropertyMock(return_value="My AC Unit") + registry.async_get.return_value = entity_entry + mp.setattr( + "custom_components.span_panel.switch.er.async_get", + lambda _hass: registry, + ) + SpanPanelCircuitsSwitch(coordinator, "2", "Air Conditioner", "SPAN Panel") + + registry.async_update_entity.assert_not_called() + + +def test_switch_coordinator_update_circuit_numbers_updates_registry( + hass: HomeAssistant, +) -> None: + """In circuit-numbers mode, a name change should update the registry display name.""" + circuit = SpanCircuitSnapshotFactory.create( + circuit_id="2", + name="Air Conditioner", + tabs=[15, 17], + is_user_controllable=True, + ) + coordinator = _make_coordinator( + {"2": circuit}, options={"use_circuit_numbers": True} + ) + coordinator.hass = hass + + # Create switch with entity already in registry (existing entity) + with pytest.MonkeyPatch.context() as mp: + registry = MagicMock() + registry.async_get_entity_id.return_value = ( + "switch.span_panel_circuit_15_17_breaker" + ) + entity_entry = MagicMock() + type(entity_entry).name = PropertyMock(return_value="Air Conditioner Breaker") + registry.async_get.return_value = entity_entry + mp.setattr( + "custom_components.span_panel.switch.er.async_get", + lambda _hass: registry, + ) + switch = SpanPanelCircuitsSwitch( + coordinator, "2", "Air Conditioner", "SPAN Panel" + ) + + # Simulate a circuit name change from "Air Conditioner" to "Kitchen AC" + renamed = replace(circuit, name="Kitchen AC") + coordinator.data = SpanPanelSnapshotFactory.create(circuits={"2": renamed}) + switch.hass = hass + switch.entity_id = "switch.span_panel_circuit_15_17_breaker" + switch.async_write_ha_state = MagicMock() + + with pytest.MonkeyPatch.context() as mp: + runtime_registry = MagicMock() + runtime_entry = MagicMock() + type(runtime_entry).name = PropertyMock(return_value="Air Conditioner Breaker") + runtime_registry.async_get.return_value = runtime_entry + mp.setattr( + "custom_components.span_panel.switch.er.async_get", + lambda _hass: runtime_registry, + ) + switch._handle_coordinator_update() + + runtime_registry.async_update_entity.assert_called_once_with( + "switch.span_panel_circuit_15_17_breaker", name="Kitchen AC Breaker" + ) + coordinator.request_reload.assert_not_called() - # Trigger coordinator update (now won't fail due to hass being None) - switch._handle_coordinator_update() - # Should request reload due to name change - mock_coordinator.request_reload.assert_called_once() +def test_switch_coordinator_update_circuit_numbers_preserves_user_override( + hass: HomeAssistant, +) -> None: + """In circuit-numbers mode, user-customized registry names must not be overwritten.""" + circuit = SpanCircuitSnapshotFactory.create( + circuit_id="2", + name="Air Conditioner", + tabs=[15, 17], + is_user_controllable=True, + ) + coordinator = _make_coordinator( + {"2": circuit}, options={"use_circuit_numbers": True} + ) + coordinator.hass = hass + + # Create switch with entity already in registry + with pytest.MonkeyPatch.context() as mp: + registry = MagicMock() + registry.async_get_entity_id.return_value = ( + "switch.span_panel_circuit_15_17_breaker" + ) + entity_entry = MagicMock() + type(entity_entry).name = PropertyMock(return_value="Air Conditioner Breaker") + registry.async_get.return_value = entity_entry + mp.setattr( + "custom_components.span_panel.switch.er.async_get", + lambda _hass: registry, + ) + switch = SpanPanelCircuitsSwitch( + coordinator, "2", "Air Conditioner", "SPAN Panel" + ) + + # Simulate a circuit name change; user has set "My AC Unit" in the registry + renamed = replace(circuit, name="Kitchen AC") + coordinator.data = SpanPanelSnapshotFactory.create(circuits={"2": renamed}) + switch.hass = hass + switch.entity_id = "switch.span_panel_circuit_15_17_breaker" + switch.async_write_ha_state = MagicMock() + + with pytest.MonkeyPatch.context() as mp: + runtime_registry = MagicMock() + runtime_entry = MagicMock() + type(runtime_entry).name = PropertyMock(return_value="My AC Unit") + runtime_registry.async_get.return_value = runtime_entry + mp.setattr( + "custom_components.span_panel.switch.er.async_get", + lambda _hass: runtime_registry, + ) + switch._handle_coordinator_update() + + runtime_registry.async_update_entity.assert_not_called() + coordinator.request_reload.assert_not_called() + + +def test_switch_coordinator_update_friendly_mode_still_reloads( + hass: HomeAssistant, +) -> None: + """In friendly-names mode, name changes should still trigger a reload.""" + circuit = SpanCircuitSnapshotFactory.create( + circuit_id="1", + name="Kitchen Outlets", + is_user_controllable=True, + ) + coordinator = _make_coordinator( + {"1": circuit}, options={"use_circuit_numbers": False} + ) + coordinator.hass = hass + + # Create switch with entity already in registry (previous name known) + with pytest.MonkeyPatch.context() as mp: + registry = MagicMock() + registry.async_get_entity_id.return_value = ( + "switch.span_panel_kitchen_outlets_breaker" + ) + entity_entry = MagicMock() + type(entity_entry).name = PropertyMock(return_value=None) + registry.async_get.return_value = entity_entry + mp.setattr( + "custom_components.span_panel.switch.er.async_get", + lambda _hass: registry, + ) + switch = SpanPanelCircuitsSwitch( + coordinator, "1", "Kitchen Outlets", "SPAN Panel" + ) + + assert switch._previous_circuit_name == "Kitchen Outlets" + + # Simulate a circuit name change + renamed = replace(circuit, name="New Kitchen Outlets") + coordinator.data = SpanPanelSnapshotFactory.create(circuits={"1": renamed}) + switch.hass = hass + switch.entity_id = "switch.span_panel_kitchen_outlets_breaker" + switch.async_write_ha_state = MagicMock() + + with pytest.MonkeyPatch.context() as mp: + runtime_registry = MagicMock() + runtime_entry = MagicMock() + type(runtime_entry).name = PropertyMock(return_value=None) + runtime_registry.async_get.return_value = runtime_entry + mp.setattr( + "custom_components.span_panel.switch.er.async_get", + lambda _hass: runtime_registry, + ) + switch._handle_coordinator_update() + + coordinator.request_reload.assert_called_once() diff --git a/tests/test_circuit_manifest_service.py b/tests/test_circuit_manifest_service.py new file mode 100644 index 00000000..922ef680 --- /dev/null +++ b/tests/test_circuit_manifest_service.py @@ -0,0 +1,471 @@ +"""Tests for the export_circuit_manifest service.""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest + +from custom_components.span_panel import ( + SpanPanelRuntimeData, + _async_register_services, +) +from custom_components.span_panel.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import entity_registry as er + +from .factories import SpanCircuitSnapshotFactory, SpanPanelSnapshotFactory + +from pytest_homeassistant_custom_component.common import MockConfigEntry + + +def _make_coordinator(snapshot): + """Create a mock coordinator wrapping a snapshot.""" + coordinator = MagicMock() + coordinator.data = snapshot + return coordinator + + +def _register_power_entity( + hass: HomeAssistant, + config_entry_id: str, + serial: str, + circuit_id: str, + suggested_entity_id: str, +) -> er.RegistryEntry: + """Register a circuit power sensor entity in the entity registry.""" + config_entry = hass.config_entries.async_get_entry(config_entry_id) + entity_registry = er.async_get(hass) + unique_id = f"span_{serial.lower()}_{circuit_id}_power" + return entity_registry.async_get_or_create( + "sensor", + DOMAIN, + unique_id, + config_entry=config_entry, + suggested_object_id=suggested_entity_id.split(".", 1)[1], + ) + + +async def _call_manifest_service(hass: HomeAssistant): + """Call the export_circuit_manifest service and return the response.""" + return await hass.services.async_call( + DOMAIN, + "export_circuit_manifest", + {}, + blocking=True, + return_response=True, + ) + + +class TestExportCircuitManifest: + """Tests for the export_circuit_manifest service.""" + + @pytest.mark.asyncio + async def test_basic_manifest(self, hass: HomeAssistant): + """Returns correct manifest for a panel with circuits.""" + kitchen = SpanCircuitSnapshotFactory.create( + circuit_id="uuid_kitchen", + name="Kitchen", + tabs=[2, 3], + ) + bedroom = SpanCircuitSnapshotFactory.create( + circuit_id="uuid_bedroom", + name="Bedroom", + tabs=[5], + ) + snapshot = SpanPanelSnapshotFactory.create( + serial_number="sp3-test-001", + circuits={"uuid_kitchen": kitchen, "uuid_bedroom": bedroom}, + ) + + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "192.168.1.100"}, + entry_id="span_entry", + unique_id="sp3-test-001", + ) + entry.add_to_hass(hass) + entry.mock_state(hass, ConfigEntryState.LOADED) + entry.runtime_data = SpanPanelRuntimeData( + coordinator=_make_coordinator(snapshot) + ) + + _register_power_entity( + hass, + "span_entry", + "sp3-test-001", + "uuid_kitchen", + "sensor.span_panel_kitchen_power", + ) + _register_power_entity( + hass, + "span_entry", + "sp3-test-001", + "uuid_bedroom", + "sensor.span_panel_bedroom_power", + ) + + _async_register_services(hass) + result = await _call_manifest_service(hass) + + assert result is not None + assert len(result["panels"]) == 1 + + panel = result["panels"][0] + assert panel["serial"] == "sp3-test-001" + assert panel["host"] == "192.168.1.100" + assert len(panel["circuits"]) == 2 + + by_template = {c["template"]: c for c in panel["circuits"]} + + assert by_template["clone_2"]["device_type"] == "circuit" + assert by_template["clone_2"]["tabs"] == [2, 3] + assert by_template["clone_2"]["entity_id"].endswith("_kitchen_power") + + assert by_template["clone_5"]["device_type"] == "circuit" + assert by_template["clone_5"]["tabs"] == [5] + assert by_template["clone_5"]["entity_id"].endswith("_bedroom_power") + + @pytest.mark.asyncio + async def test_multiple_panels(self, hass: HomeAssistant): + """Returns manifests for all loaded panels.""" + circuit_a = SpanCircuitSnapshotFactory.create( + circuit_id="uuid_a", + tabs=[1], + ) + circuit_b = SpanCircuitSnapshotFactory.create( + circuit_id="uuid_b", + tabs=[3], + ) + snapshot_a = SpanPanelSnapshotFactory.create( + serial_number="serial-aaa", + circuits={"uuid_a": circuit_a}, + ) + snapshot_b = SpanPanelSnapshotFactory.create( + serial_number="serial-bbb", + circuits={"uuid_b": circuit_b}, + ) + + entry_a = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "192.168.1.100"}, + entry_id="entry_a", + unique_id="serial-aaa", + ) + entry_b = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "192.168.1.101"}, + entry_id="entry_b", + unique_id="serial-bbb", + ) + entry_a.add_to_hass(hass) + entry_b.add_to_hass(hass) + entry_a.mock_state(hass, ConfigEntryState.LOADED) + entry_b.mock_state(hass, ConfigEntryState.LOADED) + entry_a.runtime_data = SpanPanelRuntimeData( + coordinator=_make_coordinator(snapshot_a) + ) + entry_b.runtime_data = SpanPanelRuntimeData( + coordinator=_make_coordinator(snapshot_b) + ) + + _register_power_entity( + hass, "entry_a", "serial-aaa", "uuid_a", "sensor.panel_a_power" + ) + _register_power_entity( + hass, "entry_b", "serial-bbb", "uuid_b", "sensor.panel_b_power" + ) + + _async_register_services(hass) + result = await _call_manifest_service(hass) + + assert result is not None + serials = {p["serial"] for p in result["panels"]} + assert serials == {"serial-aaa", "serial-bbb"} + + by_serial = {p["serial"]: p for p in result["panels"]} + assert by_serial["serial-aaa"]["host"] == "192.168.1.100" + assert by_serial["serial-bbb"]["host"] == "192.168.1.101" + + @pytest.mark.asyncio + async def test_unmapped_tabs_excluded(self, hass: HomeAssistant): + """Unmapped tab circuits are excluded from the manifest.""" + real = SpanCircuitSnapshotFactory.create(circuit_id="uuid_real", tabs=[1]) + unmapped = SpanCircuitSnapshotFactory.create( + circuit_id="unmapped_tab_5", tabs=[5] + ) + snapshot = SpanPanelSnapshotFactory.create( + serial_number="sp3-001", + circuits={"uuid_real": real, "unmapped_tab_5": unmapped}, + ) + + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "192.168.1.1"}, + entry_id="span_entry", + unique_id="sp3-001", + ) + entry.add_to_hass(hass) + entry.mock_state(hass, ConfigEntryState.LOADED) + entry.runtime_data = SpanPanelRuntimeData( + coordinator=_make_coordinator(snapshot) + ) + + _register_power_entity( + hass, "span_entry", "sp3-001", "uuid_real", "sensor.real_power" + ) + _register_power_entity( + hass, + "span_entry", + "sp3-001", + "unmapped_tab_5", + "sensor.unmapped_power", + ) + + _async_register_services(hass) + result = await _call_manifest_service(hass) + + panel = result["panels"][0] + templates = [c["template"] for c in panel["circuits"]] + assert "clone_1" in templates + assert "clone_5" not in templates + + @pytest.mark.asyncio + async def test_circuit_without_entity_excluded(self, hass: HomeAssistant): + """Circuits with no registered power entity are excluded.""" + registered = SpanCircuitSnapshotFactory.create(circuit_id="uuid_reg", tabs=[1]) + unregistered = SpanCircuitSnapshotFactory.create( + circuit_id="uuid_unreg", tabs=[3] + ) + snapshot = SpanPanelSnapshotFactory.create( + serial_number="sp3-001", + circuits={"uuid_reg": registered, "uuid_unreg": unregistered}, + ) + + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "192.168.1.1"}, + entry_id="span_entry", + unique_id="sp3-001", + ) + entry.add_to_hass(hass) + entry.mock_state(hass, ConfigEntryState.LOADED) + entry.runtime_data = SpanPanelRuntimeData( + coordinator=_make_coordinator(snapshot) + ) + + # Only register entity for one circuit + _register_power_entity( + hass, "span_entry", "sp3-001", "uuid_reg", "sensor.reg_power" + ) + + _async_register_services(hass) + result = await _call_manifest_service(hass) + + panel = result["panels"][0] + assert len(panel["circuits"]) == 1 + assert panel["circuits"][0]["template"] == "clone_1" + + @pytest.mark.asyncio + async def test_no_loaded_entries_raises_validation_error(self, hass: HomeAssistant): + """Raises ServiceValidationError when no config entries are loaded.""" + _async_register_services(hass) + + with pytest.raises(ServiceValidationError) as excinfo: + await _call_manifest_service(hass) + assert excinfo.value.translation_key == "export_manifest_no_entries" + assert excinfo.value.translation_domain == DOMAIN + + @pytest.mark.asyncio + async def test_all_device_types_included(self, hass: HomeAssistant): + """All device types are included — PV, BESS, EVSE, and regular circuits.""" + regular = SpanCircuitSnapshotFactory.create( + circuit_id="uuid_reg", + tabs=[1], + device_type="circuit", + ) + pv = SpanCircuitSnapshotFactory.create( + circuit_id="uuid_pv", + tabs=[5], + device_type="pv", + ) + evse = SpanCircuitSnapshotFactory.create( + circuit_id="uuid_evse", + tabs=[10, 12], + device_type="evse", + ) + snapshot = SpanPanelSnapshotFactory.create( + serial_number="sp3-001", + circuits={ + "uuid_reg": regular, + "uuid_pv": pv, + "uuid_evse": evse, + }, + ) + + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "192.168.1.1"}, + entry_id="span_entry", + unique_id="sp3-001", + ) + entry.add_to_hass(hass) + entry.mock_state(hass, ConfigEntryState.LOADED) + entry.runtime_data = SpanPanelRuntimeData( + coordinator=_make_coordinator(snapshot) + ) + + _register_power_entity( + hass, "span_entry", "sp3-001", "uuid_reg", "sensor.reg_power" + ) + _register_power_entity( + hass, "span_entry", "sp3-001", "uuid_pv", "sensor.pv_power" + ) + _register_power_entity( + hass, "span_entry", "sp3-001", "uuid_evse", "sensor.evse_power" + ) + + _async_register_services(hass) + result = await _call_manifest_service(hass) + + panel = result["panels"][0] + assert len(panel["circuits"]) == 3 + + by_template = {c["template"]: c for c in panel["circuits"]} + assert by_template["clone_1"]["device_type"] == "circuit" + assert by_template["clone_5"]["device_type"] == "pv" + assert by_template["clone_10"]["device_type"] == "evse" + + @pytest.mark.asyncio + async def test_bess_device_type_mapped_to_battery(self, hass: HomeAssistant): + """Internal 'bess' device type is mapped to 'battery' in the manifest.""" + bess = SpanCircuitSnapshotFactory.create( + circuit_id="uuid_bess", + tabs=[14, 16], + device_type="bess", + ) + snapshot = SpanPanelSnapshotFactory.create( + serial_number="sp3-001", + circuits={"uuid_bess": bess}, + ) + + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "192.168.1.1"}, + entry_id="span_entry", + unique_id="sp3-001", + ) + entry.add_to_hass(hass) + entry.mock_state(hass, ConfigEntryState.LOADED) + entry.runtime_data = SpanPanelRuntimeData( + coordinator=_make_coordinator(snapshot) + ) + + _register_power_entity( + hass, "span_entry", "sp3-001", "uuid_bess", "sensor.bess_power" + ) + + _async_register_services(hass) + result = await _call_manifest_service(hass) + + panel = result["panels"][0] + assert len(panel["circuits"]) == 1 + assert panel["circuits"][0]["device_type"] == "battery" + assert panel["circuits"][0]["template"] == "clone_14" + assert panel["circuits"][0]["tabs"] == [14, 16] + + @pytest.mark.asyncio + async def test_panel_with_no_resolvable_circuits_omitted(self, hass: HomeAssistant): + """Panel where no circuits have registered entities is omitted.""" + circuit = SpanCircuitSnapshotFactory.create(circuit_id="uuid_orphan", tabs=[1]) + snapshot = SpanPanelSnapshotFactory.create( + serial_number="sp3-001", + circuits={"uuid_orphan": circuit}, + ) + + entry = MockConfigEntry( + domain=DOMAIN, + data={}, + entry_id="span_entry", + unique_id="sp3-001", + ) + entry.add_to_hass(hass) + entry.mock_state(hass, ConfigEntryState.LOADED) + entry.runtime_data = SpanPanelRuntimeData( + coordinator=_make_coordinator(snapshot) + ) + # No entities registered + + _async_register_services(hass) + result = await _call_manifest_service(hass) + + assert result["panels"] == [] + + @pytest.mark.asyncio + @pytest.mark.asyncio + async def test_template_uses_min_tab(self, hass: HomeAssistant): + """Template name is clone_{min(tabs)}, not max or first.""" + circuit = SpanCircuitSnapshotFactory.create( + circuit_id="uuid_240v", + tabs=[8, 6], + ) + snapshot = SpanPanelSnapshotFactory.create( + serial_number="sp3-001", + circuits={"uuid_240v": circuit}, + ) + + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "192.168.1.1"}, + entry_id="span_entry", + unique_id="sp3-001", + ) + entry.add_to_hass(hass) + entry.mock_state(hass, ConfigEntryState.LOADED) + entry.runtime_data = SpanPanelRuntimeData( + coordinator=_make_coordinator(snapshot) + ) + + _register_power_entity( + hass, "span_entry", "sp3-001", "uuid_240v", "sensor.c_power" + ) + + _async_register_services(hass) + result = await _call_manifest_service(hass) + + circuit_data = result["panels"][0]["circuits"][0] + assert circuit_data["template"] == "clone_6" + assert circuit_data["tabs"] == [8, 6] + + @pytest.mark.asyncio + async def test_host_included_from_config_entry(self, hass: HomeAssistant): + """Host field is populated from config entry data.""" + circuit = SpanCircuitSnapshotFactory.create(circuit_id="uuid_a", tabs=[1]) + snapshot = SpanPanelSnapshotFactory.create( + serial_number="sp3-001", + circuits={"uuid_a": circuit}, + ) + + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "10.0.0.50"}, + entry_id="span_entry", + unique_id="sp3-001", + ) + entry.add_to_hass(hass) + entry.mock_state(hass, ConfigEntryState.LOADED) + entry.runtime_data = SpanPanelRuntimeData( + coordinator=_make_coordinator(snapshot) + ) + + _register_power_entity( + hass, "span_entry", "sp3-001", "uuid_a", "sensor.a_power" + ) + + _async_register_services(hass) + result = await _call_manifest_service(hass) + + assert result["panels"][0]["host"] == "10.0.0.50" diff --git a/tests/test_config_flow_validation.py b/tests/test_config_flow_validation.py new file mode 100644 index 00000000..e61e3236 --- /dev/null +++ b/tests/test_config_flow_validation.py @@ -0,0 +1,335 @@ +"""Tests for Span Panel config flow validation helpers.""" + +from __future__ import annotations + +import ssl +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from span_panel_api import DetectionResult, V2AuthResponse, V2StatusInfo +from span_panel_api.exceptions import SpanPanelConnectionError + +from custom_components.span_panel.config_flow_validation import ( + check_fqdn_tls_ready, + is_fqdn, + validate_host, + validate_v2_passphrase, + validate_v2_proximity, +) + +MOCK_V2_AUTH = V2AuthResponse( + access_token="v2-token-abc", + token_type="bearer", + iat_ms=1700000000000, + ebus_broker_host="192.168.1.100", + ebus_broker_mqtts_port=8883, + ebus_broker_ws_port=8080, + ebus_broker_wss_port=8443, + ebus_broker_username="span-user", + ebus_broker_password="mqtt-secret", + hostname="span-panel.local", + serial_number="SPAN-V2-001", + hop_passphrase="correct-horse-battery-staple", +) + + +@pytest.mark.asyncio +async def test_validate_host_returns_true_for_supported_versions() -> None: + """Supported API versions should count as valid hosts.""" + hass = MagicMock() + fake_client = MagicMock() + with ( + patch( + "custom_components.span_panel.config_flow_validation.get_async_client", + return_value=fake_client, + ) as mock_get_client, + patch( + "custom_components.span_panel.config_flow_validation.detect_api_version", + return_value=DetectionResult( + api_version="v2", + status_info=V2StatusInfo( + serial_number="SPAN-V2-001", firmware_version="2.0.0" + ), + ), + ) as mock_detect, + ): + assert await validate_host(hass, "panel.example.com", port=8080) is True + + mock_get_client.assert_called_once_with(hass, verify_ssl=False) + mock_detect.assert_awaited_once_with( + "panel.example.com", port=8080, httpx_client=fake_client + ) + + +@pytest.mark.asyncio +async def test_validate_host_returns_false_on_detection_error() -> None: + """Detection failures should be treated as invalid hosts.""" + hass = MagicMock() + fake_client = MagicMock() + with ( + patch( + "custom_components.span_panel.config_flow_validation.get_async_client", + return_value=fake_client, + ), + patch( + "custom_components.span_panel.config_flow_validation.detect_api_version", + side_effect=SpanPanelConnectionError("boom"), + ), + ): + assert await validate_host(hass, "panel.example.com") is False + + +@pytest.mark.asyncio +async def test_validate_host_returns_false_when_probe_failed() -> None: + """Transport/probe failures must not count as a reachable v1 host.""" + hass = MagicMock() + fake_client = MagicMock() + with ( + patch( + "custom_components.span_panel.config_flow_validation.get_async_client", + return_value=fake_client, + ), + patch( + "custom_components.span_panel.config_flow_validation.detect_api_version", + return_value=DetectionResult( + api_version="v1", + status_info=None, + probe_failed=True, + ), + ), + ): + assert await validate_host(hass, "panel.example.com") is False + + +@pytest.mark.asyncio +async def test_validate_host_rejects_v1_panel() -> None: + """A v1-only panel should be rejected since only v2 is supported.""" + hass = MagicMock() + fake_client = MagicMock() + with ( + patch( + "custom_components.span_panel.config_flow_validation.get_async_client", + return_value=fake_client, + ), + patch( + "custom_components.span_panel.config_flow_validation.detect_api_version", + return_value=DetectionResult( + api_version="v1", + status_info=None, + probe_failed=False, + ), + ), + ): + assert await validate_host(hass, "panel.example.com") is False + + +def test_validate_ipv4_and_fqdn_classification() -> None: + """FQDN helper should classify host formats correctly.""" + assert is_fqdn("panel.example.com") is True + assert is_fqdn("192.168.1.10") is False + assert is_fqdn("2001:db8::1") is False + assert is_fqdn("span-panel.local") is False + assert is_fqdn("span-panel.local.") is False + assert is_fqdn("panel") is False + + +@pytest.mark.asyncio +async def test_validate_v2_helpers_delegate_register_v2() -> None: + """Passphrase and proximity helpers should delegate to register_v2.""" + hass = MagicMock() + fake_client = MagicMock() + with ( + patch( + "custom_components.span_panel.config_flow_validation.get_async_client", + return_value=fake_client, + ), + patch( + "custom_components.span_panel.config_flow_validation.register_v2", + new=AsyncMock(return_value=MOCK_V2_AUTH), + ) as mock_register, + ): + assert ( + await validate_v2_passphrase( + hass, "panel.example.com", "passphrase", port=8080 + ) + == MOCK_V2_AUTH + ) + assert ( + await validate_v2_proximity(hass, "panel.example.com", port=9090) + == MOCK_V2_AUTH + ) + + assert mock_register.await_args_list[0].args == ( + "panel.example.com", + "Home Assistant", + "passphrase", + ) + assert mock_register.await_args_list[0].kwargs == { + "port": 8080, + "httpx_client": fake_client, + } + assert mock_register.await_args_list[1].args == ( + "panel.example.com", + "Home Assistant", + ) + assert mock_register.await_args_list[1].kwargs == { + "port": 9090, + "httpx_client": fake_client, + } + + +@pytest.mark.asyncio +async def test_check_fqdn_tls_ready_returns_false_when_ca_download_fails() -> None: + """TLS readiness should fail fast when the CA certificate cannot be downloaded.""" + hass = MagicMock() + fake_client = MagicMock() + with ( + patch( + "custom_components.span_panel.config_flow_validation.get_async_client", + return_value=fake_client, + ), + patch( + "custom_components.span_panel.config_flow_validation.download_ca_cert", + side_effect=SpanPanelConnectionError("no cert"), + ) as mock_download, + ): + assert await check_fqdn_tls_ready(hass, "panel.example.com", 8883) is False + + mock_download.assert_awaited_once_with( + "panel.example.com", port=80, httpx_client=fake_client + ) + + +@pytest.mark.asyncio +async def test_check_fqdn_tls_ready_forwards_custom_http_port() -> None: + """TLS readiness should forward the provided HTTP port to CA download.""" + hass = MagicMock() + fake_client = MagicMock() + with ( + patch( + "custom_components.span_panel.config_flow_validation.get_async_client", + return_value=fake_client, + ), + patch( + "custom_components.span_panel.config_flow_validation.download_ca_cert", + side_effect=SpanPanelConnectionError("no cert"), + ) as mock_download, + ): + assert ( + await check_fqdn_tls_ready(hass, "panel.example.com", 8883, http_port=8080) + is False + ) + + mock_download.assert_awaited_once_with( + "panel.example.com", port=8080, httpx_client=fake_client + ) + + +@pytest.mark.asyncio +async def test_check_fqdn_tls_ready_returns_true_on_success() -> None: + """TLS readiness should pass when the handshake succeeds.""" + + class FakeLoop: + async def run_in_executor(self, _executor, func): + return func() + + class FakeSocket: + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + class FakeWrappedSocket(FakeSocket): + pass + + class FakeSSLContext: + def __init__(self, _protocol) -> None: + self.check_hostname = False + self.verify_mode = ssl.CERT_NONE + + def load_verify_locations(self, _path: str) -> None: + return None + + def wrap_socket(self, _sock, server_hostname: str): + assert server_hostname == "panel.example.com" + return FakeWrappedSocket() + + hass = MagicMock() + fake_client = MagicMock() + with ( + patch( + "custom_components.span_panel.config_flow_validation.get_async_client", + return_value=fake_client, + ), + patch( + "custom_components.span_panel.config_flow_validation.download_ca_cert", + new=AsyncMock(return_value="pem-data"), + ), + patch( + "custom_components.span_panel.config_flow_validation.asyncio.get_running_loop", + return_value=FakeLoop(), + ), + patch( + "custom_components.span_panel.config_flow_validation.ssl.SSLContext", + side_effect=FakeSSLContext, + ), + patch( + "custom_components.span_panel.config_flow_validation.socket.create_connection", + return_value=FakeSocket(), + ), + ): + assert await check_fqdn_tls_ready(hass, "panel.example.com", 8883) is True + + +@pytest.mark.asyncio +async def test_check_fqdn_tls_ready_returns_false_on_ssl_error() -> None: + """TLS readiness should fail when the hostname/cert handshake fails.""" + + class FakeLoop: + async def run_in_executor(self, _executor, func): + return func() + + class FakeSocket: + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + class FakeSSLContext: + def __init__(self, _protocol) -> None: + self.check_hostname = False + self.verify_mode = ssl.CERT_NONE + + def load_verify_locations(self, _path: str) -> None: + return None + + def wrap_socket(self, _sock, server_hostname: str): + raise ssl.SSLError(f"bad cert for {server_hostname}") + + hass = MagicMock() + fake_client = MagicMock() + with ( + patch( + "custom_components.span_panel.config_flow_validation.get_async_client", + return_value=fake_client, + ), + patch( + "custom_components.span_panel.config_flow_validation.download_ca_cert", + new=AsyncMock(return_value="pem-data"), + ), + patch( + "custom_components.span_panel.config_flow_validation.asyncio.get_running_loop", + return_value=FakeLoop(), + ), + patch( + "custom_components.span_panel.config_flow_validation.ssl.SSLContext", + side_effect=FakeSSLContext, + ), + patch( + "custom_components.span_panel.config_flow_validation.socket.create_connection", + return_value=FakeSocket(), + ), + ): + assert await check_fqdn_tls_ready(hass, "panel.example.com", 8883) is False diff --git a/tests/test_coordinator.py b/tests/test_coordinator.py new file mode 100644 index 00000000..48427766 --- /dev/null +++ b/tests/test_coordinator.py @@ -0,0 +1,509 @@ +"""Tests for the Span Panel coordinator.""" + +from __future__ import annotations + +import logging +from typing import cast +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from span_panel_api.exceptions import ( + SpanPanelAPIError, + SpanPanelAuthError, + SpanPanelConnectionError, + SpanPanelServerError, + SpanPanelTimeoutError, +) + +from custom_components.span_panel.coordinator import SpanPanelCoordinator +from homeassistant.core import HomeAssistant +from span_panel_api import SpanMqttClient +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryNotReady, + HomeAssistantError, +) + +from .factories import ( + SpanBatterySnapshotFactory, + SpanEvseSnapshotFactory, + SpanPanelSnapshotFactory, +) + +from pytest_homeassistant_custom_component.common import MockConfigEntry + + +def _create_coordinator( + hass: HomeAssistant, + *, + client: object | None = None, + options: dict | None = None, +) -> SpanPanelCoordinator: + """Create a coordinator with mocked dependencies.""" + return SpanPanelCoordinator( + hass, + cast(SpanMqttClient, client or MagicMock()), + MockConfigEntry( + domain="span_panel", + options=options or {}, + entry_id="entry-123", + title="SPAN Panel", + ), + ) + + +async def test_capability_change_requests_reload(hass: HomeAssistant) -> None: + """A new hardware capability should trigger a reload request.""" + coordinator = _create_coordinator(hass) + + baseline = SpanPanelSnapshotFactory.create() + upgraded = SpanPanelSnapshotFactory.create( + battery=SpanBatterySnapshotFactory.create(soe_percentage=88.0), + power_flow_pv=1250.0, + power_flow_site=3000.0, + evse={"evse-0": SpanEvseSnapshotFactory.create()}, + ) + + coordinator._check_capability_change(baseline) + assert coordinator._known_capabilities == frozenset() + assert coordinator._reload_requested is False + + coordinator._check_capability_change(upgraded) + + assert coordinator._known_capabilities == frozenset( + {"bess", "evse", "power_flows", "pv"} + ) + assert coordinator._reload_requested is True + + +async def test_async_update_data_returns_cached_snapshot_on_connection_error( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """A transient connection error should return cached data when available.""" + client = MagicMock() + client.get_snapshot = AsyncMock( + side_effect=SpanPanelConnectionError("panel offline") + ) + coordinator = _create_coordinator(hass, client=client) + cached_snapshot = SpanPanelSnapshotFactory.create(serial_number="sp3-cached-001") + coordinator.data = cached_snapshot + + caplog.set_level(logging.INFO) + + result = await coordinator._async_update_data() + + assert result is cached_snapshot + assert coordinator.panel_offline is True + assert "SPAN Panel is unavailable: panel offline" in caplog.text + + +async def test_async_update_data_raises_auth_failed(hass: HomeAssistant) -> None: + """Authentication failures should be promoted to config-entry auth errors.""" + client = MagicMock() + client.get_snapshot = AsyncMock(side_effect=SpanPanelAuthError("bad auth")) + coordinator = _create_coordinator(hass, client=client) + + with pytest.raises(Exception) as err: + await coordinator._async_update_data() + + assert err.type.__name__ == "ConfigEntryAuthFailed" + + +async def test_run_post_update_tasks_validates_once_and_schedules_reload( + hass: HomeAssistant, +) -> None: + """Post-update tasks should validate once and schedule a requested reload.""" + coordinator = _create_coordinator(hass) + snapshot = SpanPanelSnapshotFactory.create() + coordinator._reload_requested = True + + with ( + patch.object(coordinator, "_run_schema_validation") as mock_validate, + patch.object(coordinator, "_fire_dip_notification", AsyncMock()) as mock_notify, + patch.object(coordinator, "_async_reload_task", AsyncMock()) as mock_reload, + patch.object(hass, "async_create_task") as mock_create_task, + ): + await coordinator._run_post_update_tasks(snapshot) + await coordinator._run_post_update_tasks(snapshot) + + assert mock_validate.call_count == 1 + assert mock_notify.await_count == 2 + assert mock_create_task.call_count == 1 + reload_coro = mock_create_task.call_args.args[0] + reload_coro.close() + mock_reload.assert_called_once() + assert coordinator._reload_requested is False + + +async def test_async_shutdown_unregisters_streaming_and_closes_client( + hass: HomeAssistant, +) -> None: + """Shutdown should unregister streaming and close the client.""" + client = MagicMock() + client.stop_streaming = AsyncMock() + client.close = AsyncMock() + unregister = MagicMock() + coordinator = _create_coordinator(hass, client=client) + coordinator._unregister_streaming = unregister + + await coordinator.async_shutdown() + + unregister.assert_called_once() + client.stop_streaming.assert_awaited_once() + client.close.assert_awaited_once() + assert coordinator._unregister_streaming is None + + +async def test_report_dip_and_fire_notification_clears_events( + hass: HomeAssistant, +) -> None: + """Dip notifications should summarize and clear pending events.""" + coordinator = _create_coordinator(hass) + coordinator.report_energy_dip("sensor.a", 2.5, 4.0) + coordinator.report_energy_dip("sensor.b", 1.0, 1.5) + + with patch( + "custom_components.span_panel.coordinator.async_create" + ) as mock_create: + await coordinator._fire_dip_notification() + + mock_create.assert_called_once() + body = mock_create.call_args.args[1] + assert "sensor.a" in body + assert "dip 2.5 Wh" in body + assert coordinator._pending_dip_events == [] + + +async def test_fire_dip_notification_noops_without_events(hass: HomeAssistant) -> None: + """No notification should be created when there are no pending dips.""" + coordinator = _create_coordinator(hass) + + with patch( + "custom_components.span_panel.coordinator.async_create" + ) as mock_create: + await coordinator._fire_dip_notification() + + mock_create.assert_not_called() + + +async def test_async_setup_streaming_registers_callback_and_starts_client( + hass: HomeAssistant, +) -> None: + """Streaming setup should register both callbacks and start the client.""" + client = MagicMock() + unregister_connection = MagicMock() + client.register_connection_callback = MagicMock(return_value=unregister_connection) + client.register_snapshot_callback = MagicMock(return_value=MagicMock()) + client.start_streaming = AsyncMock() + coordinator = _create_coordinator(hass, client=client) + + await coordinator.async_setup_streaming() + + client.register_snapshot_callback.assert_called_once() + client.start_streaming.assert_awaited_once() + assert coordinator._unregister_streaming is not None + client.register_connection_callback.assert_called_once_with(coordinator._on_connection_change) + assert coordinator._unregister_connection is not None + + +async def test_on_snapshot_push_updates_state_and_runs_post_tasks( + hass: HomeAssistant, +) -> None: + """Push snapshots should update coordinator data and run maintenance.""" + coordinator = _create_coordinator(hass) + coordinator._panel_offline = True + snapshot = SpanPanelSnapshotFactory.create() + + with ( + patch.object(coordinator, "_check_capability_change") as mock_caps, + patch.object(coordinator, "async_set_updated_data") as mock_set, + patch.object(coordinator, "_run_post_update_tasks", AsyncMock()) as mock_post, + ): + await coordinator._on_snapshot_push(snapshot) + + assert coordinator.panel_offline is False + mock_caps.assert_called_once_with(snapshot) + mock_set.assert_called_once_with(snapshot) + mock_post.assert_awaited_once_with(snapshot) + + +async def test_run_schema_validation_skips_without_metadata( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Schema validation should skip cleanly when no metadata is available.""" + + class FakeSpanMqttClient: + field_metadata = None + + client = FakeSpanMqttClient() + coordinator = _create_coordinator(hass, client=client) + + caplog.set_level(logging.DEBUG) + + with patch( + "custom_components.span_panel.coordinator.SpanMqttClient", + FakeSpanMqttClient, + ): + coordinator._run_schema_validation() + + assert "Schema validation skipped" in caplog.text + + +async def test_run_schema_validation_validates_field_metadata( + hass: HomeAssistant, +) -> None: + """Schema validation should pass field metadata to the validator.""" + + class FakeField: + def __init__(self, unit: str, datatype: str) -> None: + self.unit = unit + self.datatype = datatype + + class FakeSpanMqttClient: + field_metadata = {"instantPowerW": FakeField("W", "number")} + + client = FakeSpanMqttClient() + coordinator = _create_coordinator(hass, client=client) + + with ( + patch( + "custom_components.span_panel.coordinator.SpanMqttClient", + FakeSpanMqttClient, + ), + patch( + "custom_components.span_panel.coordinator.collect_sensor_definitions", + return_value={"sensor_defs": "ok"}, + ) as mock_collect, + patch( + "custom_components.span_panel.coordinator.validate_field_metadata" + ) as mock_validate, + ): + coordinator._run_schema_validation() + + mock_collect.assert_called_once() + mock_validate.assert_called_once_with( + {"instantPowerW": {"unit": "W", "datatype": "number"}}, + sensor_defs={"sensor_defs": "ok"}, + ) + + +@pytest.mark.parametrize( + ("error", "expected_log"), + [ + (SpanPanelTimeoutError("timeout"), "SPAN Panel is unavailable: timeout"), + (SpanPanelServerError("server"), "SPAN Panel is unavailable: server"), + (SpanPanelAPIError("api"), "SPAN Panel is unavailable: api"), + (RuntimeError("boom"), "SPAN Panel is unavailable: boom"), + ], +) +async def test_async_update_data_raises_first_error_without_cached_data( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + error: Exception, + expected_log: str, +) -> None: + """First-refresh errors should be re-raised after logging.""" + client = MagicMock() + client.get_snapshot = AsyncMock(side_effect=error) + coordinator = _create_coordinator(hass, client=client) + + caplog.set_level(logging.INFO) + + with pytest.raises(type(error)): + await coordinator._async_update_data() + + assert coordinator.panel_offline is True + assert expected_log in caplog.text + + +async def test_async_update_data_re_raises_existing_auth_failed( + hass: HomeAssistant, +) -> None: + """Existing auth failures should pass through untouched.""" + client = MagicMock() + client.get_snapshot = AsyncMock(side_effect=ConfigEntryAuthFailed) + coordinator = _create_coordinator(hass, client=client) + + with pytest.raises(ConfigEntryAuthFailed): + await coordinator._async_update_data() + + +async def test_async_update_data_logs_unavailable_and_recovery_once( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Coordinator should log offline and recovery transitions once each.""" + cached_snapshot = SpanPanelSnapshotFactory.create(serial_number="sp3-cached-001") + recovered_snapshot = SpanPanelSnapshotFactory.create( + serial_number="sp3-recovered-001" + ) + client = MagicMock() + client.get_snapshot = AsyncMock( + side_effect=[ + SpanPanelConnectionError("panel offline"), + SpanPanelConnectionError("still offline"), + recovered_snapshot, + ] + ) + coordinator = _create_coordinator(hass, client=client) + coordinator.data = cached_snapshot + + caplog.set_level(logging.INFO) + + assert await coordinator._async_update_data() is cached_snapshot + assert await coordinator._async_update_data() is cached_snapshot + assert await coordinator._async_update_data() is recovered_snapshot + + assert caplog.text.count("SPAN Panel is unavailable: panel offline") == 1 + assert "SPAN Panel is unavailable: still offline" not in caplog.text + assert caplog.text.count("SPAN Panel is back online") == 1 + + +async def test_async_reload_task_handles_expected_errors( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Reload task should log and suppress reload-related errors.""" + coordinator = _create_coordinator(hass) + + caplog.set_level(logging.WARNING) + + with ( + patch.object(hass, "async_block_till_done", AsyncMock()), + patch.object( + hass.config_entries, + "async_reload", + AsyncMock(side_effect=ConfigEntryNotReady("not ready")), + ), + ): + await coordinator._async_reload_task() + + assert "Config entry not ready during reload: not ready" in caplog.text + + caplog.clear() + caplog.set_level(logging.ERROR) + + with ( + patch.object(hass, "async_block_till_done", AsyncMock()), + patch.object( + hass.config_entries, + "async_reload", + AsyncMock(side_effect=HomeAssistantError("reload failed")), + ), + ): + await coordinator._async_reload_task() + + assert "Home Assistant error during reload: reload failed" in caplog.text + + +async def test_connection_callback_registered_and_unregistered_on_lifecycle( + hass: HomeAssistant, +) -> None: + """async_setup_streaming should register a connection callback; async_shutdown should unregister it.""" + client = MagicMock() + client.register_connection_callback = MagicMock() + client.register_snapshot_callback = MagicMock() + client.start_streaming = AsyncMock() + client.stop_streaming = AsyncMock() + client.close = AsyncMock() + + # register_connection_callback returns an unregister function + unregister_connection = MagicMock() + client.register_connection_callback.return_value = unregister_connection + client.register_snapshot_callback.return_value = MagicMock() + + coordinator = _create_coordinator(hass, client=client) + + await coordinator.async_setup_streaming() + + # Connection callback was registered exactly once with the coordinator's handler + client.register_connection_callback.assert_called_once_with(coordinator._on_connection_change) + assert coordinator._unregister_connection is unregister_connection + + await coordinator.async_shutdown() + + # Unregister was invoked and the field cleared + cast(MagicMock, unregister_connection).assert_called_once_with() + assert coordinator._unregister_connection is None + + +async def test_on_connection_change_false_flips_offline_and_notifies_listeners( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """A False edge must flip panel_offline True, log once, and push a listener update.""" + coordinator = _create_coordinator(hass) + assert coordinator.panel_offline is False + + with patch.object(coordinator, "async_update_listeners") as notify: + with caplog.at_level(logging.INFO): + coordinator._on_connection_change(False) + + assert coordinator.panel_offline is True + notify.assert_called_once_with() + assert any( + "is unavailable" in r.message and "MQTT broker disconnected" in r.message + for r in caplog.records + ) + + +async def test_on_connection_change_true_clears_offline_and_notifies_listeners( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """A True edge must flip panel_offline False, log once, and push a listener update.""" + coordinator = _create_coordinator(hass) + coordinator._panel_offline = True + + with patch.object(coordinator, "async_update_listeners") as notify: + with caplog.at_level(logging.INFO): + coordinator._on_connection_change(True) + + assert coordinator.panel_offline is False + notify.assert_called_once_with() + assert any("is back online" in r.message for r in caplog.records) + + +async def test_on_connection_change_noop_when_state_unchanged( + hass: HomeAssistant, +) -> None: + """When connected state matches current panel_offline, no listener fan-out.""" + coordinator = _create_coordinator(hass) + + # Already online (panel_offline=False); receiving another True edge is a no-op + with patch.object(coordinator, "async_update_listeners") as notify_online_case: + coordinator._on_connection_change(True) + notify_online_case.assert_not_called() + assert coordinator.panel_offline is False + + # Already offline; receiving another False edge is a no-op + coordinator._panel_offline = True + with patch.object(coordinator, "async_update_listeners") as notify_offline_case: + coordinator._on_connection_change(False) + notify_offline_case.assert_not_called() + assert coordinator.panel_offline is True + + +async def test_async_update_data_stale_data_error_marks_offline_and_returns_last_data( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """A SpanPanelStaleDataError from get_snapshot() should be treated as an expected offline signal.""" + from span_panel_api.exceptions import SpanPanelStaleDataError + + last_snapshot = SpanPanelSnapshotFactory.create() + + client = MagicMock() + client.get_snapshot = AsyncMock( + side_effect=SpanPanelStaleDataError("MQTT broker disconnected") + ) + + coordinator = _create_coordinator(hass, client=client) + # Simulate a prior successful update + coordinator.data = last_snapshot + assert coordinator.panel_offline is False + + with caplog.at_level(logging.INFO): + result = await coordinator._async_update_data() + + assert result is last_snapshot + assert coordinator.panel_offline is True + assert any( + "is unavailable" in r.message and "MQTT broker disconnected" in r.message + for r in caplog.records + ) diff --git a/tests/test_current_monitor.py b/tests/test_current_monitor.py new file mode 100644 index 00000000..1ad64daf --- /dev/null +++ b/tests/test_current_monitor.py @@ -0,0 +1,861 @@ +"""Tests for the CurrentMonitor class.""" + +import asyncio +from datetime import UTC, datetime, timedelta +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from custom_components.span_panel.const import ( + DEFAULT_CONTINUOUS_THRESHOLD_PCT, + DEFAULT_COOLDOWN_DURATION_M, + DEFAULT_SPIKE_THRESHOLD_PCT, + DEFAULT_WINDOW_DURATION_M, + ENABLE_CURRENT_MONITORING, + EVENT_CURRENT_ALERT, +) +from homeassistant.core import CoreState + +from custom_components.span_panel.current_monitor import CurrentMonitor +from custom_components.span_panel.options import ( + CONTINUOUS_THRESHOLD_PCT, + COOLDOWN_DURATION_M, + NOTIFY_TARGETS, + SPIKE_THRESHOLD_PCT, + WINDOW_DURATION_M, +) +from tests.factories import SpanCircuitSnapshotFactory, SpanPanelSnapshotFactory + + +def _make_options(**overrides): + """Create options dict with monitoring enabled and defaults.""" + opts = { + ENABLE_CURRENT_MONITORING: True, + CONTINUOUS_THRESHOLD_PCT: DEFAULT_CONTINUOUS_THRESHOLD_PCT, + SPIKE_THRESHOLD_PCT: DEFAULT_SPIKE_THRESHOLD_PCT, + WINDOW_DURATION_M: DEFAULT_WINDOW_DURATION_M, + COOLDOWN_DURATION_M: DEFAULT_COOLDOWN_DURATION_M, + NOTIFY_TARGETS: "event_bus", + } + opts.update(overrides) + return opts + + +def _make_monitor(hass, options=None, entry_id="test_entry"): + """Create a CurrentMonitor with mocked hass and entry.""" + if options is None: + options = _make_options() + entry = MagicMock() + entry.entry_id = entry_id + entry.options = options + return CurrentMonitor(hass, entry) + + +def _run_coro(coro): + """Run a coroutine synchronously so async_create_task records inner calls. + + When an event loop is already running (async tests) the coroutine is + scheduled as an eager task so it is consumed immediately and never + triggers an "unawaited coroutine" warning. + """ + if not asyncio.iscoroutine(coro): + return coro + try: + loop = asyncio.get_running_loop() + except RuntimeError: + loop = None + if loop and loop.is_running(): + return loop.create_task(coro) + new_loop = asyncio.new_event_loop() + try: + return new_loop.run_until_complete(coro) + finally: + new_loop.close() + + +def _make_hass(): + """Create a minimal mock hass object.""" + hass = MagicMock() + hass.state = CoreState.running + hass.bus = MagicMock() + hass.bus.async_fire = MagicMock() + hass.services = MagicMock() + hass.services.async_call = AsyncMock() + hass.states = MagicMock() + hass.states.get = MagicMock(return_value=None) + hass.async_create_task = MagicMock(side_effect=_run_coro) + return hass + + +class TestSpikeDetection: + """Tests for instantaneous spike threshold detection.""" + + def test_spike_fires_when_current_meets_threshold(self): + """A reading at exactly the breaker rating triggers a spike.""" + hass = _make_hass() + monitor = _make_monitor(hass, _make_options( + **{SPIKE_THRESHOLD_PCT: 100} + )) + circuit = SpanCircuitSnapshotFactory.create( + circuit_id="1", + name="Kitchen", + current_a=20.0, + breaker_rating_a=20.0, + ) + snapshot = SpanPanelSnapshotFactory.create( + circuits={"1": circuit}, + main_breaker_rating_a=200, + ) + monitor.process_snapshot(snapshot) + assert monitor.get_circuit_state("1").last_spike_alert is not None + + def test_spike_does_not_fire_below_threshold(self): + """A reading below the threshold does not trigger a spike.""" + hass = _make_hass() + monitor = _make_monitor(hass, _make_options( + **{SPIKE_THRESHOLD_PCT: 100} + )) + circuit = SpanCircuitSnapshotFactory.create( + circuit_id="1", + name="Kitchen", + current_a=19.9, + breaker_rating_a=20.0, + ) + snapshot = SpanPanelSnapshotFactory.create( + circuits={"1": circuit}, + main_breaker_rating_a=200, + ) + monitor.process_snapshot(snapshot) + assert monitor.get_circuit_state("1").last_spike_alert is None + + def test_spike_uses_absolute_value_for_pv(self): + """Negative current (PV backfeed) is checked by absolute value.""" + hass = _make_hass() + monitor = _make_monitor(hass, _make_options( + **{SPIKE_THRESHOLD_PCT: 100} + )) + circuit = SpanCircuitSnapshotFactory.create( + circuit_id="15", + name="Solar", + current_a=-30.0, + breaker_rating_a=30.0, + device_type="pv", + ) + snapshot = SpanPanelSnapshotFactory.create( + circuits={"15": circuit}, + main_breaker_rating_a=200, + ) + monitor.process_snapshot(snapshot) + assert monitor.get_circuit_state("15").last_spike_alert is not None + + def test_spike_skips_circuit_with_none_current(self): + """Circuits without current_a (non-V2) are skipped.""" + hass = _make_hass() + monitor = _make_monitor(hass) + circuit = SpanCircuitSnapshotFactory.create( + circuit_id="1", + name="Kitchen", + current_a=None, + breaker_rating_a=20.0, + ) + snapshot = SpanPanelSnapshotFactory.create( + circuits={"1": circuit}, + main_breaker_rating_a=200, + ) + monitor.process_snapshot(snapshot) + assert monitor.get_circuit_state("1") is None + + def test_spike_skips_circuit_with_none_rating(self): + """Circuits without breaker_rating_a are skipped.""" + hass = _make_hass() + monitor = _make_monitor(hass) + circuit = SpanCircuitSnapshotFactory.create( + circuit_id="1", + name="Kitchen", + current_a=20.0, + breaker_rating_a=None, + ) + snapshot = SpanPanelSnapshotFactory.create( + circuits={"1": circuit}, + main_breaker_rating_a=200, + ) + monitor.process_snapshot(snapshot) + assert monitor.get_circuit_state("1") is None + + +class TestContinuousOverloadDetection: + """Tests for sustained overload window tracking.""" + + def test_continuous_window_starts_when_over_threshold(self): + """Over-threshold reading starts the window timer.""" + hass = _make_hass() + monitor = _make_monitor(hass, _make_options( + **{CONTINUOUS_THRESHOLD_PCT: 80} + )) + circuit = SpanCircuitSnapshotFactory.create( + circuit_id="1", + name="Kitchen", + current_a=16.1, # 80.5% of 20A + breaker_rating_a=20.0, + ) + snapshot = SpanPanelSnapshotFactory.create( + circuits={"1": circuit}, + main_breaker_rating_a=200, + ) + monitor.process_snapshot(snapshot) + state = monitor.get_circuit_state("1") + assert state.over_threshold_since is not None + + def test_continuous_window_resets_when_below_threshold(self): + """Dropping below threshold resets the window.""" + hass = _make_hass() + monitor = _make_monitor(hass, _make_options( + **{CONTINUOUS_THRESHOLD_PCT: 80} + )) + # First reading: over threshold + circuit_over = SpanCircuitSnapshotFactory.create( + circuit_id="1", name="Kitchen", + current_a=17.0, breaker_rating_a=20.0, + ) + snapshot_over = SpanPanelSnapshotFactory.create( + circuits={"1": circuit_over}, main_breaker_rating_a=200, + ) + monitor.process_snapshot(snapshot_over) + assert monitor.get_circuit_state("1").over_threshold_since is not None + + # Second reading: below threshold + circuit_under = SpanCircuitSnapshotFactory.create( + circuit_id="1", name="Kitchen", + current_a=15.0, breaker_rating_a=20.0, + ) + snapshot_under = SpanPanelSnapshotFactory.create( + circuits={"1": circuit_under}, main_breaker_rating_a=200, + ) + monitor.process_snapshot(snapshot_under) + assert monitor.get_circuit_state("1").over_threshold_since is None + + def test_continuous_alert_fires_after_window_duration(self): + """Alert fires when over threshold for the full window duration.""" + hass = _make_hass() + monitor = _make_monitor(hass, _make_options( + **{CONTINUOUS_THRESHOLD_PCT: 80, WINDOW_DURATION_M: 15} + )) + circuit = SpanCircuitSnapshotFactory.create( + circuit_id="1", name="Kitchen", + current_a=17.0, breaker_rating_a=20.0, + ) + snapshot = SpanPanelSnapshotFactory.create( + circuits={"1": circuit}, main_breaker_rating_a=200, + ) + + # First snapshot sets the window start + monitor.process_snapshot(snapshot) + state = monitor.get_circuit_state("1") + assert state.last_continuous_alert is None + + # Simulate time passing beyond window duration + state.over_threshold_since = datetime.now(UTC) - timedelta(minutes=16) + monitor.process_snapshot(snapshot) + state = monitor.get_circuit_state("1") + assert state.last_continuous_alert is not None + + def test_continuous_alert_does_not_fire_before_window(self): + """Alert does not fire before the window duration elapses.""" + hass = _make_hass() + monitor = _make_monitor(hass, _make_options( + **{CONTINUOUS_THRESHOLD_PCT: 80, WINDOW_DURATION_M: 15} + )) + circuit = SpanCircuitSnapshotFactory.create( + circuit_id="1", name="Kitchen", + current_a=17.0, breaker_rating_a=20.0, + ) + snapshot = SpanPanelSnapshotFactory.create( + circuits={"1": circuit}, main_breaker_rating_a=200, + ) + monitor.process_snapshot(snapshot) + # Immediately process again — window just started + monitor.process_snapshot(snapshot) + state = monitor.get_circuit_state("1") + assert state.last_continuous_alert is None + + +class TestCooldown: + """Tests for alert cooldown behavior.""" + + def test_spike_suppressed_during_cooldown(self): + """Second spike within cooldown is suppressed.""" + hass = _make_hass() + monitor = _make_monitor(hass, _make_options( + **{SPIKE_THRESHOLD_PCT: 100, COOLDOWN_DURATION_M: 15} + )) + circuit = SpanCircuitSnapshotFactory.create( + circuit_id="1", name="Kitchen", + current_a=20.0, breaker_rating_a=20.0, + ) + snapshot = SpanPanelSnapshotFactory.create( + circuits={"1": circuit}, main_breaker_rating_a=200, + ) + + monitor.process_snapshot(snapshot) + first_alert_time = monitor.get_circuit_state("1").last_spike_alert + + # Reset the hass.bus mock to track new calls + hass.bus.async_fire.reset_mock() + + # Process again immediately — should be suppressed + monitor.process_snapshot(snapshot) + hass.bus.async_fire.assert_not_called() + # Alert timestamp unchanged + assert monitor.get_circuit_state("1").last_spike_alert == first_alert_time + + def test_spike_fires_again_after_cooldown(self): + """Spike fires again after cooldown elapses.""" + hass = _make_hass() + monitor = _make_monitor(hass, _make_options( + **{SPIKE_THRESHOLD_PCT: 100, COOLDOWN_DURATION_M: 15} + )) + circuit = SpanCircuitSnapshotFactory.create( + circuit_id="1", name="Kitchen", + current_a=20.0, breaker_rating_a=20.0, + ) + snapshot = SpanPanelSnapshotFactory.create( + circuits={"1": circuit}, main_breaker_rating_a=200, + ) + + monitor.process_snapshot(snapshot) + # Push the alert timestamp back beyond cooldown + state = monitor.get_circuit_state("1") + old_alert_time = datetime.now(UTC) - timedelta(minutes=16) + state.last_spike_alert = old_alert_time + + hass.bus.async_fire.reset_mock() + monitor.process_snapshot(snapshot) + # Should have fired again — new timestamp is strictly later than the backdated one + assert monitor.get_circuit_state("1").last_spike_alert > old_alert_time + + +class TestMainsMonitoring: + """Tests for panel mains leg monitoring.""" + + def test_mains_spike_detected_on_upstream_l1(self): + """Upstream L1 current exceeding main breaker rating fires spike.""" + hass = _make_hass() + monitor = _make_monitor(hass, _make_options( + **{SPIKE_THRESHOLD_PCT: 100} + )) + snapshot = SpanPanelSnapshotFactory.create( + main_breaker_rating_a=200, + upstream_l1_current_a=200.0, + upstream_l2_current_a=100.0, + ) + monitor.process_snapshot(snapshot) + state = monitor.get_mains_state("upstream_l1") + assert state is not None + assert state.last_spike_alert is not None + + def test_mains_skipped_when_no_breaker_rating(self): + """Mains monitoring skipped when main_breaker_rating_a is None.""" + hass = _make_hass() + monitor = _make_monitor(hass) + snapshot = SpanPanelSnapshotFactory.create( + main_breaker_rating_a=None, + upstream_l1_current_a=200.0, + ) + monitor.process_snapshot(snapshot) + assert monitor.get_mains_state("upstream_l1") is None + + def test_mains_skipped_when_leg_current_is_none(self): + """Individual mains leg skipped when its current reading is None.""" + hass = _make_hass() + monitor = _make_monitor(hass) + snapshot = SpanPanelSnapshotFactory.create( + main_breaker_rating_a=200, + upstream_l1_current_a=None, + upstream_l2_current_a=100.0, + ) + monitor.process_snapshot(snapshot) + assert monitor.get_mains_state("upstream_l1") is None + assert monitor.get_mains_state("upstream_l2") is not None + + def test_mains_continuous_overload_on_single_leg(self): + """Continuous overload fires on one leg while other stays normal.""" + hass = _make_hass() + monitor = _make_monitor(hass, _make_options( + **{CONTINUOUS_THRESHOLD_PCT: 80, WINDOW_DURATION_M: 15} + )) + snapshot = SpanPanelSnapshotFactory.create( + main_breaker_rating_a=200, + upstream_l1_current_a=170.0, # 85% — over threshold + upstream_l2_current_a=100.0, # 50% — under threshold + ) + monitor.process_snapshot(snapshot) + + l1_state = monitor.get_mains_state("upstream_l1") + l2_state = monitor.get_mains_state("upstream_l2") + assert l1_state.over_threshold_since is not None + assert l2_state.over_threshold_since is None + + +class TestPerCircuitOverrides: + """Tests for per-circuit threshold overrides.""" + + def test_circuit_override_takes_precedence(self): + """Per-circuit override threshold used instead of global.""" + hass = _make_hass() + monitor = _make_monitor(hass, _make_options( + **{SPIKE_THRESHOLD_PCT: 100} + )) + # Override circuit 1 to spike at 90% + monitor.set_circuit_override("1", {SPIKE_THRESHOLD_PCT: 90}) + + circuit = SpanCircuitSnapshotFactory.create( + circuit_id="1", name="Kitchen", + current_a=18.0, # 90% of 20A — hits override threshold + breaker_rating_a=20.0, + ) + snapshot = SpanPanelSnapshotFactory.create( + circuits={"1": circuit}, main_breaker_rating_a=200, + ) + monitor.process_snapshot(snapshot) + assert monitor.get_circuit_state("1").last_spike_alert is not None + + def test_circuit_override_disabled_skips_monitoring(self): + """Per-circuit monitoring_enabled=False skips that circuit.""" + hass = _make_hass() + monitor = _make_monitor(hass) + monitor.set_circuit_override("1", {"monitoring_enabled": False}) + + circuit = SpanCircuitSnapshotFactory.create( + circuit_id="1", name="Well Pump", + current_a=25.0, breaker_rating_a=20.0, + ) + snapshot = SpanPanelSnapshotFactory.create( + circuits={"1": circuit}, main_breaker_rating_a=200, + ) + monitor.process_snapshot(snapshot) + assert monitor.get_circuit_state("1") is None + + def test_clear_override_reverts_to_global(self): + """Clearing an override reverts to global defaults.""" + hass = _make_hass() + monitor = _make_monitor(hass, _make_options( + **{SPIKE_THRESHOLD_PCT: 100} + )) + monitor.set_circuit_override("1", {SPIKE_THRESHOLD_PCT: 50}) + monitor.clear_circuit_override("1") + + circuit = SpanCircuitSnapshotFactory.create( + circuit_id="1", name="Kitchen", + current_a=19.0, # 95% — under global 100% threshold + breaker_rating_a=20.0, + ) + snapshot = SpanPanelSnapshotFactory.create( + circuits={"1": circuit}, main_breaker_rating_a=200, + ) + monitor.process_snapshot(snapshot) + assert monitor.get_circuit_state("1").last_spike_alert is None + + +class TestPerMainsOverrides: + """Tests for per-mains-leg threshold overrides.""" + + def test_mains_override_takes_precedence(self): + """Per-leg override threshold used instead of global.""" + hass = _make_hass() + monitor = _make_monitor(hass, _make_options( + **{SPIKE_THRESHOLD_PCT: 100} + )) + monitor.set_mains_override("upstream_l1", {SPIKE_THRESHOLD_PCT: 90}) + + snapshot = SpanPanelSnapshotFactory.create( + main_breaker_rating_a=200, + upstream_l1_current_a=180.0, # 90% — hits override threshold + ) + monitor.process_snapshot(snapshot) + assert monitor.get_mains_state("upstream_l1").last_spike_alert is not None + + def test_mains_override_disabled_skips_leg(self): + """Per-leg monitoring_enabled=False skips that leg.""" + hass = _make_hass() + monitor = _make_monitor(hass) + monitor.set_mains_override("upstream_l1", {"monitoring_enabled": False}) + + snapshot = SpanPanelSnapshotFactory.create( + main_breaker_rating_a=200, + upstream_l1_current_a=250.0, + ) + monitor.process_snapshot(snapshot) + assert monitor.get_mains_state("upstream_l1") is None + + def test_clear_mains_override_reverts_to_global(self): + """Clearing a mains override reverts to global defaults.""" + hass = _make_hass() + monitor = _make_monitor(hass, _make_options( + **{SPIKE_THRESHOLD_PCT: 100} + )) + monitor.set_mains_override("upstream_l1", {SPIKE_THRESHOLD_PCT: 50}) + monitor.clear_mains_override("upstream_l1") + + snapshot = SpanPanelSnapshotFactory.create( + main_breaker_rating_a=200, + upstream_l1_current_a=190.0, # 95% — under global 100% + ) + monitor.process_snapshot(snapshot) + assert monitor.get_mains_state("upstream_l1").last_spike_alert is None + + +class TestStoragePersistence: + """Tests for persisting overrides to HA storage.""" + + @pytest.mark.asyncio + async def test_save_and_load_circuit_overrides(self): + """Circuit overrides survive save/load cycle.""" + hass = _make_hass() + store_data = {} + + async def mock_save(data): + store_data["saved"] = data + + async def mock_load(): + return store_data.get("saved") + + monitor = _make_monitor(hass) + monitor._store = MagicMock() + monitor._store.async_save = AsyncMock(side_effect=mock_save) + monitor._store.async_load = AsyncMock(side_effect=mock_load) + + monitor.set_circuit_override("1", {SPIKE_THRESHOLD_PCT: 90}) + monitor.set_mains_override("upstream_l1", {SPIKE_THRESHOLD_PCT: 85}) + await monitor.async_save_overrides() + + # Create a new monitor and load + monitor2 = _make_monitor(hass) + monitor2._store = MagicMock() + monitor2._store.async_load = AsyncMock(side_effect=mock_load) + await monitor2.async_load_overrides() + + assert monitor2._circuit_overrides["1"][SPIKE_THRESHOLD_PCT] == 90 + assert monitor2._mains_overrides["upstream_l1"][SPIKE_THRESHOLD_PCT] == 85 + + @pytest.mark.asyncio + async def test_load_handles_no_existing_data(self): + """Loading with no stored data leaves overrides empty.""" + hass = _make_hass() + monitor = _make_monitor(hass) + monitor._store = MagicMock() + monitor._store.async_load = AsyncMock(return_value=None) + await monitor.async_load_overrides() + assert monitor._circuit_overrides == {} + assert monitor._mains_overrides == {} + + +class TestNotificationDispatch: + """Tests for alert notification through all channels.""" + + def test_spike_fires_event_bus(self): + """Spike alert fires event on the HA event bus.""" + hass = _make_hass() + monitor = _make_monitor(hass, _make_options(**{NOTIFY_TARGETS: "event_bus"})) + circuit = SpanCircuitSnapshotFactory.create( + circuit_id="1", name="Kitchen", + current_a=20.0, breaker_rating_a=20.0, + ) + snapshot = SpanPanelSnapshotFactory.create( + serial_number="ABC123", + circuits={"1": circuit}, main_breaker_rating_a=200, + ) + monitor.process_snapshot(snapshot) + + hass.bus.async_fire.assert_called_once() + call_args = hass.bus.async_fire.call_args + assert call_args[0][0] == EVENT_CURRENT_ALERT + event_data = call_args[0][1] + assert event_data["alert_type"] == "spike" + assert event_data["alert_id"] == "1" + assert event_data["alert_name"] == "Kitchen" + assert event_data["current_a"] == 20.0 + assert event_data["breaker_rating_a"] == 20.0 + assert event_data["utilization_pct"] == 100.0 + assert event_data["panel_serial"] == "ABC123" + + def test_spike_does_not_fire_event_when_disabled(self): + """No event fired when event_bus target is not in notify_targets.""" + hass = _make_hass() + monitor = _make_monitor(hass, _make_options(**{NOTIFY_TARGETS: ""})) + circuit = SpanCircuitSnapshotFactory.create( + circuit_id="1", name="Kitchen", + current_a=20.0, breaker_rating_a=20.0, + ) + snapshot = SpanPanelSnapshotFactory.create( + circuits={"1": circuit}, main_breaker_rating_a=200, + ) + monitor.process_snapshot(snapshot) + hass.bus.async_fire.assert_not_called() + + def test_spike_calls_notify_service(self): + """Spike alert calls configured notify service targets.""" + hass = _make_hass() + monitor = _make_monitor(hass, _make_options( + **{NOTIFY_TARGETS: "notify.mobile_app_phone"} + )) + circuit = SpanCircuitSnapshotFactory.create( + circuit_id="1", name="Kitchen", + current_a=20.0, breaker_rating_a=20.0, + ) + snapshot = SpanPanelSnapshotFactory.create( + circuits={"1": circuit}, main_breaker_rating_a=200, + ) + monitor.process_snapshot(snapshot) + + # Should have called the notify service for the target + notify_calls = [ + c for c in hass.services.async_call.call_args_list + if c[0][0] == "notify" + ] + assert len(notify_calls) == 1 + + def test_no_notifications_during_startup(self): + """Service calls are suppressed when HA is still starting.""" + hass = _make_hass() + hass.state = CoreState.starting + monitor = _make_monitor(hass, _make_options()) + circuit = SpanCircuitSnapshotFactory.create( + circuit_id="1", name="Kitchen", + current_a=20.0, breaker_rating_a=20.0, + ) + snapshot = SpanPanelSnapshotFactory.create( + circuits={"1": circuit}, main_breaker_rating_a=200, + ) + monitor.process_snapshot(snapshot) + + hass.services.async_call.assert_not_called() + # Event bus should still fire + hass.bus.async_fire.assert_called() + + def test_notification_message_format_spike(self): + """Spike notification message includes current and rating.""" + title, message = CurrentMonitor._format_notification( + alert_type="spike", + alert_name="Kitchen", + alert_id="sensor.kitchen_current", + current_a=22.1, + breaker_rating_a=20.0, + threshold_pct=100, + utilization_pct=110.5, + window_duration_s=None, + title_template="SPAN: {name} {alert_type}", + message_template="{name} at {current_a}A ({utilization_pct}% of {breaker_rating_a}A rating)", + ) + assert title == "SPAN: Kitchen spike" + assert "22.1A" in message + assert "110.5%" in message + assert "20A" in message + + def test_notification_message_format_continuous(self): + """Continuous notification message includes window duration.""" + title, message = CurrentMonitor._format_notification( + alert_type="continuous_overload", + alert_name="Kitchen", + alert_id="sensor.kitchen_current", + current_a=18.4, + breaker_rating_a=20.0, + threshold_pct=80, + utilization_pct=92.0, + window_duration_s=900, + title_template="SPAN: {name} {alert_type}", + message_template=( + "{name} drawing {current_a}A ({utilization_pct}% of {breaker_rating_a}A rating) " + "— continuous threshold of {threshold_pct}% exceeded over {window_m} min" + ), + ) + assert title == "SPAN: Kitchen continuous_overload" + assert "18.4A" in message + assert "92.0%" in message + assert "80%" in message + assert "15 min" in message + + def test_notification_custom_template_with_entity_id(self): + """Custom templates produce expected output with entity_id placeholder.""" + title, message = CurrentMonitor._format_notification( + alert_type="spike", + alert_name="Kitchen", + alert_id="sensor.kitchen_current", + current_a=22.1, + breaker_rating_a=20.0, + threshold_pct=100, + utilization_pct=110.5, + window_duration_s=None, + title_template="Alert: {entity_id}", + message_template="{entity_id} is at {current_a}A", + ) + assert title == "Alert: sensor.kitchen_current" + assert message == "sensor.kitchen_current is at 22.1A" + + def test_notification_invalid_template_falls_back(self): + """Invalid template placeholders fall back to defaults.""" + title, message = CurrentMonitor._format_notification( + alert_type="spike", + alert_name="Kitchen", + alert_id="sensor.kitchen_current", + current_a=22.1, + breaker_rating_a=20.0, + threshold_pct=100, + utilization_pct=110.5, + window_duration_s=None, + title_template="{nonexistent_var}", + message_template="{also_bad}", + ) + assert "Kitchen" in title + assert "22.1A" in message + + +class TestGlobalSettingsStorage: + """Tests for global monitoring settings in storage.""" + + def test_get_global_settings_returns_defaults_from_entry_options(self): + """Returns values from entry.options when no stored globals exist.""" + hass = _make_hass() + monitor = _make_monitor(hass, _make_options()) + settings = monitor.get_global_settings() + assert settings[CONTINUOUS_THRESHOLD_PCT] == DEFAULT_CONTINUOUS_THRESHOLD_PCT + assert settings[SPIKE_THRESHOLD_PCT] == DEFAULT_SPIKE_THRESHOLD_PCT + assert settings[WINDOW_DURATION_M] == DEFAULT_WINDOW_DURATION_M + assert settings[COOLDOWN_DURATION_M] == DEFAULT_COOLDOWN_DURATION_M + + def test_get_global_settings_from_storage(self): + """Returns stored values when available, ignoring entry.options.""" + hass = _make_hass() + monitor = _make_monitor(hass, _make_options()) + monitor._global_settings = { + CONTINUOUS_THRESHOLD_PCT: 70, + SPIKE_THRESHOLD_PCT: 90, + } + settings = monitor.get_global_settings() + assert settings[CONTINUOUS_THRESHOLD_PCT] == 70 + assert settings[SPIKE_THRESHOLD_PCT] == 90 + + def test_get_global_settings_storage_defaults_missing_keys(self): + """Stored globals with missing keys fall back to defaults.""" + hass = _make_hass() + monitor = _make_monitor(hass, _make_options()) + monitor._global_settings = { + CONTINUOUS_THRESHOLD_PCT: 70, + } + settings = monitor.get_global_settings() + assert settings[CONTINUOUS_THRESHOLD_PCT] == 70 + assert settings[SPIKE_THRESHOLD_PCT] == DEFAULT_SPIKE_THRESHOLD_PCT + + def test_set_global_settings_persists(self): + """set_global_settings updates internal state.""" + hass = _make_hass() + monitor = _make_monitor(hass, _make_options()) + monitor.set_global_settings({ + CONTINUOUS_THRESHOLD_PCT: 75, + SPIKE_THRESHOLD_PCT: 95, + }) + assert monitor._global_settings[CONTINUOUS_THRESHOLD_PCT] == 75 + assert monitor._global_settings[SPIKE_THRESHOLD_PCT] == 95 + + def test_set_global_settings_ignores_unknown_keys(self): + """Unknown keys are not stored.""" + hass = _make_hass() + monitor = _make_monitor(hass, _make_options()) + monitor.set_global_settings({"bogus_key": 42}) + assert "bogus_key" not in monitor._global_settings + + def test_set_global_settings_triggers_save(self): + """set_global_settings schedules a storage save.""" + hass = _make_hass() + monitor = _make_monitor(hass, _make_options()) + monitor.set_global_settings({CONTINUOUS_THRESHOLD_PCT: 75}) + hass.async_create_task.assert_called() + + def test_circuit_thresholds_use_global_settings(self): + """Circuit thresholds read from global settings, not entry.options.""" + hass = _make_hass() + monitor = _make_monitor(hass, _make_options()) + monitor._global_settings = { + CONTINUOUS_THRESHOLD_PCT: 70, + SPIKE_THRESHOLD_PCT: 85, + WINDOW_DURATION_M: 10, + COOLDOWN_DURATION_M: 20, + } + thresholds = monitor._resolve_circuit_thresholds("test_circuit") + assert thresholds == (70, 85, 10, 20) + + def test_mains_thresholds_use_global_settings(self): + """Mains thresholds read from global settings, not entry.options.""" + hass = _make_hass() + monitor = _make_monitor(hass, _make_options()) + monitor._global_settings = { + CONTINUOUS_THRESHOLD_PCT: 65, + SPIKE_THRESHOLD_PCT: 88, + WINDOW_DURATION_M: 12, + COOLDOWN_DURATION_M: 25, + } + thresholds = monitor._resolve_mains_thresholds("upstream_l1") + assert thresholds == (65, 88, 12, 25) + + def test_dispatch_alert_uses_global_settings(self): + """Alert dispatch reads notification settings from global settings.""" + hass = _make_hass() + monitor = _make_monitor(hass, _make_options()) + monitor._global_settings = { + NOTIFY_TARGETS: "notify.test_target", + } + circuit = SpanCircuitSnapshotFactory.create( + circuit_id="1", name="Kitchen", + current_a=20.0, breaker_rating_a=20.0, + ) + snapshot = SpanPanelSnapshotFactory.create( + circuits={"1": circuit}, main_breaker_rating_a=200, + ) + monitor.process_snapshot(snapshot) + # Event bus should NOT fire because event_bus is not in notify_targets + hass.bus.async_fire.assert_not_called() + + @pytest.mark.asyncio + async def test_save_and_load_global_settings(self): + """Global settings survive save/load cycle.""" + hass = _make_hass() + store_data: dict[str, Any] = {} + + async def mock_save(data: dict[str, Any]) -> None: + store_data["saved"] = data + + async def mock_load() -> dict[str, Any] | None: + return store_data.get("saved") + + monitor = _make_monitor(hass) + monitor._store = MagicMock() + monitor._store.async_save = AsyncMock(side_effect=mock_save) + monitor._store.async_load = AsyncMock(side_effect=mock_load) + + monitor.set_global_settings({ + CONTINUOUS_THRESHOLD_PCT: 70, + SPIKE_THRESHOLD_PCT: 90, + }) + await monitor.async_save_overrides() + + # Create a new monitor and load + monitor2 = _make_monitor(hass) + monitor2._store = MagicMock() + monitor2._store.async_load = AsyncMock(side_effect=mock_load) + await monitor2.async_load_overrides() + + assert monitor2._global_settings[CONTINUOUS_THRESHOLD_PCT] == 70 + assert monitor2._global_settings[SPIKE_THRESHOLD_PCT] == 90 + + @pytest.mark.asyncio + async def test_load_without_global_key_leaves_empty(self): + """Loading storage without a 'global' key leaves global_settings empty.""" + hass = _make_hass() + monitor = _make_monitor(hass) + monitor._store = MagicMock() + monitor._store.async_load = AsyncMock(return_value={ + "circuit_overrides": {}, + "mains_overrides": {}, + }) + await monitor.async_load_overrides() + assert monitor._global_settings == {} diff --git a/tests/test_current_monitor_services.py b/tests/test_current_monitor_services.py new file mode 100644 index 00000000..42e89e64 --- /dev/null +++ b/tests/test_current_monitor_services.py @@ -0,0 +1,49 @@ +"""Tests for current monitoring service registration and handling.""" + +import pytest +import voluptuous as vol + + +class TestServiceRegistration: + """Tests for monitoring service registration.""" + + def test_set_circuit_threshold_service_schema_validates(self): + """Service schema accepts valid circuit threshold input.""" + from custom_components.span_panel.__init__ import ( + _build_set_circuit_threshold_schema, + ) + + schema = _build_set_circuit_threshold_schema() + result = schema({"circuit_id": "sensor.span_panel_kitchen_power", "spike_threshold_pct": 90}) + assert result["circuit_id"] == "sensor.span_panel_kitchen_power" + assert result["spike_threshold_pct"] == 90 + + def test_set_circuit_threshold_schema_rejects_missing_circuit_id(self): + """Service schema rejects input without circuit_id.""" + from custom_components.span_panel.__init__ import ( + _build_set_circuit_threshold_schema, + ) + + schema = _build_set_circuit_threshold_schema() + with pytest.raises(vol.MultipleInvalid): + schema({"spike_threshold_pct": 90}) + + def test_set_mains_threshold_service_schema_validates(self): + """Service schema accepts valid mains threshold input.""" + from custom_components.span_panel.__init__ import ( + _build_set_mains_threshold_schema, + ) + + schema = _build_set_mains_threshold_schema() + result = schema({"leg": "sensor.span_panel_upstream_l1_current", "spike_threshold_pct": 90}) + assert result["leg"] == "sensor.span_panel_upstream_l1_current" + + def test_set_mains_threshold_schema_accepts_legacy_leg_name(self): + """Service schema accepts legacy leg name for backwards compatibility.""" + from custom_components.span_panel.__init__ import ( + _build_set_mains_threshold_schema, + ) + + schema = _build_set_mains_threshold_schema() + result = schema({"leg": "upstream_l1", "spike_threshold_pct": 90}) + assert result["leg"] == "upstream_l1" diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py new file mode 100644 index 00000000..c5af3331 --- /dev/null +++ b/tests/test_diagnostics.py @@ -0,0 +1,185 @@ +"""Tests for Span Panel diagnostics.""" + +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import MagicMock + +from homeassistant.components.diagnostics import REDACTED +from custom_components.span_panel import SpanPanelRuntimeData +from custom_components.span_panel.const import ( + CONF_EBUS_BROKER_PASSWORD, + CONF_EBUS_BROKER_USERNAME, + CONF_HOP_PASSPHRASE, + DOMAIN, +) +from custom_components.span_panel.diagnostics import ( + async_get_config_entry_diagnostics, +) +from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.core import HomeAssistant + +from .factories import ( + SpanBatterySnapshotFactory, + SpanCircuitSnapshotFactory, + SpanEvseSnapshotFactory, + SpanPanelSnapshotFactory, +) + +from pytest_homeassistant_custom_component.common import MockConfigEntry + + +async def test_config_entry_diagnostics_includes_redacted_runtime_data( + hass: HomeAssistant, +) -> None: + """Return redacted diagnostics with optional runtime sections populated.""" + snapshot = SpanPanelSnapshotFactory.create( + serial_number="sp3-diag-001", + firmware_version="spanos2/r202603/05", + panel_size=32, + wifi_ssid="Span WiFi", + eth0_link=True, + wlan_link=False, + circuits={ + "uuid_kitchen": SpanCircuitSnapshotFactory.create( + circuit_id="uuid_kitchen", + name="Kitchen", + relay_state="CLOSED", + priority="SOC_THRESHOLD", + instant_power_w=245.5, + produced_energy_wh=10.0, + consumed_energy_wh=2500.0, + device_type="circuit", + tabs=[5, 6], + ) + }, + evse={"evse-0": SpanEvseSnapshotFactory.create()}, + battery=SpanBatterySnapshotFactory.create( + connected=True, + soe_percentage=84.0, + soe_kwh=11.2, + ), + ) + coordinator = MagicMock() + coordinator.data = snapshot + coordinator.panel_offline = False + coordinator.last_update_success = True + + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_ACCESS_TOKEN: "access-secret", + CONF_EBUS_BROKER_PASSWORD: "mqtt-password", + CONF_EBUS_BROKER_USERNAME: "mqtt-user", + CONF_HOP_PASSPHRASE: "hop-secret", + }, + title="SPAN Panel", + unique_id="sp3-diag-001", + ) + entry.runtime_data = SpanPanelRuntimeData(coordinator=coordinator) + + result = await async_get_config_entry_diagnostics(hass, entry) + + assert result["config_entry"]["data"][CONF_ACCESS_TOKEN] == REDACTED + assert result["config_entry"]["data"][CONF_EBUS_BROKER_PASSWORD] == REDACTED + assert result["config_entry"]["data"][CONF_EBUS_BROKER_USERNAME] == REDACTED + assert result["config_entry"]["data"][CONF_HOP_PASSPHRASE] == REDACTED + + assert result["panel"] == { + "serial_number": "sp3-diag-001", + "firmware_version": "spanos2/r202603/05", + "panel_size": 32, + "wifi_ssid": "Span WiFi", + "eth0_link": True, + "wlan_link": False, + } + assert result["circuits"]["uuid_kitchen"] == { + "name": "Kitchen", + "relay_state": "CLOSED", + "relay_state_target": None, + "priority": "SOC_THRESHOLD", + "priority_target": None, + "is_user_controllable": True, + "instant_power_w": 245.5, + "produced_energy_wh": 10.0, + "consumed_energy_wh": 2500.0, + "device_type": "circuit", + "tabs": [5, 6], + } + assert result["evse"]["evse-0"] == { + "node_id": "evse-0", + "feed_circuit_id": "evse_circuit_1", + "status": "CHARGING", + "lock_state": "LOCKED", + "advertised_current_a": 32.0, + } + assert result["battery"] == { + "connected": True, + "soe_percentage": 84.0, + "soe_kwh": 11.2, + } + assert result["coordinator"] == { + "panel_offline": False, + "last_update_success": True, + } + + +async def test_config_entry_diagnostics_omits_optional_sections_when_unavailable( + hass: HomeAssistant, +) -> None: + """Return empty optional sections when the snapshot lacks them.""" + snapshot = SimpleNamespace( + serial_number="sp3-diag-002", + firmware_version="spanos2/r202603/06", + panel_size=None, + wifi_ssid=None, + eth0_link=None, + wlan_link=None, + circuits={ + "uuid_minimal": SimpleNamespace( + name=None, + relay_state="OPEN", + relay_state_target=None, + priority="NEVER", + priority_target=None, + is_user_controllable=False, + instant_power_w=0.0, + produced_energy_wh=0.0, + consumed_energy_wh=0.0, + ) + }, + evse={}, + battery=None, + ) + coordinator = MagicMock() + coordinator.data = snapshot + coordinator.panel_offline = True + coordinator.last_update_success = False + + entry = MockConfigEntry(domain=DOMAIN, data={}, title="SPAN Panel") + entry.runtime_data = SpanPanelRuntimeData(coordinator=coordinator) + + result = await async_get_config_entry_diagnostics(hass, entry) + + assert result["panel"] == { + "serial_number": "sp3-diag-002", + "firmware_version": "spanos2/r202603/06", + "panel_size": None, + } + assert result["circuits"]["uuid_minimal"] == { + "name": None, + "relay_state": "OPEN", + "relay_state_target": None, + "priority": "NEVER", + "priority_target": None, + "is_user_controllable": False, + "instant_power_w": 0.0, + "produced_energy_wh": 0.0, + "consumed_energy_wh": 0.0, + } + assert result["evse"] == {} + assert result["battery"] == {} + assert result["coordinator"] == { + "panel_offline": True, + "last_update_success": False, + } diff --git a/tests/test_dps_and_bess.py b/tests/test_dps_and_bess.py new file mode 100644 index 00000000..8985afa2 --- /dev/null +++ b/tests/test_dps_and_bess.py @@ -0,0 +1,202 @@ +"""Tests for GFE override buttons and BESS connected binary sensor.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from span_panel_api import SpanBatterySnapshot +from span_panel_api.exceptions import SpanPanelServerError + +from custom_components.span_panel.binary_sensor import BESS_CONNECTED_SENSOR +from custom_components.span_panel.button import ( + GFE_OVERRIDE_DESCRIPTION, + SpanPanelGFEOverrideButton, +) +from custom_components.span_panel.helpers import has_bess + +from .factories import SpanPanelSnapshotFactory + +# --------------------------------------------------------------------------- +# BESS Connected Binary Sensor +# --------------------------------------------------------------------------- + + +class TestBessConnectedBinarySensor: + """Tests for the bess_connected binary sensor.""" + + def test_bess_connected_true(self) -> None: + """Connected=True -> is_on=True.""" + snapshot = SpanPanelSnapshotFactory.create( + battery=SpanBatterySnapshot(soe_percentage=85.0, connected=True), + ) + assert BESS_CONNECTED_SENSOR.value_fn(snapshot) is True + + def test_bess_connected_false(self) -> None: + """Connected=False -> is_on=False.""" + snapshot = SpanPanelSnapshotFactory.create( + battery=SpanBatterySnapshot(soe_percentage=85.0, connected=False), + ) + assert BESS_CONNECTED_SENSOR.value_fn(snapshot) is False + + def test_bess_connected_none(self) -> None: + """Connected=None -> is_on=None.""" + snapshot = SpanPanelSnapshotFactory.create( + battery=SpanBatterySnapshot(soe_percentage=85.0, connected=None), + ) + assert BESS_CONNECTED_SENSOR.value_fn(snapshot) is None + + def test_bess_sensor_not_created_without_bess(self) -> None: + """No BESS (soe_percentage=None) -> has_bess returns False.""" + snapshot = SpanPanelSnapshotFactory.create( + battery=SpanBatterySnapshot(), + ) + assert not has_bess(snapshot) + + def test_bess_sensor_created_with_bess(self) -> None: + """BESS present (soe_percentage set) -> has_bess returns True.""" + snapshot = SpanPanelSnapshotFactory.create( + battery=SpanBatterySnapshot(soe_percentage=85.0, connected=True), + ) + assert has_bess(snapshot) + + +# --------------------------------------------------------------------------- +# GFE Override Buttons +# --------------------------------------------------------------------------- + + +def _make_gfe_coordinator( + dominant_power_source: str | None = "GRID", + battery: SpanBatterySnapshot | None = None, +) -> MagicMock: + """Build a mock coordinator for GFE button tests.""" + snapshot = SpanPanelSnapshotFactory.create( + dominant_power_source=dominant_power_source, + battery=battery if battery is not None else SpanBatterySnapshot(), + ) + + coordinator = MagicMock() + coordinator.data = snapshot + coordinator.config_entry = MagicMock() + coordinator.config_entry.title = "SPAN Panel" + coordinator.config_entry.data = {} + coordinator.config_entry.options = {} + coordinator.async_request_refresh = AsyncMock() + return coordinator + + +class TestGFEOverrideButtons: + """Tests for the GFE override button entities.""" + + def test_grid_button_unique_id(self) -> None: + """Grid override button has expected unique ID.""" + coordinator = _make_gfe_coordinator() + button = SpanPanelGFEOverrideButton( + coordinator, GFE_OVERRIDE_DESCRIPTION, "GRID" + ) + assert button._attr_unique_id is not None + assert "gfe_override" in str(button._attr_unique_id) + + @pytest.mark.asyncio + async def test_grid_button_press_publishes_grid(self) -> None: + """Pressing the grid button calls set_dominant_power_source with GRID.""" + coordinator = _make_gfe_coordinator() + button = SpanPanelGFEOverrideButton( + coordinator, GFE_OVERRIDE_DESCRIPTION, "GRID" + ) + button.hass = MagicMock() + + coordinator.client = AsyncMock() + coordinator.client.set_dominant_power_source = AsyncMock() + + await button.async_press() + + coordinator.client.set_dominant_power_source.assert_called_once_with("GRID") + coordinator.async_request_refresh.assert_called_once() + + @pytest.mark.asyncio + async def test_button_missing_control_method(self) -> None: + """Client without set_dominant_power_source method logs warning.""" + coordinator = _make_gfe_coordinator() + button = SpanPanelGFEOverrideButton( + coordinator, GFE_OVERRIDE_DESCRIPTION, "GRID" + ) + button.hass = MagicMock() + + # Client without set_dominant_power_source + coordinator.client = MagicMock(spec=[]) + + await button.async_press() + + # Should not raise, just log + coordinator.async_request_refresh.assert_not_called() + + @pytest.mark.asyncio + async def test_button_server_error(self) -> None: + """SpanPanelServerError triggers a notification.""" + coordinator = _make_gfe_coordinator() + + with patch( + "custom_components.span_panel.button.async_create_span_notification", + new_callable=AsyncMock, + ) as mock_notification: + button = SpanPanelGFEOverrideButton( + coordinator, GFE_OVERRIDE_DESCRIPTION, "GRID" + ) + button.hass = MagicMock() + + coordinator.client = AsyncMock() + coordinator.client.set_dominant_power_source = AsyncMock( + side_effect=SpanPanelServerError("test error") + ) + + await button.async_press() + + mock_notification.assert_called_once() + + def test_available_when_bess_offline_and_not_grid(self) -> None: + """Button is available when BESS is offline and GFE is not GRID.""" + coordinator = _make_gfe_coordinator( + dominant_power_source="BATTERY", + battery=SpanBatterySnapshot(soe_percentage=50.0, connected=False), + ) + coordinator.panel_offline = False + button = SpanPanelGFEOverrideButton( + coordinator, GFE_OVERRIDE_DESCRIPTION, "GRID" + ) + assert button.available is True + + def test_unavailable_when_bess_online(self) -> None: + """Button is unavailable when BESS is communicating.""" + coordinator = _make_gfe_coordinator( + dominant_power_source="BATTERY", + battery=SpanBatterySnapshot(soe_percentage=50.0, connected=True), + ) + coordinator.panel_offline = False + button = SpanPanelGFEOverrideButton( + coordinator, GFE_OVERRIDE_DESCRIPTION, "GRID" + ) + assert button.available is False + + def test_unavailable_when_gfe_is_grid(self) -> None: + """Button is unavailable when GFE is already GRID.""" + coordinator = _make_gfe_coordinator( + dominant_power_source="GRID", + battery=SpanBatterySnapshot(soe_percentage=50.0, connected=False), + ) + coordinator.panel_offline = False + button = SpanPanelGFEOverrideButton( + coordinator, GFE_OVERRIDE_DESCRIPTION, "GRID" + ) + assert button.available is False + + def test_available_when_no_bess(self) -> None: + """Button is available when no BESS is commissioned and GFE is not GRID.""" + coordinator = _make_gfe_coordinator( + dominant_power_source="BATTERY", + battery=SpanBatterySnapshot(), + ) + coordinator.panel_offline = False + button = SpanPanelGFEOverrideButton( + coordinator, GFE_OVERRIDE_DESCRIPTION, "GRID" + ) + assert button.available is True diff --git a/tests/test_energy_dip_compensation.py b/tests/test_energy_dip_compensation.py new file mode 100644 index 00000000..394b878a --- /dev/null +++ b/tests/test_energy_dip_compensation.py @@ -0,0 +1,456 @@ +"""Tests for energy dip compensation feature.""" + +# ruff: noqa: D102 + +from types import SimpleNamespace +from unittest.mock import MagicMock + +from homeassistant.components.sensor import SensorStateClass +from custom_components.span_panel.const import ENABLE_ENERGY_DIP_COMPENSATION +from custom_components.span_panel.options import ENERGY_REPORTING_GRACE_PERIOD +from custom_components.span_panel.sensor_base import ( + SpanEnergyExtraStoredData, + SpanEnergySensorBase, +) + + +class DummyDipSensor(SpanEnergySensorBase): + """Minimal concrete energy sensor for dip compensation tests.""" + + def __init__( # pylint: disable=super-init-not-called + self, + dip_enabled: bool = True, + state_class: SensorStateClass = SensorStateClass.TOTAL_INCREASING, + ) -> None: + """Bypass parent __init__ to avoid full HA dependencies.""" + self.coordinator = SimpleNamespace( + panel_offline=False, + config_entry=SimpleNamespace( + options={ + ENERGY_REPORTING_GRACE_PERIOD: 15, + ENABLE_ENERGY_DIP_COMPENSATION: dip_enabled, + }, + ), + data=SimpleNamespace(), + report_energy_dip=MagicMock(), + ) + self._mock_panel_value: float | None = None + self.entity_description = SimpleNamespace( + device_class="energy", + state_class=state_class, + key="dummy", + value_fn=lambda _: self._mock_panel_value, + native_unit_of_measurement="Wh", + ) + self._attr_native_value: float | None = None + self._last_valid_state: float | None = None + self._last_valid_changed = None + self._grace_period_minutes = 15 + self._previous_circuit_name = None + self._attr_unique_id = "dummy_sensor" + self._attr_name = "Dummy" + self._restored_from_storage: bool = False + + # Energy dip compensation state + self._energy_offset: float = 0.0 + self._last_panel_reading: float | None = None + self._last_dip_delta: float | None = None + self._is_total_increasing: bool = ( + state_class == SensorStateClass.TOTAL_INCREASING + ) + self._dip_compensation_enabled: bool = dip_enabled + + def _generate_unique_id(self, snapshot, description): + return "dummy_sensor" + + def _generate_friendly_name(self, snapshot, description): + return "Dummy" + + def get_data_source(self, snapshot): + return "dummy_data" + + +# ============================================================================= +# SpanEnergyExtraStoredData round-trip with new fields +# ============================================================================= + + +class TestExtraStoredDataDipFields: + """Tests for energy dip fields in SpanEnergyExtraStoredData.""" + + def test_as_dict_includes_dip_fields(self): + """Verify as_dict includes the three new dip fields.""" + data = SpanEnergyExtraStoredData( + native_value=100.0, + native_unit_of_measurement="Wh", + last_valid_state=100.0, + last_valid_changed="2025-12-01T00:00:00", + energy_offset=5.0, + last_panel_reading=95.0, + last_dip_delta=5.0, + ) + result = data.as_dict() + assert result["energy_offset"] == 5.0 + assert result["last_panel_reading"] == 95.0 + assert result["last_dip_delta"] == 5.0 + + def test_from_dict_restores_dip_fields(self): + """Verify from_dict restores the three new dip fields.""" + stored = { + "native_value": 200.0, + "native_unit_of_measurement": "Wh", + "last_valid_state": 200.0, + "last_valid_changed": "2025-12-01T12:00:00", + "energy_offset": 10.0, + "last_panel_reading": 190.0, + "last_dip_delta": 10.0, + } + result = SpanEnergyExtraStoredData.from_dict(stored) + assert result is not None + assert result.energy_offset == 10.0 + assert result.last_panel_reading == 190.0 + assert result.last_dip_delta == 10.0 + + def test_backward_compat_missing_dip_fields(self): + """Old stored data without dip fields deserializes with None.""" + stored = { + "native_value": 300.0, + "native_unit_of_measurement": "Wh", + "last_valid_state": 300.0, + "last_valid_changed": "2025-12-01T06:00:00", + } + result = SpanEnergyExtraStoredData.from_dict(stored) + assert result is not None + assert result.energy_offset is None + assert result.last_panel_reading is None + assert result.last_dip_delta is None + + def test_roundtrip_with_dip_fields(self): + """Data survives a full round-trip through serialization.""" + original = SpanEnergyExtraStoredData( + native_value=500.0, + native_unit_of_measurement="Wh", + last_valid_state=500.0, + last_valid_changed="2025-12-01T09:00:00", + energy_offset=25.0, + last_panel_reading=475.0, + last_dip_delta=8.0, + ) + restored = SpanEnergyExtraStoredData.from_dict(original.as_dict()) + assert restored is not None + assert restored.energy_offset == original.energy_offset + assert restored.last_panel_reading == original.last_panel_reading + assert restored.last_dip_delta == original.last_dip_delta + + def test_roundtrip_with_none_dip_fields(self): + """None dip fields survive round-trip.""" + original = SpanEnergyExtraStoredData( + native_value=100.0, + native_unit_of_measurement="Wh", + last_valid_state=100.0, + last_valid_changed="2025-12-01T09:00:00", + energy_offset=None, + last_panel_reading=None, + last_dip_delta=None, + ) + restored = SpanEnergyExtraStoredData.from_dict(original.as_dict()) + assert restored is not None + assert restored.energy_offset is None + assert restored.last_panel_reading is None + assert restored.last_dip_delta is None + + +# ============================================================================= +# Dip compensation logic +# ============================================================================= + + +class TestDipCompensation: + """Tests for the core dip compensation logic in _process_raw_value.""" + + def test_first_reading_sets_baseline(self): + """First reading sets _last_panel_reading without applying offset.""" + sensor = DummyDipSensor(dip_enabled=True) + sensor._mock_panel_value = 1000.0 + sensor._update_native_value() + + assert sensor._attr_native_value == 1000.0 + assert sensor._last_panel_reading == 1000.0 + assert sensor._energy_offset == 0.0 + + def test_normal_increase_passthrough(self): + """Normal increasing values pass through without offset.""" + sensor = DummyDipSensor(dip_enabled=True) + + sensor._mock_panel_value = 1000.0 + sensor._update_native_value() + + sensor._mock_panel_value = 1100.0 + sensor._update_native_value() + + assert sensor._attr_native_value == 1100.0 + assert sensor._last_panel_reading == 1100.0 + assert sensor._energy_offset == 0.0 + + def test_dip_applies_offset(self): + """A dip in raw value produces compensated output.""" + sensor = DummyDipSensor(dip_enabled=True) + + sensor._mock_panel_value = 1000.0 + sensor._update_native_value() + + # Dip of 50 Wh + sensor._mock_panel_value = 950.0 + sensor._update_native_value() + + # HA should see 950 + 50 = 1000 + assert sensor._attr_native_value == 1000.0 + assert sensor._energy_offset == 50.0 + assert sensor._last_panel_reading == 950.0 + assert sensor._last_dip_delta == 50.0 + + def test_below_threshold_ignored(self): + """Dips below 1.0 Wh threshold are not compensated.""" + sensor = DummyDipSensor(dip_enabled=True) + + sensor._mock_panel_value = 1000.0 + sensor._update_native_value() + + # Dip of 0.5 Wh — below threshold + sensor._mock_panel_value = 999.5 + sensor._update_native_value() + + assert sensor._attr_native_value == 999.5 + assert sensor._energy_offset == 0.0 + assert sensor._last_dip_delta is None + + def test_multiple_dips_accumulate(self): + """Multiple dips accumulate the offset.""" + sensor = DummyDipSensor(dip_enabled=True) + + sensor._mock_panel_value = 1000.0 + sensor._update_native_value() + + # First dip: 20 Wh + sensor._mock_panel_value = 980.0 + sensor._update_native_value() + assert sensor._energy_offset == 20.0 + assert sensor._attr_native_value == 1000.0 + + # Normal increase + sensor._mock_panel_value = 1010.0 + sensor._update_native_value() + assert sensor._attr_native_value == 1030.0 # 1010 + 20 + + # Second dip: 30 Wh + sensor._mock_panel_value = 980.0 + sensor._update_native_value() + assert sensor._energy_offset == 50.0 # 20 + 30 + assert sensor._attr_native_value == 1030.0 # 980 + 50 + + def test_disabled_passthrough(self): + """When disabled, dips pass through without compensation.""" + sensor = DummyDipSensor(dip_enabled=False) + + sensor._mock_panel_value = 1000.0 + sensor._update_native_value() + + sensor._mock_panel_value = 950.0 + sensor._update_native_value() + + # No compensation — raw value passed through + assert sensor._attr_native_value == 950.0 + assert sensor._energy_offset == 0.0 + + def test_non_total_increasing_passthrough(self): + """MEASUREMENT sensors pass through without compensation.""" + sensor = DummyDipSensor( + dip_enabled=True, + state_class=SensorStateClass.MEASUREMENT, + ) + + sensor._mock_panel_value = 1000.0 + sensor._update_native_value() + + sensor._mock_panel_value = 950.0 + sensor._update_native_value() + + # Not a TOTAL_INCREASING sensor — no compensation + assert sensor._attr_native_value == 950.0 + assert sensor._energy_offset == 0.0 + + def test_dip_reports_to_coordinator(self): + """Dip detection calls coordinator.report_energy_dip.""" + sensor = DummyDipSensor(dip_enabled=True) + sensor.entity_id = "sensor.test_energy" + + sensor._mock_panel_value = 1000.0 + sensor._update_native_value() + + sensor._mock_panel_value = 950.0 + sensor._update_native_value() + + sensor.coordinator.report_energy_dip.assert_called_once_with( + "sensor.test_energy", 50.0, 50.0 + ) + + def test_no_report_when_no_dip(self): + """Normal increases don't trigger coordinator notification.""" + sensor = DummyDipSensor(dip_enabled=True) + + sensor._mock_panel_value = 1000.0 + sensor._update_native_value() + + sensor._mock_panel_value = 1100.0 + sensor._update_native_value() + + sensor.coordinator.report_energy_dip.assert_not_called() + + def test_exactly_at_threshold_triggers(self): + """A dip of exactly 1.0 Wh triggers compensation.""" + sensor = DummyDipSensor(dip_enabled=True) + + sensor._mock_panel_value = 1000.0 + sensor._update_native_value() + + sensor._mock_panel_value = 999.0 + sensor._update_native_value() + + assert sensor._energy_offset == 1.0 + assert sensor._attr_native_value == 1000.0 + + +# ============================================================================= +# Extra state attributes +# ============================================================================= + + +class TestDipAttributes: + """Tests for energy dip compensation state attributes.""" + + def test_shown_when_offset_nonzero(self): + """Attributes include energy_offset when it is nonzero.""" + sensor = DummyDipSensor(dip_enabled=True) + sensor._energy_offset = 25.0 + sensor._last_dip_delta = 10.0 + + attrs = sensor.extra_state_attributes + assert attrs is not None + assert attrs["energy_offset"] == 25.0 + assert attrs["last_dip_delta"] == 10.0 + + def test_hidden_when_offset_zero_no_dip(self): + """Attributes omit energy_offset when zero and no dip has occurred.""" + sensor = DummyDipSensor(dip_enabled=True) + # Defaults: offset=0.0, last_dip_delta=None + + attrs = sensor.extra_state_attributes + # No dip fields should appear + assert attrs is None or "energy_offset" not in attrs + + def test_hidden_when_disabled(self): + """Dip attributes are not shown when compensation is disabled.""" + sensor = DummyDipSensor(dip_enabled=False) + sensor._energy_offset = 25.0 + sensor._last_dip_delta = 10.0 + + attrs = sensor.extra_state_attributes + assert attrs is None or "energy_offset" not in attrs + + def test_last_dip_shown_when_dip_occurred(self): + """last_dip_delta appears even when offset is zero (shouldn't happen, but edge case).""" + sensor = DummyDipSensor(dip_enabled=True) + sensor._energy_offset = 0.0 + sensor._last_dip_delta = 5.0 + + attrs = sensor.extra_state_attributes + assert attrs is not None + assert attrs["last_dip_delta"] == 5.0 + # energy_offset is 0, should not be included + assert "energy_offset" not in attrs + + +# ============================================================================= +# Restoration +# ============================================================================= + + +class TestDipRestoration: + """Tests for dip compensation state restoration.""" + + def test_offset_restored_when_enabled(self): + """Verify _energy_offset is restored from stored data when enabled.""" + sensor = DummyDipSensor(dip_enabled=True) + + # Simulate what async_added_to_hass restoration does + restored = SpanEnergyExtraStoredData( + native_value=1000.0, + native_unit_of_measurement="Wh", + last_valid_state=1000.0, + last_valid_changed="2025-12-01T00:00:00", + energy_offset=50.0, + last_panel_reading=950.0, + last_dip_delta=10.0, + ) + + # Apply restoration (mimicking the async_added_to_hass logic) + if sensor._dip_compensation_enabled and sensor._is_total_increasing: + if restored.energy_offset is not None: + sensor._energy_offset = restored.energy_offset + if restored.last_panel_reading is not None: + sensor._last_panel_reading = restored.last_panel_reading + if restored.last_dip_delta is not None: + sensor._last_dip_delta = restored.last_dip_delta + + assert sensor._energy_offset == 50.0 + assert sensor._last_panel_reading == 950.0 + assert sensor._last_dip_delta == 10.0 + + def test_offset_not_restored_when_disabled(self): + """Verify offsets are NOT restored when compensation is disabled.""" + sensor = DummyDipSensor(dip_enabled=False) + + restored = SpanEnergyExtraStoredData( + native_value=1000.0, + native_unit_of_measurement="Wh", + last_valid_state=1000.0, + last_valid_changed="2025-12-01T00:00:00", + energy_offset=50.0, + last_panel_reading=950.0, + last_dip_delta=10.0, + ) + + # Apply restoration logic (gate on enabled flag) + if sensor._dip_compensation_enabled and sensor._is_total_increasing: + if restored.energy_offset is not None: + sensor._energy_offset = restored.energy_offset + + # Disabled — should remain at defaults + assert sensor._energy_offset == 0.0 + assert sensor._last_panel_reading is None + assert sensor._last_dip_delta is None + + def test_extra_restore_state_data_includes_dip_fields(self): + """extra_restore_state_data includes the dip compensation fields.""" + sensor = DummyDipSensor(dip_enabled=True) + sensor._attr_native_value = 1000.0 + sensor._energy_offset = 25.0 + sensor._last_panel_reading = 975.0 + sensor._last_dip_delta = 5.0 + sensor._last_valid_state = 1000.0 + + stored = sensor.extra_restore_state_data + d = stored.as_dict() + assert d["energy_offset"] == 25.0 + assert d["last_panel_reading"] == 975.0 + assert d["last_dip_delta"] == 5.0 + + def test_extra_restore_state_data_zero_offset_stored_as_none(self): + """Zero offset is stored as None to keep stored data compact.""" + sensor = DummyDipSensor(dip_enabled=True) + sensor._attr_native_value = 1000.0 + sensor._energy_offset = 0.0 + + stored = sensor.extra_restore_state_data + d = stored.as_dict() + assert d["energy_offset"] is None diff --git a/tests/test_energy_sensor_recorder.py b/tests/test_energy_sensor_recorder.py new file mode 100644 index 00000000..9c8925a1 --- /dev/null +++ b/tests/test_energy_sensor_recorder.py @@ -0,0 +1,27 @@ +"""Energy sensors exclude volatile attributes from recorder history (#197).""" + +from homeassistant.components.sensor import SensorEntity +from custom_components.span_panel.sensor_base import SpanEnergySensorBase + + +def test_span_energy_sensor_combined_unrecorded_includes_high_churn_attributes() -> ( + None +): + """Recorder must not persist grace-period / dip diagnostic attributes.""" + + combined = SpanEnergySensorBase._Entity__combined_unrecorded_attributes + for key in ( + "energy_offset", + "grace_period_remaining", + "last_dip_delta", + "last_valid_changed", + "last_valid_state", + "tabs", + "using_grace_period", + "voltage", + ): + assert key in combined, f"missing unrecorded key: {key}" + + assert SensorEntity._entity_component_unrecorded_attributes <= combined, ( + "sensor component exclusions (e.g. options) must remain" + ) diff --git a/tests/test_entity_summary.py b/tests/test_entity_summary.py deleted file mode 100644 index 9214568d..00000000 --- a/tests/test_entity_summary.py +++ /dev/null @@ -1,370 +0,0 @@ -"""Tests for entity_summary module. - -This module tests the entity summary logging functionality. -""" -import logging -from unittest.mock import MagicMock, patch - -from homeassistant.config_entries import ConfigEntry -import pytest - - -class TestLogEntitySummary: - """Test the log_entity_summary function.""" - - @pytest.fixture - def mock_coordinator(self): - """Create a mock coordinator with span panel data.""" - # Lazy imports to avoid collection issues - from custom_components.span_panel.coordinator import SpanPanelCoordinator - from custom_components.span_panel.span_panel import SpanPanel - from custom_components.span_panel.span_panel_circuit import SpanPanelCircuit - - coordinator = MagicMock(spec=SpanPanelCoordinator) - span_panel = MagicMock(spec=SpanPanel) - - # Create mock circuits - circuits = {} - for i in range(1, 11): # 10 circuits total - circuit = MagicMock(spec=SpanPanelCircuit) - circuit.circuit_id = f"circuit_{i}" - circuit.name = f"Circuit {i}" - circuit.is_user_controllable = i <= 8 # First 8 are controllable, last 2 are not - circuits[f"circuit_{i}"] = circuit - - span_panel.circuits = circuits - coordinator.data = span_panel - return coordinator - - @pytest.fixture - def mock_config_entry(self): - """Create a mock config entry.""" - config_entry = MagicMock(spec=ConfigEntry) - config_entry.options = {} - return config_entry - - @pytest.fixture - def mock_config_entry_with_options(self): - """Create a mock config entry with all options enabled.""" - # Lazy imports to avoid collection issues - from custom_components.span_panel.options import BATTERY_ENABLE, INVERTER_ENABLE - - config_entry = MagicMock(spec=ConfigEntry) - config_entry.options = { - BATTERY_ENABLE: True, - INVERTER_ENABLE: True - } - return config_entry - - def test_log_entity_summary_debug_level(self, mock_coordinator, mock_config_entry, caplog): - """Test entity summary logging at debug level.""" - # Lazy imports to avoid collection issues - from custom_components.span_panel.entity_summary import log_entity_summary - - with caplog.at_level(logging.DEBUG, logger="custom_components.span_panel"): - log_entity_summary(mock_coordinator, mock_config_entry) - - # Check that summary was logged - assert "=== SPAN PANEL ENTITY SUMMARY ===" in caplog.text - assert "Total circuits: 10 (8 controllable, 2 non-controllable)" in caplog.text - assert "=== NATIVE SENSORS ===" in caplog.text - assert "=== SYNTHETIC SENSORS (Template-based) ===" in caplog.text - assert "=== END ENTITY SUMMARY ===" in caplog.text - - def test_log_entity_summary_info_level(self, mock_coordinator, mock_config_entry, caplog): - """Test entity summary logging at info level.""" - # Lazy imports to avoid collection issues - from custom_components.span_panel.entity_summary import log_entity_summary - - with caplog.at_level(logging.INFO, logger="custom_components.span_panel"): - log_entity_summary(mock_coordinator, mock_config_entry) - - # Check that summary was logged - assert "=== SPAN PANEL ENTITY SUMMARY ===" in caplog.text - assert "Total circuits: 10" in caplog.text - - def test_log_entity_summary_no_logging(self, mock_coordinator, mock_config_entry, caplog): - """Test that nothing is logged when logging is disabled.""" - # Lazy imports to avoid collection issues - from custom_components.span_panel.entity_summary import log_entity_summary - - # Set logger to WARNING level (higher than INFO/DEBUG) - with caplog.at_level(logging.WARNING, logger="custom_components.span_panel"): - log_entity_summary(mock_coordinator, mock_config_entry) - - # Should not log anything - assert "=== SPAN PANEL ENTITY SUMMARY ===" not in caplog.text - - def test_log_entity_summary_with_battery_enabled(self, mock_coordinator, mock_config_entry_with_options, caplog): - """Test entity summary with battery enabled.""" - # Lazy imports to avoid collection issues - from custom_components.span_panel.entity_summary import log_entity_summary - - with caplog.at_level(logging.DEBUG, logger="custom_components.span_panel"): - log_entity_summary(mock_coordinator, mock_config_entry_with_options) - - assert "Battery sensors: 1 (native sensor)" in caplog.text - - def test_log_entity_summary_with_battery_disabled(self, mock_coordinator, mock_config_entry, caplog): - """Test entity summary with battery disabled.""" - # Lazy imports to avoid collection issues - from custom_components.span_panel.entity_summary import log_entity_summary - - with caplog.at_level(logging.DEBUG, logger="custom_components.span_panel"): - log_entity_summary(mock_coordinator, mock_config_entry) - - assert "Battery sensors: 0 (battery disabled)" in caplog.text - - def test_log_entity_summary_with_solar_enabled(self, mock_coordinator, mock_config_entry_with_options, caplog): - """Test entity summary with solar enabled.""" - # Lazy imports to avoid collection issues - from custom_components.span_panel.entity_summary import log_entity_summary - - with caplog.at_level(logging.DEBUG, logger="custom_components.span_panel"): - log_entity_summary(mock_coordinator, mock_config_entry_with_options) - - assert "Solar synthetic sensors: 3" in caplog.text - - def test_log_entity_summary_with_solar_disabled(self, mock_coordinator, mock_config_entry, caplog): - """Test entity summary with solar disabled.""" - # Lazy imports to avoid collection issues - from custom_components.span_panel.entity_summary import log_entity_summary - - with caplog.at_level(logging.DEBUG, logger="custom_components.span_panel"): - log_entity_summary(mock_coordinator, mock_config_entry) - - assert "Solar synthetic sensors: 0 (solar disabled)" in caplog.text - - def test_log_entity_summary_non_controllable_circuits(self, mock_coordinator, mock_config_entry, caplog): - """Test that non-controllable circuits are properly identified and logged.""" - # Lazy imports to avoid collection issues - from custom_components.span_panel.entity_summary import log_entity_summary - - with caplog.at_level(logging.INFO, logger="custom_components.span_panel"): - log_entity_summary(mock_coordinator, mock_config_entry) - - # Should show the 2 non-controllable circuits - assert "Non-controllable circuits:" in caplog.text - assert "Circuit 9 (ID: circuit_9)" in caplog.text - assert "Circuit 10 (ID: circuit_10)" in caplog.text - - def test_log_entity_summary_all_controllable_circuits(self, mock_config_entry, caplog): - """Test entity summary when all circuits are controllable.""" - # Create coordinator with all controllable circuits - # Lazy imports to avoid collection issues - from custom_components.span_panel.coordinator import SpanPanelCoordinator - from custom_components.span_panel.span_panel import SpanPanel - from custom_components.span_panel.span_panel_circuit import SpanPanelCircuit - - coordinator = MagicMock(spec=SpanPanelCoordinator) - span_panel = MagicMock(spec=SpanPanel) - - circuits = {} - for i in range(1, 6): # 5 circuits, all controllable - circuit = MagicMock(spec=SpanPanelCircuit) - circuit.circuit_id = f"circuit_{i}" - circuit.name = f"Circuit {i}" - circuit.is_user_controllable = True - circuits[f"circuit_{i}"] = circuit - - span_panel.circuits = circuits - coordinator.data = span_panel - - # Lazy imports to avoid collection issues - from custom_components.span_panel.entity_summary import log_entity_summary - - with caplog.at_level(logging.INFO, logger="custom_components.span_panel"): - log_entity_summary(coordinator, mock_config_entry) - - assert "Non-controllable circuits: None" in caplog.text - - def test_log_entity_summary_no_circuits(self, mock_config_entry, caplog): - """Test entity summary with no circuits.""" - # Lazy imports to avoid collection issues - from custom_components.span_panel.coordinator import SpanPanelCoordinator - from custom_components.span_panel.span_panel import SpanPanel - - coordinator = MagicMock(spec=SpanPanelCoordinator) - span_panel = MagicMock(spec=SpanPanel) - span_panel.circuits = {} - coordinator.data = span_panel - - # Lazy imports to avoid collection issues - from custom_components.span_panel.entity_summary import log_entity_summary - - with caplog.at_level(logging.DEBUG, logger="custom_components.span_panel"): - log_entity_summary(coordinator, mock_config_entry) - - assert "Total circuits: 0 (0 controllable, 0 non-controllable)" in caplog.text - assert "Circuit synthetic sensors: 0" in caplog.text - - def test_log_entity_summary_sensor_counts(self, mock_coordinator, mock_config_entry_with_options, caplog): - """Test that sensor counts are calculated correctly.""" - # Lazy imports to avoid collection issues - from custom_components.span_panel.entity_summary import log_entity_summary - - with caplog.at_level(logging.DEBUG, logger="custom_components.span_panel"): - log_entity_summary(mock_coordinator, mock_config_entry_with_options) - - # With 10 circuits, battery enabled, solar enabled - # Unmapped sensors: 10 circuits * 3 sensors per circuit = 30 - assert "Unmapped circuit sensors: 30" in caplog.text - - # Circuit synthetic sensors: 10 circuits * 3 sensors = 30 - assert "Circuit synthetic sensors: 30" in caplog.text - - # Panel synthetic sensors: 6 (fixed) - assert "Panel synthetic sensors: 6" in caplog.text - - # Solar synthetic sensors: 3 (when enabled) - assert "Solar synthetic sensors: 3" in caplog.text - - # Battery sensors: 1 (when enabled) - assert "Battery sensors: 1" in caplog.text - - # Circuit switches: 8 (only controllable circuits) - assert "Circuit switches: 8 (controllable circuits only)" in caplog.text - - # Circuit selects: 8 (only controllable circuits) - assert "Circuit selects: 8 (controllable circuits only)" in caplog.text - - def test_log_entity_summary_total_entity_calculation(self, mock_coordinator, mock_config_entry_with_options, caplog): - """Test that total entity count is calculated correctly.""" - # Lazy imports to avoid collection issues - from custom_components.span_panel.entity_summary import log_entity_summary - - with caplog.at_level(logging.DEBUG, logger="custom_components.span_panel"): - log_entity_summary(mock_coordinator, mock_config_entry_with_options) - - # Check the calculation is shown - assert "Total entities:" in caplog.text - assert "sensors +" in caplog.text - assert "switches +" in caplog.text - assert "selects =" in caplog.text - - def test_log_entity_summary_logger_level_detection(self, mock_coordinator, mock_config_entry): - """Test that the function correctly detects logger levels.""" - with patch('logging.getLogger') as mock_get_logger: - mock_logger = MagicMock() - mock_get_logger.return_value = mock_logger - - # Test debug level enabled - mock_logger.isEnabledFor.side_effect = lambda level: level == logging.DEBUG - - # Lazy imports to avoid collection issues - from custom_components.span_panel.entity_summary import log_entity_summary - - log_entity_summary(mock_coordinator, mock_config_entry) - - # Should check for both DEBUG and INFO levels - mock_logger.isEnabledFor.assert_any_call(logging.DEBUG) - mock_logger.isEnabledFor.assert_any_call(logging.INFO) - - def test_log_entity_summary_uses_correct_log_function(self, mock_coordinator, mock_config_entry): - """Test that the correct log function is used based on level.""" - with patch('logging.getLogger') as mock_get_logger: - mock_logger = MagicMock() - mock_get_logger.return_value = mock_logger - - # Test debug level enabled - mock_logger.isEnabledFor.side_effect = lambda level: level == logging.DEBUG - - # Lazy imports to avoid collection issues - from custom_components.span_panel.entity_summary import log_entity_summary - - log_entity_summary(mock_coordinator, mock_config_entry) - - # Should use debug function - assert mock_logger.debug.called - - # Reset and test info level only - mock_logger.reset_mock() - mock_logger.isEnabledFor.side_effect = lambda level: level == logging.INFO and level != logging.DEBUG - - log_entity_summary(mock_coordinator, mock_config_entry) - - # Should use info function for main logs - assert mock_logger.info.called - - -class TestEntitySummaryEdgeCases: - """Test edge cases for entity summary.""" - - def test_log_entity_summary_with_none_options(self, caplog): - """Test entity summary when config entry options is None.""" - # Lazy imports to avoid collection issues - from custom_components.span_panel.coordinator import SpanPanelCoordinator - from custom_components.span_panel.span_panel import SpanPanel - - coordinator = MagicMock(spec=SpanPanelCoordinator) - span_panel = MagicMock(spec=SpanPanel) - span_panel.circuits = {} - coordinator.data = span_panel - - config_entry = MagicMock(spec=ConfigEntry) - config_entry.options = {} # Empty dict instead of None to avoid AttributeError - - # Lazy imports to avoid collection issues - from custom_components.span_panel.entity_summary import log_entity_summary - - with caplog.at_level(logging.DEBUG, logger="custom_components.span_panel"): - # Should not crash when options is empty - log_entity_summary(coordinator, config_entry) - - # Should default to disabled options - assert "Battery sensors: 0 (battery disabled)" in caplog.text - assert "Solar synthetic sensors: 0 (solar disabled)" in caplog.text - - def test_log_entity_summary_missing_option_keys(self, caplog): - """Test entity summary when specific option keys are missing.""" - # Lazy imports to avoid collection issues - from custom_components.span_panel.coordinator import SpanPanelCoordinator - from custom_components.span_panel.span_panel import SpanPanel - - coordinator = MagicMock(spec=SpanPanelCoordinator) - span_panel = MagicMock(spec=SpanPanel) - span_panel.circuits = {} - coordinator.data = span_panel - - config_entry = MagicMock(spec=ConfigEntry) - config_entry.options = {} # Empty dict, missing keys - - # Lazy imports to avoid collection issues - from custom_components.span_panel.entity_summary import log_entity_summary - - with caplog.at_level(logging.DEBUG, logger="custom_components.span_panel"): - log_entity_summary(coordinator, config_entry) - - # Should default to False for missing keys - assert "Battery sensors: 0 (battery disabled)" in caplog.text - assert "Solar synthetic sensors: 0 (solar disabled)" in caplog.text - - def test_log_entity_summary_circuit_without_attributes(self, caplog): - """Test entity summary with circuits missing expected attributes.""" - # Lazy imports to avoid collection issues - from custom_components.span_panel.coordinator import SpanPanelCoordinator - from custom_components.span_panel.span_panel import SpanPanel - - coordinator = MagicMock(spec=SpanPanelCoordinator) - span_panel = MagicMock(spec=SpanPanel) - - # Create circuit with minimal attributes - circuit = MagicMock() - circuit.circuit_id = "test_circuit" - circuit.name = "Test Circuit" - # Missing is_user_controllable - should handle gracefully - - span_panel.circuits = {"test_circuit": circuit} - coordinator.data = span_panel - - config_entry = MagicMock(spec=ConfigEntry) - config_entry.options = {} - - # Lazy imports to avoid collection issues - from custom_components.span_panel.entity_summary import log_entity_summary - - with caplog.at_level(logging.DEBUG, logger="custom_components.span_panel"): - # Should handle missing attributes gracefully - log_entity_summary(coordinator, config_entry) - - assert "=== SPAN PANEL ENTITY SUMMARY ===" in caplog.text diff --git a/tests/test_error_handling.py b/tests/test_error_handling.py deleted file mode 100644 index 0f1ed68e..00000000 --- a/tests/test_error_handling.py +++ /dev/null @@ -1,164 +0,0 @@ -"""test_error_handling. - -Tests for error handling scenarios in the Span Panel integration. -""" - -from typing import Any -from unittest.mock import AsyncMock, MagicMock, patch - -import aiohttp -from homeassistant.config_entries import ConfigEntryState -import pytest - -from tests.factories import SpanPanelApiResponseFactory -from tests.helpers import ( - patch_span_panel_dependencies, - setup_span_panel_entry, - trigger_coordinator_update, -) - - -@pytest.fixture(autouse=True) -def expected_lingering_timers(): - """Fix expected lingering timers for tests.""" - return True - - -@pytest.mark.asyncio -async def test_api_connection_timeout_during_setup(hass: Any, enable_custom_integrations: Any): - """Test that setup fails gracefully when API connection times out.""" - entry, _ = setup_span_panel_entry(hass) - - # Mock API to raise timeout - with patch("custom_components.span_panel.SpanPanel") as mock_span_panel_class: - mock_span_panel = AsyncMock() - mock_span_panel.update.side_effect = aiohttp.ClientTimeout() - - # Mock the status object with proper serial_number and other required fields - mock_status = MagicMock() - mock_status.serial_number = "test_serial_123" - mock_status.model = "Span Panel" - mock_status.firmware_version = "1.0.0" - mock_span_panel.status = mock_status - mock_span_panel.host = "192.168.1.100" - - # Mock circuits to return empty dict (not a coroutine) - mock_span_panel.circuits = {} - - mock_span_panel_class.return_value = mock_span_panel - - # Setup should succeed despite timeout (timeout happens during coordinator update, not setup) - result = await hass.config_entries.async_setup(entry.entry_id) - assert result is True - assert entry.state == ConfigEntryState.LOADED - - -@pytest.mark.asyncio -async def test_api_connection_refused_during_setup(hass: Any, enable_custom_integrations: Any): - """Test that setup fails gracefully when API connection is refused.""" - entry, _ = setup_span_panel_entry(hass) - - # Mock API to raise connection error - with patch("custom_components.span_panel.SpanPanel") as mock_span_panel_class: - mock_span_panel = AsyncMock() - mock_span_panel.update.side_effect = aiohttp.ClientError("Connection refused") - - # Mock the status object with proper serial_number and other required fields - mock_status = MagicMock() - mock_status.serial_number = "test_serial_123" - mock_status.model = "Span Panel" - mock_status.firmware_version = "1.0.0" - mock_span_panel.status = mock_status - mock_span_panel.host = "192.168.1.100" - - # Mock circuits to return empty dict (not a coroutine) - mock_span_panel.circuits = {} - - mock_span_panel_class.return_value = mock_span_panel - - # Setup should succeed despite connection error (error happens during coordinator update, not setup) - result = await hass.config_entries.async_setup(entry.entry_id) - assert result is True - assert entry.state == ConfigEntryState.LOADED - - -@pytest.mark.asyncio -async def test_coordinator_update_api_failure(hass: Any, enable_custom_integrations: Any): - """Test coordinator behavior when API calls fail during updates.""" - mock_responses = SpanPanelApiResponseFactory.create_complete_panel_response() - entry, _ = setup_span_panel_entry(hass, mock_responses) - - with patch_span_panel_dependencies(mock_responses) as (mock_panel, mock_api): - # Setup integration successfully first - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - coordinator = hass.data["span_panel"][entry.entry_id]["coordinator"] - - # Make API calls fail on next update - mock_api.get_panel_data.side_effect = aiohttp.ClientError("API Error") - mock_api.get_circuits_data.side_effect = aiohttp.ClientError("API Error") - - # Trigger update - should handle errors gracefully - await trigger_coordinator_update(coordinator) - - # Coordinator should still be available but may show as unavailable - assert coordinator is not None - - -@pytest.mark.asyncio -async def test_invalid_authentication_handling(hass: Any, enable_custom_integrations: Any): - """Test handling of authentication failures.""" - entry, _ = setup_span_panel_entry(hass) - - with patch("custom_components.span_panel.SpanPanel") as mock_span_panel_class: - mock_span_panel = AsyncMock() - # Use a simpler approach - just raise a general client error for auth issues - mock_span_panel.update.side_effect = aiohttp.ClientError("401: Unauthorized") - - # Mock the status object with proper serial_number and other required fields - mock_status = MagicMock() - mock_status.serial_number = "test_serial_123" - mock_status.model = "Span Panel" - mock_status.firmware_version = "1.0.0" - mock_span_panel.status = mock_status - mock_span_panel.host = "192.168.1.100" - - # Mock circuits to return empty dict (not a coroutine) - mock_span_panel.circuits = {} - - mock_span_panel_class.return_value = mock_span_panel - - # Setup should succeed despite auth error (error happens during coordinator update, not setup) - result = await hass.config_entries.async_setup(entry.entry_id) - assert result is True - - -@pytest.mark.asyncio -async def test_network_disconnection_recovery(hass: Any, enable_custom_integrations: Any): - """Test recovery behavior after network disconnection.""" - mock_responses = SpanPanelApiResponseFactory.create_complete_panel_response() - entry, _ = setup_span_panel_entry(hass, mock_responses) - - with patch_span_panel_dependencies(mock_responses) as (mock_panel, mock_api): - # Setup successfully - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - coordinator = hass.data["span_panel"][entry.entry_id]["coordinator"] - - # Simulate network disconnection - mock_api.get_panel_data.side_effect = aiohttp.ClientError("Network unreachable") - - # Update should fail - await trigger_coordinator_update(coordinator) - - # Simulate network recovery - mock_api.get_panel_data.side_effect = None - mock_api.get_panel_data.return_value = mock_responses["panel"] - - # Update should succeed again - await trigger_coordinator_update(coordinator) - - # Verify recovery - mock_api.get_panel_data.assert_called() diff --git a/tests/test_evse_entities.py b/tests/test_evse_entities.py new file mode 100644 index 00000000..de88028a --- /dev/null +++ b/tests/test_evse_entities.py @@ -0,0 +1,319 @@ +"""Tests for EVSE (EV Charger) entity support. + +Tests cover: +- EVSE sensor creation when snapshot has EVSE data +- No EVSE sensors created when snapshot.evse is empty +- Charger status enum values propagate correctly +- Advertised current sensor reads from snapshot +- Binary sensor "Charging" ON when status == CHARGING +- Binary sensor "EV Connected" ON for connected statuses +- EVSE device_info has correct via_device linking +- Capability detection includes "evse" when EVSE present +- Multiple EVSE devices create separate entity sets +""" + +# ruff: noqa: D102 + +from __future__ import annotations + +from dataclasses import replace + +from custom_components.span_panel.binary_sensor import ( + _EV_CONNECTED_STATUSES, + EVSE_BINARY_SENSORS, +) +from custom_components.span_panel.coordinator import SpanPanelCoordinator +from custom_components.span_panel.helpers import ( + build_evse_unique_id, + detect_capabilities, + has_evse, + resolve_evse_display_suffix, +) +from custom_components.span_panel.sensor_definitions import EVSE_SENSORS +from custom_components.span_panel.util import evse_device_info + +from .factories import ( + SpanCircuitSnapshotFactory, + SpanEvseSnapshotFactory, + SpanPanelSnapshotFactory, +) + + +class TestEvseDetection: + """Test EVSE capability detection.""" + + def test_has_evse_returns_true_when_evse_present(self): + evse = SpanEvseSnapshotFactory.create() + snapshot = SpanPanelSnapshotFactory.create(evse={"evse-0": evse}) + assert has_evse(snapshot) is True + + def test_has_evse_returns_false_when_empty(self): + snapshot = SpanPanelSnapshotFactory.create() + assert has_evse(snapshot) is False + + def test_capability_detection_includes_evse(self): + evse = SpanEvseSnapshotFactory.create() + snapshot = SpanPanelSnapshotFactory.create(evse={"evse-0": evse}) + caps = detect_capabilities(snapshot) + assert "evse" in caps + + def test_capability_detection_includes_evse_via_circuit_device_type(self): + circuit = SpanCircuitSnapshotFactory.create(circuit_id="ev1", name="EV Charger") + # Use dataclasses.replace to set device_type since it's frozen + circuit_with_evse = replace(circuit, device_type="evse") + snapshot = SpanPanelSnapshotFactory.create( + circuits={"ev1": circuit_with_evse}, + ) + caps = SpanPanelCoordinator._detect_capabilities(snapshot) + assert "evse" in caps + + def test_capability_detection_excludes_evse_when_absent(self): + snapshot = SpanPanelSnapshotFactory.create() + caps = detect_capabilities(snapshot) + assert "evse" not in caps + + +class TestEvseSensorDefinitions: + """Test EVSE sensor definition structure.""" + + def test_evse_sensors_count(self): + assert len(EVSE_SENSORS) == 3 + + def test_evse_status_sensor_is_enum(self): + status_desc = next(d for d in EVSE_SENSORS if d.key == "evse_status") + assert status_desc.device_class is not None + assert status_desc.device_class.value == "enum" + assert status_desc.options == ["unknown"] + + def test_evse_lock_state_sensor_is_enum(self): + lock_desc = next(d for d in EVSE_SENSORS if d.key == "evse_lock_state") + assert lock_desc.device_class is not None + assert lock_desc.device_class.value == "enum" + assert lock_desc.options == ["unknown"] + + def test_evse_advertised_current_is_measurement(self): + current_desc = next( + d for d in EVSE_SENSORS if d.key == "evse_advertised_current" + ) + assert current_desc.native_unit_of_measurement == "A" + assert current_desc.state_class is not None + + def test_evse_status_value_fn(self): + evse = SpanEvseSnapshotFactory.create(status="CHARGING") + status_desc = next(d for d in EVSE_SENSORS if d.key == "evse_status") + assert status_desc.value_fn(evse) == "CHARGING" + + def test_evse_lock_state_value_fn(self): + evse = SpanEvseSnapshotFactory.create(lock_state="LOCKED") + lock_desc = next(d for d in EVSE_SENSORS if d.key == "evse_lock_state") + assert lock_desc.value_fn(evse) == "LOCKED" + + def test_evse_advertised_current_value_fn(self): + evse = SpanEvseSnapshotFactory.create(advertised_current_a=32.0) + current_desc = next( + d for d in EVSE_SENSORS if d.key == "evse_advertised_current" + ) + assert current_desc.value_fn(evse) == 32.0 + + def test_evse_advertised_current_none(self): + evse = SpanEvseSnapshotFactory.create(advertised_current_a=None) + current_desc = next( + d for d in EVSE_SENSORS if d.key == "evse_advertised_current" + ) + assert current_desc.value_fn(evse) is None + + +class TestEvseBinarySensorDefinitions: + """Test EVSE binary sensor definition structure.""" + + def test_evse_binary_sensors_count(self): + assert len(EVSE_BINARY_SENSORS) == 2 + + def test_charging_binary_sensor_on_when_charging(self): + evse = SpanEvseSnapshotFactory.create(status="CHARGING") + charging_desc = next(d for d in EVSE_BINARY_SENSORS if d.key == "evse_charging") + assert charging_desc.value_fn(evse) is True + + def test_charging_binary_sensor_off_when_available(self): + evse = SpanEvseSnapshotFactory.create(status="AVAILABLE") + charging_desc = next(d for d in EVSE_BINARY_SENSORS if d.key == "evse_charging") + assert charging_desc.value_fn(evse) is False + + def test_charging_binary_sensor_off_when_preparing(self): + evse = SpanEvseSnapshotFactory.create(status="PREPARING") + charging_desc = next(d for d in EVSE_BINARY_SENSORS if d.key == "evse_charging") + assert charging_desc.value_fn(evse) is False + + def test_ev_connected_on_for_connected_statuses(self): + connected_desc = next( + d for d in EVSE_BINARY_SENSORS if d.key == "evse_ev_connected" + ) + for status in _EV_CONNECTED_STATUSES: + evse = SpanEvseSnapshotFactory.create(status=status) + assert connected_desc.value_fn(evse) is True, ( + f"Expected True for status={status}" + ) + + def test_ev_connected_off_for_disconnected_statuses(self): + connected_desc = next( + d for d in EVSE_BINARY_SENSORS if d.key == "evse_ev_connected" + ) + for status in ("AVAILABLE", "UNKNOWN", "FAULTED", "UNAVAILABLE", "RESERVED"): + evse = SpanEvseSnapshotFactory.create(status=status) + assert connected_desc.value_fn(evse) is False, ( + f"Expected False for status={status}" + ) + + +class TestEvseDeviceInfo: + """Test EVSE DeviceInfo construction.""" + + def test_evse_device_info_full_metadata(self): + evse = SpanEvseSnapshotFactory.create( + node_id="evse-0", + vendor_name="SPAN", + product_name="SPAN Drive", + serial_number="SN123", + software_version="2.0.0", + ) + info = evse_device_info( + "panel-serial", evse, "Main House", display_suffix="Garage" + ) + identifiers = info.get("identifiers") + assert identifiers is not None + assert ("span_panel", "panel-serial_evse_evse-0") in identifiers + assert info.get("name") == "Main House SPAN Drive (Garage)" + assert info.get("manufacturer") == "SPAN" + assert info.get("model") == "SPAN Drive" + assert info.get("serial_number") == "SN123" + assert info.get("sw_version") == "2.0.0" + via = info.get("via_device") + assert via == ("span_panel", "panel-serial") + + def test_evse_device_info_fallback_names(self): + evse = SpanEvseSnapshotFactory.create( + vendor_name=None, + product_name=None, + ) + info = evse_device_info("panel-serial", evse, "Span Panel", display_suffix=None) + assert info.get("name") == "Span Panel EV Charger" + assert info.get("manufacturer") == "SPAN" + assert info.get("model") == "SPAN Drive" + + def test_evse_device_info_serial_suffix(self): + evse = SpanEvseSnapshotFactory.create( + product_name="SPAN Drive", + serial_number="SN-EVSE-001", + ) + info = evse_device_info( + "panel-serial", evse, "Museum Garage", display_suffix="SN-EVSE-001" + ) + assert info.get("name") == "Museum Garage SPAN Drive (SN-EVSE-001)" + + def test_evse_device_info_no_serial(self): + evse = SpanEvseSnapshotFactory.create(serial_number=None) + info = evse_device_info("panel-serial", evse, "Span Panel") + assert info.get("serial_number") is None + + +class TestEvseStatusOptions: + """Test EVSE enum options seed with 'unknown' only.""" + + def test_status_options_seed_with_unknown(self): + status_desc = next(d for d in EVSE_SENSORS if d.key == "evse_status") + assert status_desc.options == ["unknown"] + + def test_lock_state_options_seed_with_unknown(self): + lock_desc = next(d for d in EVSE_SENSORS if d.key == "evse_lock_state") + assert lock_desc.options == ["unknown"] + + +class TestEvseMultipleDevices: + """Test multiple EVSE device handling.""" + + def test_multiple_evse_in_snapshot(self): + evse_a = SpanEvseSnapshotFactory.create( + node_id="evse-0", status="CHARGING", feed_circuit_id="c1" + ) + evse_b = SpanEvseSnapshotFactory.create( + node_id="evse-1", status="AVAILABLE", feed_circuit_id="c2" + ) + snapshot = SpanPanelSnapshotFactory.create( + evse={"evse-0": evse_a, "evse-1": evse_b} + ) + assert len(snapshot.evse) == 2 + assert snapshot.evse["evse-0"].status == "CHARGING" + assert snapshot.evse["evse-1"].status == "AVAILABLE" + + def test_multiple_evse_device_infos_are_distinct(self): + evse_a = SpanEvseSnapshotFactory.create(node_id="evse-0") + evse_b = SpanEvseSnapshotFactory.create(node_id="evse-1") + info_a = evse_device_info("panel", evse_a, "Span Panel") + info_b = evse_device_info("panel", evse_b, "Span Panel") + assert info_a.get("identifiers") != info_b.get("identifiers") + + +class TestEvseSnapshotFactory: + """Test the EVSE snapshot factory itself.""" + + def test_default_factory_values(self): + evse = SpanEvseSnapshotFactory.create() + assert evse.node_id == "evse-0" + assert evse.status == "CHARGING" + assert evse.lock_state == "LOCKED" + assert evse.advertised_current_a == 32.0 + assert evse.vendor_name == "SPAN" + assert evse.product_name == "SPAN Drive" + + def test_available_factory(self): + evse = SpanEvseSnapshotFactory.create_available() + assert evse.status == "AVAILABLE" + assert evse.lock_state == "UNLOCKED" + + +class TestEvseDisplaySuffix: + """Test resolve_evse_display_suffix helper.""" + + def test_friendly_names_uses_circuit_name(self): + circuit = SpanCircuitSnapshotFactory.create(circuit_id="c1", name="Garage") + evse = SpanEvseSnapshotFactory.create(feed_circuit_id="c1") + snapshot = SpanPanelSnapshotFactory.create( + circuits={"c1": circuit}, evse={"evse-0": evse} + ) + result = resolve_evse_display_suffix(evse, snapshot, use_circuit_numbers=False) + assert result == "Garage" + + def test_circuit_numbers_uses_serial(self): + evse = SpanEvseSnapshotFactory.create(serial_number="SN-001") + snapshot = SpanPanelSnapshotFactory.create(evse={"evse-0": evse}) + result = resolve_evse_display_suffix(evse, snapshot, use_circuit_numbers=True) + assert result == "SN-001" + + def test_friendly_names_no_circuit_name_returns_none(self): + circuit = SpanCircuitSnapshotFactory.create(circuit_id="c1", name="") + evse = SpanEvseSnapshotFactory.create(feed_circuit_id="c1") + snapshot = SpanPanelSnapshotFactory.create( + circuits={"c1": circuit}, evse={"evse-0": evse} + ) + result = resolve_evse_display_suffix(evse, snapshot, use_circuit_numbers=False) + assert result is None + + def test_friendly_names_no_circuit_returns_none(self): + evse = SpanEvseSnapshotFactory.create(feed_circuit_id="nonexistent") + snapshot = SpanPanelSnapshotFactory.create(evse={"evse-0": evse}) + result = resolve_evse_display_suffix(evse, snapshot, use_circuit_numbers=False) + assert result is None + + def test_circuit_numbers_no_serial_returns_none(self): + evse = SpanEvseSnapshotFactory.create(serial_number=None) + snapshot = SpanPanelSnapshotFactory.create(evse={"evse-0": evse}) + result = resolve_evse_display_suffix(evse, snapshot, use_circuit_numbers=True) + assert result is None + + +class TestEvseUniqueIdHelpers: + """Test EVSE unique ID helper functions.""" + + def test_build_evse_unique_id(self): + result = build_evse_unique_id("serial", "evse-0", "evse_status") + assert result == "span_serial_evse_evse-0_evse_status" diff --git a/tests/test_factories/span_panel_simulation_factory.py b/tests/test_factories/span_panel_simulation_factory.py deleted file mode 100644 index 4492dbf8..00000000 --- a/tests/test_factories/span_panel_simulation_factory.py +++ /dev/null @@ -1,417 +0,0 @@ -"""SPAN Panel Simulation Factory for realistic test data generation. - -This factory leverages the span-panel-api simulation mode with YAML configurations -to generate realistic SPAN panel data that exactly matches what the integration expects, -using actual SPAN panel response structures. -""" - -import asyncio -from pathlib import Path -from typing import Any - -from span_panel_api import SpanPanelClient -import yaml - - -class SpanPanelSimulationFactory: - """Factory for creating simulation-based SPAN panel data using YAML configurations.""" - - @classmethod - async def _get_config_path(cls, config_name: str = "simulation_config_32_circuit") -> str: - """Get path to a simulation configuration file. - - Args: - config_name: Name of the config file (without .yaml extension) - - Returns: - Full path to the configuration file - - """ - # Look for config in the integration's simulation_configs directory - current_file = Path(__file__) - integration_root = current_file.parent.parent.parent / "custom_components" / "span_panel" - config_path = integration_root / "simulation_configs" / f"{config_name}.yaml" - - if await asyncio.to_thread(config_path.exists): - return str(config_path) - - # Fallback: look in span-panel-api examples - span_api_examples = current_file.parent.parent.parent.parent / "span-panel-api" / "examples" - fallback_path = span_api_examples / f"{config_name}.yaml" - - if await asyncio.to_thread(fallback_path.exists): - return str(fallback_path) - - raise FileNotFoundError(f"Could not find simulation config: {config_name}.yaml") - - @classmethod - async def create_simulation_client( - cls, - host: str = "test-panel-001", - config_name: str = "simulation_config_32_circuit", - **kwargs: Any - ) -> SpanPanelClient: - """Create a simulation client with YAML-based realistic data. - - Args: - host: Host identifier (becomes serial number in simulation mode) - config_name: Name of the YAML config file to use - **kwargs: Additional client configuration parameters - - Returns: - SpanPanelClient configured for simulation mode with YAML config - - """ - config_path = await cls._get_config_path(config_name) - - return SpanPanelClient( - host=host, - simulation_mode=True, - simulation_config_path=config_path, - **kwargs - ) - - @classmethod - async def get_realistic_panel_data( - cls, - host: str = "test-panel-001", - config_name: str = "simulation_config_32_circuit", - circuit_overrides: dict[str, dict] | None = None, - global_overrides: dict[str, Any] | None = None, - ) -> dict[str, Any]: - """Get panel data using YAML-based simulation mode. - - Args: - host: Host identifier for the simulated panel - config_name: Name of the YAML config file to use - circuit_overrides: Per-circuit overrides to apply dynamically - global_overrides: Global overrides (e.g., power_multiplier) - - Returns: - Dictionary containing all panel data types the integration needs - - """ - client = await cls.create_simulation_client(host=host, config_name=config_name) - async with client: - # Apply any dynamic overrides if specified - if circuit_overrides or global_overrides: - await client.set_circuit_overrides( - circuit_overrides=circuit_overrides or {}, - global_overrides=global_overrides or {} - ) - - # Get all data types the integration needs - circuits = await client.get_circuits() - panel_state = await client.get_panel_state() - status = await client.get_status() - storage = await client.get_storage_soe() - - return { - 'circuits': circuits, - 'panel_state': panel_state, - 'status': status, - 'storage': storage - } - - @classmethod - async def get_realistic_circuits_only( - cls, - host: str = "test-circuits-001", - config_name: str = "simulation_config_32_circuit", - circuit_overrides: dict[str, dict] | None = None, - global_overrides: dict[str, Any] | None = None, - ) -> dict[str, Any]: - """Get only circuits data for tests that don't need full panel state. - - Args: - host: Host identifier for the simulated panel - config_name: Name of the YAML config file to use - circuit_overrides: Per-circuit overrides to apply dynamically - global_overrides: Global overrides (e.g., power_multiplier) - - Returns: - CircuitsOut object from simulation - - """ - client = await cls.create_simulation_client(host=host, config_name=config_name) - async with client: - # Apply any dynamic overrides if specified - if circuit_overrides or global_overrides: - await client.set_circuit_overrides( - circuit_overrides=circuit_overrides or {}, - global_overrides=global_overrides or {} - ) - - circuits = await client.get_circuits() - return dict(circuits) if circuits else {} - - @classmethod - def get_preset_scenarios(cls) -> dict[str, dict[str, Any]]: - """Get predefined simulation scenarios for common test cases. - - Returns: - Dictionary of scenario names to simulation parameters - - """ - return { - "normal_operation": { - "config_name": "simulation_config_32_circuit", - "global_overrides": {} - }, - "high_load": { - "config_name": "simulation_config_32_circuit", - "global_overrides": {"power_multiplier": 1.5}, - "circuit_overrides": { - "ev_charger_garage": { - "power_override": 11000.0, # Max EV charging - "relay_state": "CLOSED" - } - } - }, - "circuit_failures": { - "config_name": "simulation_config_32_circuit", - "circuit_overrides": { - "living_room_outlets": {"relay_state": "OPEN"}, - "office_outlets": {"relay_state": "OPEN"} - } - }, - "low_power_stable": { - "config_name": "simple_test_config", # Use simpler config - "global_overrides": {"power_multiplier": 0.3} - }, - "solar_peak": { - "config_name": "simulation_config_32_circuit", - "circuit_overrides": { - "solar_inverter_main": { - "power_override": -8000.0, # Peak solar production - "relay_state": "CLOSED" - } - } - }, - "grid_stress": { - "config_name": "simulation_config_32_circuit", - "global_overrides": {"power_multiplier": 2.0}, - "circuit_overrides": { - "main_hvac": {"relay_state": "OPEN"}, # Load shedding - "heat_pump_backup": {"relay_state": "OPEN"} - } - } - } - - @classmethod - async def get_panel_data_for_scenario(cls, scenario_name: str) -> dict[str, Any]: - """Get panel data for a predefined scenario. - - Args: - scenario_name: Name of the scenario from get_preset_scenarios() - - Returns: - Panel data configured for the specified scenario - - Raises: - ValueError: If scenario_name is not found - - """ - scenarios = cls.get_preset_scenarios() - if scenario_name not in scenarios: - available = ", ".join(scenarios.keys()) - raise ValueError(f"Unknown scenario '{scenario_name}'. Available: {available}") - - scenario_config = scenarios[scenario_name] - return await cls.get_realistic_panel_data(**scenario_config) - - @classmethod - async def get_real_circuit_ids(cls, config_name: str = "simulation_config_32_circuit") -> dict[str, str]: - """Get the actual circuit IDs from YAML simulation config with their names. - - Args: - config_name: Name of the YAML config file to use - - Returns: - Dictionary mapping circuit IDs to their friendly names - - """ - client = await cls.create_simulation_client(config_name=config_name) - async with client: - circuits = await client.get_circuits() - return { - circuit_id: circuit.name - for circuit_id, circuit in circuits.circuits.additional_properties.items() - } - - @classmethod - async def get_circuit_ids_by_type(cls, config_name: str = "simulation_config_32_circuit") -> dict[str, list[str]]: - """Get circuit IDs grouped by appliance type for targeted testing. - - Args: - config_name: Name of the YAML config file to use - - Returns: - Dictionary mapping appliance types to lists of circuit IDs - - """ - # Get all circuits dynamically from the YAML config - circuit_ids = await cls.get_real_circuit_ids(config_name) - - # Categorize circuits based on their names - categorized: dict[str, list[str]] = { - "lights": [], - "ev_chargers": [], - "hvac": [], - "appliances": [], - "outlets": [], - "solar": [], - "pool": [], - "essential": [] - } - - for circuit_id, name in circuit_ids.items(): - name_lower = name.lower() - - # Categorize based on circuit names from YAML config - if "light" in name_lower: - categorized["lights"].append(circuit_id) - elif "ev" in name_lower or "charger" in name_lower: - categorized["ev_chargers"].append(circuit_id) - elif any(term in name_lower for term in ["hvac", "heat pump"]): - categorized["hvac"].append(circuit_id) - elif any(term in name_lower for term in ["dishwasher", "dryer", "microwave", "oven", "refrigerator", "washing"]): - categorized["appliances"].append(circuit_id) - elif "outlet" in name_lower: - categorized["outlets"].append(circuit_id) - elif "solar" in name_lower or "inverter" in name_lower: - categorized["solar"].append(circuit_id) - elif "pool" in name_lower: - categorized["pool"].append(circuit_id) - elif any(term in name_lower for term in ["master", "bedroom", "kitchen", "bathroom"]): - categorized["essential"].append(circuit_id) - else: - # Default to essential for unrecognized circuits - categorized["essential"].append(circuit_id) - - return categorized - - @classmethod - async def find_circuit_ids_by_name( - cls, - name_patterns: str | list[str], - config_name: str = "simulation_config_32_circuit" - ) -> list[str]: - """Find circuit IDs by name patterns. - - Args: - name_patterns: String or list of strings to search for in circuit names (case-insensitive) - config_name: Name of the YAML config file to use - - Returns: - List of circuit IDs matching the patterns - - """ - if isinstance(name_patterns, str): - name_patterns = [name_patterns] - - circuit_ids = await cls.get_real_circuit_ids(config_name) - matching_ids = [] - - for circuit_id, name in circuit_ids.items(): - name_lower = name.lower() - if any(pattern.lower() in name_lower for pattern in name_patterns): - matching_ids.append(circuit_id) - - return matching_ids - - @classmethod - async def get_circuit_details(cls, config_name: str = "simulation_config_32_circuit") -> dict[str, dict[str, Any]]: - """Get detailed information about all circuits from YAML simulation. - - Args: - config_name: Name of the YAML config file to use - - Returns: - Dictionary mapping circuit IDs to their full circuit data - - """ - client = await cls.create_simulation_client(config_name=config_name) - async with client: - circuits = await client.get_circuits() - return { - circuit_id: { - "id": circuit.id, - "name": circuit.name, - "relay_state": circuit.relay_state, - "instant_power_w": circuit.instant_power_w, - "produced_energy_wh": circuit.produced_energy_wh, - "consumed_energy_wh": circuit.consumed_energy_wh, - "tabs": circuit.tabs, - "priority": circuit.priority, - "is_user_controllable": circuit.is_user_controllable, - "is_sheddable": circuit.is_sheddable, - "is_never_backup": circuit.is_never_backup, - } - for circuit_id, circuit in circuits.circuits.additional_properties.items() - } - - @classmethod - async def get_available_configs(cls) -> list[str]: - """Get list of available YAML configuration files. - - Returns: - List of config names (without .yaml extension) - - """ - configs = [] - - # Check integration configs first (this is the primary location) - try: - current_file = Path(__file__) - integration_root = current_file.parent.parent.parent / "custom_components" / "span_panel" - config_dir = integration_root / "simulation_configs" - - if await asyncio.to_thread(config_dir.exists): - files = await asyncio.to_thread(lambda: list(config_dir.glob("*.yaml"))) - for file in files: - configs.append(file.stem) - except Exception: - pass - - # Check span-panel-api examples as fallback - try: - current_file = Path(__file__) - span_api_examples = current_file.parent.parent.parent.parent / "span-panel-api" / "examples" - - if await asyncio.to_thread(span_api_examples.exists): - files = await asyncio.to_thread(lambda: list(span_api_examples.glob("*.yaml"))) - for file in files: - if file.stem not in configs: # Avoid duplicates - configs.append(file.stem) - except Exception: - pass - - return sorted(configs) - - @classmethod - def get_available_configs_with_names(cls) -> dict[str, str]: - """Get available configs with user-friendly display names. - - Returns: - Dictionary mapping config keys to display names - - """ - # Use the same logic as the config flow - from custom_components.span_panel.config_flow import get_available_simulation_configs - return get_available_simulation_configs() - - @staticmethod - def extract_serial_number_from_yaml(yaml_path: str) -> str: - """Extract the serial number from a YAML simulation config file. - - Args: - yaml_path: Path to the YAML configuration file - - Returns: - Serial number from the config file - - """ - content = Path(yaml_path).read_text(encoding="utf-8") - data = yaml.safe_load(content) - return str(data["global_settings"]["device_identifier"]) diff --git a/tests/test_factory.py b/tests/test_factory.py index 9ab53ad2..79b11428 100644 --- a/tests/test_factory.py +++ b/tests/test_factory.py @@ -1,158 +1,93 @@ -"""Tests for the factory classes and their use of constants.""" - +"""Tests for the factory classes and snapshot types.""" from custom_components.span_panel.binary_sensor import BINARY_SENSORS from custom_components.span_panel.const import ( - CURRENT_RUN_CONFIG, - DSM_GRID_STATE, - DSM_GRID_UP, DSM_ON_GRID, - DSM_STATE, - MAIN_RELAY_STATE, PANEL_ON_GRID, - SYSTEM_CELLULAR_LINK, SYSTEM_DOOR_STATE_CLOSED, SYSTEM_DOOR_STATE_OPEN, - SYSTEM_ETHERNET_LINK, - SYSTEM_WIFI_LINK, -) -from custom_components.span_panel.span_panel_hardware_status import ( - SpanPanelHardwareStatus, ) from tests.factories import ( - SpanPanelApiResponseFactory, - SpanPanelDataFactory, - SpanPanelStatusFactory, + SpanBatterySnapshotFactory, + SpanCircuitSnapshotFactory, + SpanPanelSnapshotFactory, ) -def test_panel_factory_uses_correct_constants(): - """Test that panel factory uses the correct constant keys.""" - panel_data = SpanPanelDataFactory.create_on_grid_panel_data() +def test_panel_factory_creates_correct_defaults(): + """Test that panel factory creates snapshot with correct default values.""" + snapshot = SpanPanelSnapshotFactory.create() - # Verify that the factory uses the constant keys - assert CURRENT_RUN_CONFIG in panel_data - assert DSM_GRID_STATE in panel_data - assert DSM_STATE in panel_data - assert MAIN_RELAY_STATE in panel_data + assert snapshot.current_run_config == PANEL_ON_GRID + assert snapshot.dsm_state == DSM_ON_GRID + assert snapshot.main_relay_state == "CLOSED" + assert snapshot.serial_number == "sp3-242424-001" + assert snapshot.firmware_version == "1.2.3" - # Verify expected values - assert panel_data[CURRENT_RUN_CONFIG] == PANEL_ON_GRID - assert panel_data[DSM_GRID_STATE] == DSM_GRID_UP - assert panel_data[DSM_STATE] == DSM_ON_GRID - assert panel_data[MAIN_RELAY_STATE] == "CLOSED" +def test_panel_factory_on_grid(): + """Test on-grid panel snapshot has correct state.""" + snapshot = SpanPanelSnapshotFactory.create_on_grid() -def test_status_factory_uses_correct_constants(): - """Test that status factory uses the correct constant values.""" - status_data = SpanPanelStatusFactory.create_status() + assert snapshot.current_run_config == PANEL_ON_GRID + assert snapshot.dsm_state == DSM_ON_GRID + assert snapshot.instant_grid_power_w == 1850.5 - # Verify that the factory uses the correct constant values - assert status_data["system"]["doorState"] == SYSTEM_DOOR_STATE_CLOSED - # Verify network link constants are used as keys - assert SYSTEM_ETHERNET_LINK in status_data["network"] - assert SYSTEM_WIFI_LINK in status_data["network"] - assert SYSTEM_CELLULAR_LINK in status_data["network"] +def test_status_factory_network_defaults(): + """Test that snapshot has correct network connectivity defaults.""" + snapshot = SpanPanelSnapshotFactory.create() - # Verify expected structure for API compatibility - assert "software" in status_data - assert "firmwareVersion" in status_data["software"] - assert "system" in status_data - assert "network" in status_data - - # Verify default network values - assert status_data["network"][SYSTEM_ETHERNET_LINK] is True - assert status_data["network"][SYSTEM_WIFI_LINK] is True - assert status_data["network"][SYSTEM_CELLULAR_LINK] is False + assert snapshot.door_state == SYSTEM_DOOR_STATE_CLOSED + assert snapshot.eth0_link is True + assert snapshot.wlan_link is True + assert snapshot.wwan_link is False def test_status_factory_network_configuration(): - """Test that status factory can create different network configurations.""" - # Test with all connections disabled - status_offline = SpanPanelStatusFactory.create_status( - ethernet_link=False, - wifi_link=False, - cellular_link=False, + """Test that snapshot can be created with different network configurations.""" + snapshot_offline = SpanPanelSnapshotFactory.create( + eth0_link=False, + wlan_link=False, + wwan_link=False, ) - assert status_offline["network"][SYSTEM_ETHERNET_LINK] is False - assert status_offline["network"][SYSTEM_WIFI_LINK] is False - assert status_offline["network"][SYSTEM_CELLULAR_LINK] is False - - # Test with only cellular enabled - status_cellular = SpanPanelStatusFactory.create_status( - ethernet_link=False, - wifi_link=False, - cellular_link=True, - ) - - assert status_cellular["network"][SYSTEM_ETHERNET_LINK] is False - assert status_cellular["network"][SYSTEM_WIFI_LINK] is False - assert status_cellular["network"][SYSTEM_CELLULAR_LINK] is True - + assert snapshot_offline.eth0_link is False + assert snapshot_offline.wlan_link is False + assert snapshot_offline.wwan_link is False -def test_status_factory_integration_with_hardware_status(): - """Test that status factory data works correctly with SpanPanelHardwareStatus.""" - # Test with mixed network connectivity - status_data = SpanPanelStatusFactory.create_status( - ethernet_link=True, - wifi_link=False, - cellular_link=True, - software_version="2.5.1", - serial_number="TEST123456789", + snapshot_cellular = SpanPanelSnapshotFactory.create( + eth0_link=False, + wlan_link=False, + wwan_link=True, ) - # Create actual SpanPanelHardwareStatus object - hardware_status = SpanPanelHardwareStatus.from_dict(status_data) - - # Verify that network constants are properly mapped to boolean properties - assert hardware_status.is_ethernet_connected is True - assert hardware_status.is_wifi_connected is False - assert hardware_status.is_cellular_connected is True - - # Verify other properties work as expected - assert hardware_status.firmware_version == "2.5.1" - assert hardware_status.serial_number == "TEST123456789" - assert hardware_status.door_state == SYSTEM_DOOR_STATE_CLOSED + assert snapshot_cellular.eth0_link is False + assert snapshot_cellular.wlan_link is False + assert snapshot_cellular.wwan_link is True def test_door_state_tamper_sensor_logic(): """Test that door state works correctly as a tamper sensor.""" - # Test door CLOSED (tamper sensor should be OFF/clear) - status_closed = SpanPanelStatusFactory.create_status(door_state=SYSTEM_DOOR_STATE_CLOSED) - hardware_status_closed = SpanPanelHardwareStatus.from_dict(status_closed) - - assert hardware_status_closed.door_state == SYSTEM_DOOR_STATE_CLOSED - assert hardware_status_closed.is_door_closed is True - # Tamper sensor logic: not is_door_closed -> not True -> False (clear/OFF) - tamper_sensor_value_closed = not hardware_status_closed.is_door_closed - assert tamper_sensor_value_closed is False # Tamper clear when door closed - - # Test door OPEN (tamper sensor should be ON/detected) - status_open = SpanPanelStatusFactory.create_status(door_state=SYSTEM_DOOR_STATE_OPEN) - hardware_status_open = SpanPanelHardwareStatus.from_dict(status_open) + # Door CLOSED -> tamper clear + snapshot_closed = SpanPanelSnapshotFactory.create(door_state=SYSTEM_DOOR_STATE_CLOSED) + assert snapshot_closed.door_state == SYSTEM_DOOR_STATE_CLOSED + tamper_closed = snapshot_closed.door_state != SYSTEM_DOOR_STATE_CLOSED + assert tamper_closed is False - assert hardware_status_open.door_state == SYSTEM_DOOR_STATE_OPEN - assert hardware_status_open.is_door_closed is False - # Tamper sensor logic: not is_door_closed -> not False -> True (tampered/ON) - tamper_sensor_value_open = not hardware_status_open.is_door_closed - assert tamper_sensor_value_open is True # Tamper detected when door open + # Door OPEN -> tamper detected + snapshot_open = SpanPanelSnapshotFactory.create(door_state=SYSTEM_DOOR_STATE_OPEN) + assert snapshot_open.door_state == SYSTEM_DOOR_STATE_OPEN + tamper_open = snapshot_open.door_state != SYSTEM_DOOR_STATE_CLOSED + assert tamper_open is True - # Test unknown door state (tamper sensor should be unavailable) - status_unknown = SpanPanelStatusFactory.create_status(door_state="UNKNOWN") - hardware_status_unknown = SpanPanelHardwareStatus.from_dict(status_unknown) - - assert hardware_status_unknown.door_state == "UNKNOWN" - assert hardware_status_unknown.is_door_closed is None - # When is_door_closed is None, the binary sensor should be unavailable - # (This matches the binary sensor logic that checks for None) + # UNKNOWN door state + snapshot_unknown = SpanPanelSnapshotFactory.create(door_state="UNKNOWN") + assert snapshot_unknown.door_state == "UNKNOWN" def test_door_state_binary_sensor_availability(): """Test that door state binary sensor handles availability correctly.""" - - # Find the door state sensor description door_sensor = None for sensor in BINARY_SENSORS: if sensor.key == "doorState": @@ -163,50 +98,57 @@ def test_door_state_binary_sensor_availability(): assert door_sensor.device_class is not None assert door_sensor.device_class.value == "tamper" - # Test the actual value_fn logic used by the binary sensor - - # Test with door closed - should return False (tamper clear) - status_closed = SpanPanelStatusFactory.create_status(door_state=SYSTEM_DOOR_STATE_CLOSED) - hardware_closed = SpanPanelHardwareStatus.from_dict(status_closed) - sensor_value_closed = door_sensor.value_fn(hardware_closed) - assert sensor_value_closed is False # Tamper clear + # Door closed -> tamper clear (False) + snapshot_closed = SpanPanelSnapshotFactory.create(door_state=SYSTEM_DOOR_STATE_CLOSED) + assert door_sensor.value_fn(snapshot_closed) is False - # Test with door open - should return True (tamper detected) - status_open = SpanPanelStatusFactory.create_status(door_state=SYSTEM_DOOR_STATE_OPEN) - hardware_open = SpanPanelHardwareStatus.from_dict(status_open) - sensor_value_open = door_sensor.value_fn(hardware_open) - assert sensor_value_open is True # Tamper detected + # Door open -> tamper detected (True) + snapshot_open = SpanPanelSnapshotFactory.create(door_state=SYSTEM_DOOR_STATE_OPEN) + assert door_sensor.value_fn(snapshot_open) is True - # Test with unknown state - should return None (unavailable) - status_unknown = SpanPanelStatusFactory.create_status(door_state="UNKNOWN") - hardware_unknown = SpanPanelHardwareStatus.from_dict(status_unknown) - sensor_value_unknown = door_sensor.value_fn(hardware_unknown) - assert sensor_value_unknown is None # Unavailable + # Unknown state -> unavailable (None) + snapshot_unknown = SpanPanelSnapshotFactory.create(door_state="UNKNOWN") + assert door_sensor.value_fn(snapshot_unknown) is None def test_complete_response_factory_structure(): - """Test that the complete response factory creates the expected structure.""" - response = SpanPanelApiResponseFactory.create_complete_panel_response() - - # Verify top-level structure - assert "circuits" in response - assert "panel" in response - assert "status" in response - assert "battery" in response - - # Verify panel data uses constants - panel_data = response["panel"] - assert CURRENT_RUN_CONFIG in panel_data - assert DSM_GRID_STATE in panel_data - assert DSM_STATE in panel_data - assert MAIN_RELAY_STATE in panel_data - - # Verify status data uses constants and correct structure - status_data = response["status"] - assert status_data["system"]["doorState"] == SYSTEM_DOOR_STATE_CLOSED - assert "firmwareVersion" in status_data["software"] # API field, not constant - - # Verify network data uses constants as keys - assert SYSTEM_ETHERNET_LINK in status_data["network"] - assert SYSTEM_WIFI_LINK in status_data["network"] - assert SYSTEM_CELLULAR_LINK in status_data["network"] + """Test that the complete factory creates expected snapshot structure.""" + snapshot = SpanPanelSnapshotFactory.create_complete() + + assert snapshot.serial_number == "sp3-242424-001" + assert len(snapshot.circuits) == 3 + assert snapshot.battery.soe_percentage == 85.0 + + # Verify panel data + assert snapshot.current_run_config == PANEL_ON_GRID + assert snapshot.dsm_state == DSM_ON_GRID + assert snapshot.main_relay_state == "CLOSED" + + # Verify status data + assert snapshot.door_state == SYSTEM_DOOR_STATE_CLOSED + assert snapshot.firmware_version == "1.2.3" + assert snapshot.eth0_link is True + assert snapshot.wlan_link is True + assert snapshot.wwan_link is False + + +def test_circuit_factory_defaults(): + """Test circuit factory creates correct defaults.""" + circuit = SpanCircuitSnapshotFactory.create() + + assert circuit.circuit_id == "1" + assert circuit.name == "Test Circuit" + assert circuit.relay_state == "CLOSED" + assert circuit.instant_power_w == 150.5 + assert circuit.consumed_energy_wh == 1500.0 + assert circuit.produced_energy_wh == 0.0 + assert circuit.is_user_controllable is True + assert circuit.tabs == [1] + + +def test_battery_factory_defaults(): + """Test battery factory creates correct defaults.""" + battery = SpanBatterySnapshotFactory.create() + + assert battery.soe_percentage == 85.0 + assert battery.soe_kwh is None diff --git a/tests/test_favorites_service.py b/tests/test_favorites_service.py new file mode 100644 index 00000000..74681487 --- /dev/null +++ b/tests/test_favorites_service.py @@ -0,0 +1,600 @@ +"""Tests for cross-panel favorites storage helpers and services.""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest + +from homeassistant.core import SupportsResponse +from homeassistant.exceptions import ServiceValidationError + +from custom_components.span_panel.const import DOMAIN +from custom_components.span_panel.frontend import ( + async_get_favorites, + async_set_favorite, +) +from custom_components.span_panel.services import _async_register_favorites_services + + +class _FakeStore: + """In-memory stand-in for homeassistant.helpers.storage.Store. + + One shared backing dict keyed by storage key, so multiple Store(...) calls + in the same test see a consistent view of the data. + """ + + _shared_state: dict[str, Any] = {} + + def __init__(self, _hass: Any, _version: int, key: str) -> None: + self._key = key + + async def async_load(self) -> Any: + return _FakeStore._shared_state.get(self._key) + + async def async_save(self, data: Any) -> None: + _FakeStore._shared_state[self._key] = data + + @classmethod + def reset(cls) -> None: + cls._shared_state = {} + + @classmethod + def preload(cls, key: str, data: Any) -> None: + cls._shared_state[key] = data + + +@pytest.fixture(autouse=True) +def _reset_store() -> None: + _FakeStore.reset() + + +@pytest.fixture +def _patched_store() -> Any: + with patch( + "custom_components.span_panel.frontend.Store", + _FakeStore, + ): + yield + + +def _panel_entry(circuits: list[str] | None = None, sub_devices: list[str] | None = None) -> dict[str, list[str]]: + return {"circuits": circuits or [], "sub_devices": sub_devices or []} + + +class TestAsyncGetFavorites: + """Tests for ``async_get_favorites`` helper.""" + + @pytest.mark.asyncio + async def test_empty_storage_returns_empty_dict(self, _patched_store: Any) -> None: + hass = MagicMock() + result = await async_get_favorites(hass) + assert result == {} + + @pytest.mark.asyncio + async def test_returns_stored_favorites_new_shape(self, _patched_store: Any) -> None: + _FakeStore.preload( + "span_panel_settings", + { + "show_panel": True, + "favorites": { + "panel_a": {"circuits": ["c1", "c2"], "sub_devices": ["bess1"]}, + "panel_b": {"circuits": ["c3"], "sub_devices": []}, + }, + }, + ) + hass = MagicMock() + result = await async_get_favorites(hass) + assert result == { + "panel_a": _panel_entry(["c1", "c2"], ["bess1"]), + "panel_b": _panel_entry(["c3"], []), + } + + @pytest.mark.asyncio + async def test_legacy_list_shape_is_circuits_only(self, _patched_store: Any) -> None: + """Pre-existing favorites stored as flat lists migrate to circuits-only entries.""" + _FakeStore.preload( + "span_panel_settings", + {"favorites": {"panel_a": ["c1", "c2"], "panel_b": ["c3"]}}, + ) + hass = MagicMock() + result = await async_get_favorites(hass) + assert result == { + "panel_a": _panel_entry(["c1", "c2"]), + "panel_b": _panel_entry(["c3"]), + } + + @pytest.mark.asyncio + async def test_filters_invalid_shapes(self, _patched_store: Any) -> None: + """Malformed entries (non-str values, missing kinds, empties) are dropped.""" + _FakeStore.preload( + "span_panel_settings", + { + "favorites": { + "panel_a": {"circuits": ["c1", "", 42, "c2"], "sub_devices": "bad"}, + "panel_b": "not-a-dict", + 123: {"circuits": ["c3"]}, # type: ignore[dict-item] + "panel_empty": {"circuits": [], "sub_devices": []}, + } + }, + ) + hass = MagicMock() + result = await async_get_favorites(hass) + assert result == {"panel_a": _panel_entry(["c1", "c2"], [])} + + @pytest.mark.asyncio + async def test_tolerates_missing_favorites_key(self, _patched_store: Any) -> None: + _FakeStore.preload( + "span_panel_settings", {"show_panel": False, "panel_admin_only": True} + ) + hass = MagicMock() + result = await async_get_favorites(hass) + assert result == {} + + +class TestAsyncSetFavorite: + """Tests for ``async_set_favorite`` helper.""" + + @pytest.mark.asyncio + async def test_add_circuit_creates_panel_entry(self, _patched_store: Any) -> None: + hass = MagicMock() + result = await async_set_favorite(hass, "panel_a", "circuits", "c1", True) + assert result == {"panel_a": _panel_entry(["c1"])} + persisted = _FakeStore._shared_state["span_panel_settings"] + assert persisted["favorites"] == {"panel_a": _panel_entry(["c1"])} + + @pytest.mark.asyncio + async def test_add_subdevice_coexists_with_circuits(self, _patched_store: Any) -> None: + hass = MagicMock() + await async_set_favorite(hass, "panel_a", "circuits", "c1", True) + result = await async_set_favorite(hass, "panel_a", "sub_devices", "bess1", True) + assert result == {"panel_a": _panel_entry(["c1"], ["bess1"])} + + @pytest.mark.asyncio + async def test_add_dedupes(self, _patched_store: Any) -> None: + hass = MagicMock() + await async_set_favorite(hass, "panel_a", "circuits", "c1", True) + result = await async_set_favorite(hass, "panel_a", "circuits", "c1", True) + assert result == {"panel_a": _panel_entry(["c1"])} + + @pytest.mark.asyncio + async def test_remove_drops_empty_panel_key(self, _patched_store: Any) -> None: + hass = MagicMock() + await async_set_favorite(hass, "panel_a", "circuits", "c1", True) + result = await async_set_favorite(hass, "panel_a", "circuits", "c1", False) + assert result == {} + persisted = _FakeStore._shared_state["span_panel_settings"] + assert persisted["favorites"] == {} + + @pytest.mark.asyncio + async def test_remove_keeps_other_kind_entries(self, _patched_store: Any) -> None: + hass = MagicMock() + await async_set_favorite(hass, "panel_a", "circuits", "c1", True) + await async_set_favorite(hass, "panel_a", "sub_devices", "bess1", True) + result = await async_set_favorite(hass, "panel_a", "circuits", "c1", False) + assert result == {"panel_a": _panel_entry([], ["bess1"])} + + @pytest.mark.asyncio + async def test_remove_of_unknown_is_noop(self, _patched_store: Any) -> None: + hass = MagicMock() + result = await async_set_favorite(hass, "panel_a", "circuits", "missing", False) + assert result == {} + + @pytest.mark.asyncio + async def test_preserves_sibling_settings(self, _patched_store: Any) -> None: + """Favorites writes must not trample ``show_panel`` or ``panel_admin_only``.""" + _FakeStore.preload( + "span_panel_settings", {"show_panel": False, "panel_admin_only": True} + ) + hass = MagicMock() + await async_set_favorite(hass, "panel_a", "circuits", "c1", True) + persisted = _FakeStore._shared_state["span_panel_settings"] + assert persisted["show_panel"] is False + assert persisted["panel_admin_only"] is True + assert persisted["favorites"] == {"panel_a": _panel_entry(["c1"])} + + @pytest.mark.asyncio + async def test_unknown_kind_raises(self, _patched_store: Any) -> None: + hass = MagicMock() + with pytest.raises(ValueError): + await async_set_favorite(hass, "panel_a", "bogus", "c1", True) + + @pytest.mark.asyncio + async def test_set_migrates_legacy_shape_in_place(self, _patched_store: Any) -> None: + """Touching a panel with legacy ``[uuid]`` storage rewrites it as the canonical dict.""" + _FakeStore.preload( + "span_panel_settings", + {"favorites": {"panel_a": ["c1", "c2"]}}, + ) + hass = MagicMock() + # No new circuit added; the same uuid we already had. + result = await async_set_favorite(hass, "panel_a", "circuits", "c1", True) + assert result == {"panel_a": _panel_entry(["c1", "c2"])} + persisted = _FakeStore._shared_state["span_panel_settings"]["favorites"] + # Persisted shape is now the nested dict, not the legacy list. + assert persisted == {"panel_a": _panel_entry(["c1", "c2"])} + + @pytest.mark.asyncio + async def test_concurrent_set_favorites_does_not_drop_writes( + self, _patched_store: Any + ) -> None: + """Two parallel adds must both end up in storage (lock prevents lost writes).""" + import asyncio + + hass = MagicMock() + # Run both adds concurrently; each call goes through the lock so the + # second write picks up the first's mutation. + results = await asyncio.gather( + async_set_favorite(hass, "panel_a", "circuits", "c1", True), + async_set_favorite(hass, "panel_a", "circuits", "c2", True), + ) + # Both calls return the favorites map at the time they wrote; + # the FINAL persisted state must contain both circuits. + persisted = _FakeStore._shared_state["span_panel_settings"]["favorites"] + assert sorted(persisted["panel_a"]["circuits"]) == ["c1", "c2"] + # Each call's returned map is at least non-empty. + for r in results: + assert "panel_a" in r + + +def _capture_registered_handlers(hass: MagicMock) -> dict[str, Any]: + """Run ``_async_register_favorites_services`` and return a name->handler map.""" + handlers: dict[str, Any] = {} + schemas: dict[str, Any] = {} + responses: dict[str, Any] = {} + + def _register( + domain: str, + service: str, + handler: Any, + schema: Any | None = None, + supports_response: Any = SupportsResponse.NONE, + ) -> None: + assert domain == DOMAIN + handlers[service] = handler + schemas[service] = schema + responses[service] = supports_response + + hass.services = MagicMock() + hass.services.async_register = MagicMock(side_effect=_register) + _async_register_favorites_services(hass) + return {"handlers": handlers, "schemas": schemas, "responses": responses} + + +def _make_service_call(data: dict[str, Any]) -> MagicMock: + call = MagicMock() + call.data = data + return call + + +def _make_entity_entry( + *, + platform: str = DOMAIN, + unique_id: str = "span_sp3-242424_abcdef0123456789abcdef0123456789_power", + device_id: str | None = "d_main", +) -> MagicMock: + entry = MagicMock() + entry.platform = platform + entry.unique_id = unique_id + entry.device_id = device_id + return entry + + +def _make_device_entry( + *, + device_id: str = "d_main", + identifiers: set[tuple[str, str]] | None = None, + via_device_id: str | None = None, +) -> MagicMock: + device = MagicMock() + device.id = device_id + device.identifiers = identifiers if identifiers is not None else {(DOMAIN, "serial_a")} + device.via_device_id = via_device_id + return device + + +def _patch_registries(entity: MagicMock | None, device: MagicMock | None) -> Any: + """Patch services.er.async_get and services.dr.async_get for a single call. + + Either registry returns the given object from ``async_get`` regardless of id. + """ + entity_reg = MagicMock() + entity_reg.async_get = MagicMock(return_value=entity) + device_reg = MagicMock() + device_reg.async_get = MagicMock(return_value=device) + + return patch.multiple( + "custom_components.span_panel.services", + er=MagicMock(async_get=MagicMock(return_value=entity_reg)), + dr=MagicMock(async_get=MagicMock(return_value=device_reg)), + ) + + +def _patch_registries_for_subdevice( + entity: MagicMock, + sub_device: MagicMock, + parent_panel: MagicMock, +) -> Any: + """Stub registries so device_registry.async_get returns sub_device for the + entity's device_id and parent_panel for the via_device_id lookup.""" + entity_reg = MagicMock() + entity_reg.async_get = MagicMock(return_value=entity) + + device_reg = MagicMock() + def _device_lookup(device_id: str) -> MagicMock | None: + if device_id == sub_device.id: + return sub_device + if device_id == parent_panel.id: + return parent_panel + return None + device_reg.async_get = MagicMock(side_effect=_device_lookup) + + return patch.multiple( + "custom_components.span_panel.services", + er=MagicMock(async_get=MagicMock(return_value=entity_reg)), + dr=MagicMock(async_get=MagicMock(return_value=device_reg)), + ) + + +class TestFavoritesServiceHandlers: + """Tests for the ``get_favorites`` / ``add_favorite`` / ``remove_favorite`` service handlers.""" + + @pytest.mark.asyncio + async def test_get_favorites_returns_current_map(self, _patched_store: Any) -> None: + _FakeStore.preload( + "span_panel_settings", + {"favorites": {"panel_a": {"circuits": ["c1"], "sub_devices": []}}}, + ) + hass = MagicMock() + registered = _capture_registered_handlers(hass) + handler = registered["handlers"]["get_favorites"] + + result = await handler(_make_service_call({})) + assert result == { + "favorites": {"panel_a": _panel_entry(["c1"])}, + } + assert registered["responses"]["get_favorites"] is SupportsResponse.ONLY + + @pytest.mark.asyncio + async def test_add_favorite_rejects_unknown_entity( + self, _patched_store: Any + ) -> None: + hass = MagicMock() + registered = _capture_registered_handlers(hass) + handler = registered["handlers"]["add_favorite"] + + with _patch_registries(entity=None, device=None): + with pytest.raises(ServiceValidationError): + await handler(_make_service_call({"entity_id": "sensor.unknown"})) + + @pytest.mark.asyncio + async def test_add_favorite_rejects_non_span_platform( + self, _patched_store: Any + ) -> None: + hass = MagicMock() + registered = _capture_registered_handlers(hass) + handler = registered["handlers"]["add_favorite"] + + foreign_entity = _make_entity_entry(platform="other_domain") + with _patch_registries(entity=foreign_entity, device=None): + with pytest.raises(ServiceValidationError): + await handler(_make_service_call({"entity_id": "sensor.other_power"})) + + @pytest.mark.asyncio + async def test_add_favorite_accepts_sub_device_entity( + self, _patched_store: Any + ) -> None: + hass = MagicMock() + registered = _capture_registered_handlers(hass) + handler = registered["handlers"]["add_favorite"] + + entity = _make_entity_entry( + unique_id="span_sp3-242424_storage_battery_percentage", + device_id="d_bess", + ) + sub_device = _make_device_entry( + device_id="d_bess", + identifiers={(DOMAIN, "serial_a_bess")}, + via_device_id="d_main", + ) + parent = _make_device_entry( + device_id="d_main", + identifiers={(DOMAIN, "serial_a")}, + via_device_id=None, + ) + + with _patch_registries_for_subdevice(entity, sub_device, parent): + result = await handler( + _make_service_call({"entity_id": "sensor.battery_level"}) + ) + + assert result == {"favorites": {"d_main": _panel_entry([], ["d_bess"])}} + + @pytest.mark.asyncio + async def test_add_favorite_on_evse_feed_circuit_sensor_favorites_sub_device( + self, _patched_store: Any + ) -> None: + """EVSE feed-circuit sensors are device-info-attached to the EVSE + sub-device. Even though their unique_id still encodes the underlying + circuit UUID, favoriting one must produce a *sub-device* favorite — + the device card on the dashboard already represents both the EVSE's + status sensors and its feed-circuit power, so a circuit favorite would + duplicate the same physical thing as both a card and a row in the + Favorites view.""" + hass = MagicMock() + registered = _capture_registered_handlers(hass) + handler = registered["handlers"]["add_favorite"] + + circuit_uuid = "abcdef0123456789abcdef0123456789" + entity = _make_entity_entry( + unique_id=f"span_sp3-242424_{circuit_uuid}_power", + device_id="d_evse", + ) + evse_sub_device = _make_device_entry( + device_id="d_evse", + identifiers={(DOMAIN, "serial_a_evse")}, + via_device_id="d_main", + ) + parent = _make_device_entry( + device_id="d_main", + identifiers={(DOMAIN, "serial_a")}, + via_device_id=None, + ) + + with _patch_registries_for_subdevice(entity, evse_sub_device, parent): + result = await handler( + _make_service_call({"entity_id": "sensor.evse_feed_power"}) + ) + + assert result == {"favorites": {"d_main": _panel_entry([], ["d_evse"])}} + + @pytest.mark.asyncio + async def test_add_favorite_rejects_entity_without_uuid_in_unique_id( + self, _patched_store: Any + ) -> None: + hass = MagicMock() + registered = _capture_registered_handlers(hass) + handler = registered["handlers"]["add_favorite"] + + # Panel-level sensor (no circuit uuid segment in unique_id). + entity = _make_entity_entry(unique_id="span_sp3-242424_instantGridPowerW") + device = _make_device_entry() + + with _patch_registries(entity=entity, device=device): + with pytest.raises(ServiceValidationError): + await handler(_make_service_call({"entity_id": "sensor.panel_power"})) + + @pytest.mark.asyncio + async def test_add_favorite_persists_and_returns_map( + self, _patched_store: Any + ) -> None: + hass = MagicMock() + registered = _capture_registered_handlers(hass) + handler = registered["handlers"]["add_favorite"] + + circuit_uuid = "abcdef0123456789abcdef0123456789" + entity = _make_entity_entry( + unique_id=f"span_sp3-242424_{circuit_uuid}_power", + device_id="d_main", + ) + device = _make_device_entry(device_id="d_main") + + with _patch_registries(entity=entity, device=device): + result = await handler( + _make_service_call({"entity_id": "sensor.kitchen_power"}) + ) + + assert result == {"favorites": {"d_main": _panel_entry([circuit_uuid])}} + assert _FakeStore._shared_state["span_panel_settings"]["favorites"] == { + "d_main": _panel_entry([circuit_uuid]) + } + + @pytest.mark.asyncio + async def test_remove_favorite_resolves_via_entity_registry( + self, _patched_store: Any + ) -> None: + circuit_uuid = "abcdef0123456789abcdef0123456789" + _FakeStore.preload( + "span_panel_settings", + {"favorites": {"d_main": _panel_entry([circuit_uuid])}}, + ) + hass = MagicMock() + registered = _capture_registered_handlers(hass) + handler = registered["handlers"]["remove_favorite"] + + entity = _make_entity_entry( + unique_id=f"span_sp3-242424_{circuit_uuid}_power", + device_id="d_main", + ) + device = _make_device_entry(device_id="d_main") + + with _patch_registries(entity=entity, device=device): + result = await handler( + _make_service_call({"entity_id": "sensor.kitchen_power"}) + ) + + assert result == {"favorites": {}} + + def test_mutation_responses_are_optional(self, _patched_store: Any) -> None: + hass = MagicMock() + registered = _capture_registered_handlers(hass) + assert registered["responses"]["add_favorite"] is SupportsResponse.OPTIONAL + assert registered["responses"]["remove_favorite"] is SupportsResponse.OPTIONAL + + @pytest.mark.asyncio + async def test_add_favorite_rejects_entity_with_no_device_id( + self, _patched_store: Any + ) -> None: + """Entities with no ``device_id`` are not favoritable.""" + hass = MagicMock() + registered = _capture_registered_handlers(hass) + handler = registered["handlers"]["add_favorite"] + + orphan = _make_entity_entry(device_id=None) + with _patch_registries(entity=orphan, device=None): + with pytest.raises(ServiceValidationError): + await handler(_make_service_call({"entity_id": "sensor.orphan"})) + + @pytest.mark.asyncio + async def test_add_favorite_rejects_subdevice_with_non_span_parent( + self, _patched_store: Any + ) -> None: + """Sub-device whose ``via_device_id`` does NOT point at a SPAN panel.""" + hass = MagicMock() + registered = _capture_registered_handlers(hass) + handler = registered["handlers"]["add_favorite"] + + entity = _make_entity_entry(device_id="d_sub") + sub_device = _make_device_entry( + device_id="d_sub", + identifiers={(DOMAIN, "serial_a_bess")}, + via_device_id="d_foreign", + ) + # Parent exists but isn't a SPAN device (different domain identifier). + foreign_parent = _make_device_entry( + device_id="d_foreign", + identifiers={("other_domain", "xyz")}, + via_device_id=None, + ) + + with _patch_registries_for_subdevice(entity, sub_device, foreign_parent): + with pytest.raises(ServiceValidationError): + await handler( + _make_service_call({"entity_id": "sensor.bess_under_foreign"}) + ) + + @pytest.mark.asyncio + async def test_add_favorite_rejects_subdevice_with_missing_parent( + self, _patched_store: Any + ) -> None: + """Sub-device whose ``via_device_id`` references a missing device.""" + hass = MagicMock() + registered = _capture_registered_handlers(hass) + handler = registered["handlers"]["add_favorite"] + + entity = _make_entity_entry(device_id="d_sub") + sub_device = _make_device_entry( + device_id="d_sub", + identifiers={(DOMAIN, "serial_a_bess")}, + via_device_id="d_missing", + ) + + # Stub a registry where the parent lookup returns None. + entity_reg = MagicMock() + entity_reg.async_get = MagicMock(return_value=entity) + + device_reg = MagicMock() + def _device_lookup(device_id: str) -> MagicMock | None: + return sub_device if device_id == "d_sub" else None + device_reg.async_get = MagicMock(side_effect=_device_lookup) + + with patch.multiple( + "custom_components.span_panel.services", + er=MagicMock(async_get=MagicMock(return_value=entity_reg)), + dr=MagicMock(async_get=MagicMock(return_value=device_reg)), + ): + with pytest.raises(ServiceValidationError): + await handler( + _make_service_call({"entity_id": "sensor.bess_orphaned"}) + ) diff --git a/tests/test_global_monitoring_service.py b/tests/test_global_monitoring_service.py new file mode 100644 index 00000000..846fce44 --- /dev/null +++ b/tests/test_global_monitoring_service.py @@ -0,0 +1,55 @@ +"""Tests for set_global_monitoring service schema.""" + +import pytest +import voluptuous as vol + + +class TestSetGlobalMonitoringSchema: + """Tests for service schema validation.""" + + def test_schema_accepts_valid_input(self): + """Service schema accepts valid global monitoring input.""" + from custom_components.span_panel.__init__ import ( + _build_set_global_monitoring_schema, + ) + + schema = _build_set_global_monitoring_schema() + result = schema({ + "continuous_threshold_pct": 75, + "spike_threshold_pct": 95, + "window_duration_m": 20, + "cooldown_duration_m": 30, + "notify_targets": "notify.mobile_app, event_bus", + }) + assert result["continuous_threshold_pct"] == 75 + + def test_schema_accepts_partial_input(self): + """Service schema accepts partial input (only some fields).""" + from custom_components.span_panel.__init__ import ( + _build_set_global_monitoring_schema, + ) + + schema = _build_set_global_monitoring_schema() + result = schema({"continuous_threshold_pct": 70}) + assert result["continuous_threshold_pct"] == 70 + assert "spike_threshold_pct" not in result + + def test_schema_rejects_out_of_range(self): + """Schema rejects values outside allowed range.""" + from custom_components.span_panel.__init__ import ( + _build_set_global_monitoring_schema, + ) + + schema = _build_set_global_monitoring_schema() + with pytest.raises(vol.MultipleInvalid): + schema({"continuous_threshold_pct": 0}) + + def test_schema_accepts_empty(self): + """Schema accepts empty dict (no-op update).""" + from custom_components.span_panel.__init__ import ( + _build_set_global_monitoring_schema, + ) + + schema = _build_set_global_monitoring_schema() + result = schema({}) + assert result == {} diff --git a/tests/test_grace_period_option.py b/tests/test_grace_period_option.py index 892c01f5..1ed784cf 100644 --- a/tests/test_grace_period_option.py +++ b/tests/test_grace_period_option.py @@ -1,9 +1,11 @@ """Test grace period configuration option.""" +from unittest.mock import MagicMock + import pytest import voluptuous as vol -from custom_components.span_panel.config_flow import OPTIONS_SCHEMA +from custom_components.span_panel.config_flow_options import GENERAL_OPTIONS_SCHEMA from custom_components.span_panel.options import ENERGY_REPORTING_GRACE_PERIOD @@ -17,10 +19,10 @@ def test_grace_period_option_constant(self): def test_grace_period_in_options_schema(self): """Test that grace period option is in the options schema.""" # Check that energy_reporting_grace_period is in the schema - assert ENERGY_REPORTING_GRACE_PERIOD in OPTIONS_SCHEMA.schema + assert ENERGY_REPORTING_GRACE_PERIOD in GENERAL_OPTIONS_SCHEMA.schema # Check that it has proper validation (int, 0-60 range) - grace_period_validator = OPTIONS_SCHEMA.schema[ENERGY_REPORTING_GRACE_PERIOD] + grace_period_validator = GENERAL_OPTIONS_SCHEMA.schema[ENERGY_REPORTING_GRACE_PERIOD] # Test valid values assert grace_period_validator(0) == 0 @@ -39,22 +41,20 @@ def test_grace_period_in_options_schema(self): def test_grace_period_option_persistence(self): """Test that grace period option persists in configuration.""" - from unittest.mock import MagicMock - # Mock coordinator with grace period option mock_coordinator = MagicMock() mock_coordinator.config_entry = MagicMock() - mock_coordinator.config_entry.options = { - ENERGY_REPORTING_GRACE_PERIOD: 30 - } + mock_coordinator.config_entry.options = {ENERGY_REPORTING_GRACE_PERIOD: 30} # Test that option is accessible - grace_period = mock_coordinator.config_entry.options.get(ENERGY_REPORTING_GRACE_PERIOD, 15) + grace_period = mock_coordinator.config_entry.options.get( + ENERGY_REPORTING_GRACE_PERIOD, 15 + ) assert grace_period == 30 def test_grace_period_edge_cases(self): """Test grace period edge cases.""" - grace_period_validator = OPTIONS_SCHEMA.schema[ENERGY_REPORTING_GRACE_PERIOD] + grace_period_validator = GENERAL_OPTIONS_SCHEMA.schema[ENERGY_REPORTING_GRACE_PERIOD] # Test boundary values assert grace_period_validator(0) == 0 # Immediate unavailable @@ -69,17 +69,15 @@ def test_grace_period_edge_cases(self): def test_grace_period_integration_with_yaml_generation(self): """Test that grace period integrates correctly with YAML generation.""" - from unittest.mock import MagicMock - # Test that the grace period gets passed to YAML templates mock_coordinator = MagicMock() mock_coordinator.config_entry = MagicMock() - mock_coordinator.config_entry.options = { - ENERGY_REPORTING_GRACE_PERIOD: 25 - } + mock_coordinator.config_entry.options = {ENERGY_REPORTING_GRACE_PERIOD: 25} # Simulate how the option is used in template generation - grace_period = str(mock_coordinator.config_entry.options.get(ENERGY_REPORTING_GRACE_PERIOD, 15)) + grace_period = str( + mock_coordinator.config_entry.options.get(ENERGY_REPORTING_GRACE_PERIOD, 15) + ) # Should be converted to string for template placeholders assert grace_period == "25" diff --git a/tests/test_grace_period_restoration.py b/tests/test_grace_period_restoration.py index 14b68890..9617e354 100644 --- a/tests/test_grace_period_restoration.py +++ b/tests/test_grace_period_restoration.py @@ -1,9 +1,16 @@ """Tests for grace period state restoration across HA restarts.""" -from datetime import datetime, timedelta +# ruff: noqa: D102, D107 -from custom_components.span_panel.sensors.base import ( +from datetime import UTC, datetime, timedelta +from types import SimpleNamespace + +from homeassistant.components.sensor import SensorStateClass +from custom_components.span_panel.const import ENABLE_ENERGY_DIP_COMPENSATION +from custom_components.span_panel.options import ENERGY_REPORTING_GRACE_PERIOD +from custom_components.span_panel.sensor_base import ( SpanEnergyExtraStoredData, + SpanEnergySensorBase, ) @@ -27,6 +34,9 @@ def test_as_dict_with_all_values(self): "native_unit_of_measurement": "Wh", "last_valid_state": 1234.56, "last_valid_changed": "2025-11-29T12:00:00", + "energy_offset": None, + "last_panel_reading": None, + "last_dip_delta": None, } def test_as_dict_with_none_values(self): @@ -45,6 +55,9 @@ def test_as_dict_with_none_values(self): "native_unit_of_measurement": None, "last_valid_state": None, "last_valid_changed": None, + "energy_offset": None, + "last_panel_reading": None, + "last_dip_delta": None, } def test_from_dict_with_all_values(self): @@ -124,7 +137,9 @@ def test_roundtrip_serialization(self): assert restored is not None assert restored.native_value == original.native_value - assert restored.native_unit_of_measurement == original.native_unit_of_measurement + assert ( + restored.native_unit_of_measurement == original.native_unit_of_measurement + ) assert restored.last_valid_state == original.last_valid_state assert restored.last_valid_changed == original.last_valid_changed @@ -166,10 +181,10 @@ def test_grace_period_calculation_within_period(self): # - Panel is offline # Expected: Should use last valid state - last_valid_changed = datetime.now() - timedelta(minutes=10) + last_valid_changed = datetime.now(tz=UTC) - timedelta(minutes=10) grace_period_minutes = 15 - time_since_last_valid = datetime.now() - last_valid_changed + time_since_last_valid = datetime.now(tz=UTC) - last_valid_changed grace_period_duration = timedelta(minutes=grace_period_minutes) is_within_grace = time_since_last_valid <= grace_period_duration @@ -184,10 +199,10 @@ def test_grace_period_calculation_expired(self): # - Panel is offline # Expected: Should report None (unknown) - last_valid_changed = datetime.now() - timedelta(minutes=20) + last_valid_changed = datetime.now(tz=UTC) - timedelta(minutes=20) grace_period_minutes = 15 - time_since_last_valid = datetime.now() - last_valid_changed + time_since_last_valid = datetime.now(tz=UTC) - last_valid_changed grace_period_duration = timedelta(minutes=grace_period_minutes) is_within_grace = time_since_last_valid <= grace_period_duration @@ -197,22 +212,28 @@ def test_grace_period_calculation_expired(self): def test_grace_period_edge_case_exactly_at_limit(self): """Test grace period at exactly the limit.""" # Grace period of 15 minutes, exactly 15 minutes ago - last_valid_changed = datetime.now() - timedelta(minutes=15) + last_valid_changed = datetime.now(tz=UTC) - timedelta(minutes=15) grace_period_minutes = 15 - time_since_last_valid = datetime.now() - last_valid_changed + time_since_last_valid = datetime.now(tz=UTC) - last_valid_changed grace_period_duration = timedelta(minutes=grace_period_minutes) # At exactly the limit, should still be within grace period (<= comparison) # Allow small timing difference - assert abs(time_since_last_valid.total_seconds() - grace_period_duration.total_seconds()) < 1 + assert ( + abs( + time_since_last_valid.total_seconds() + - grace_period_duration.total_seconds() + ) + < 1 + ) def test_grace_period_zero_disabled(self): """Test that grace period of 0 means no grace period.""" - last_valid_changed = datetime.now() - timedelta(seconds=1) + last_valid_changed = datetime.now(tz=UTC) - timedelta(seconds=1) grace_period_minutes = 0 - time_since_last_valid = datetime.now() - last_valid_changed + time_since_last_valid = datetime.now(tz=UTC) - last_valid_changed grace_period_duration = timedelta(minutes=grace_period_minutes) is_within_grace = time_since_last_valid <= grace_period_duration @@ -223,10 +244,10 @@ def test_grace_period_zero_disabled(self): def test_grace_period_maximum_60_minutes(self): """Test grace period with maximum 60 minute setting.""" # 59 minutes ago with 60 minute grace period - should still be valid - last_valid_changed = datetime.now() - timedelta(minutes=59) + last_valid_changed = datetime.now(tz=UTC) - timedelta(minutes=59) grace_period_minutes = 60 - time_since_last_valid = datetime.now() - last_valid_changed + time_since_last_valid = datetime.now(tz=UTC) - last_valid_changed grace_period_duration = timedelta(minutes=grace_period_minutes) is_within_grace = time_since_last_valid <= grace_period_duration @@ -234,8 +255,8 @@ def test_grace_period_maximum_60_minutes(self): assert is_within_grace is True # 61 minutes ago with 60 minute grace period - should be expired - last_valid_changed_expired = datetime.now() - timedelta(minutes=61) - time_since_expired = datetime.now() - last_valid_changed_expired + last_valid_changed_expired = datetime.now(tz=UTC) - timedelta(minutes=61) + time_since_expired = datetime.now(tz=UTC) - last_valid_changed_expired is_within_grace_expired = time_since_expired <= grace_period_duration @@ -256,7 +277,7 @@ def test_restoration_after_brief_restart(self): # 6. Grace period: 15 minutes # Expected: Should restore and use last_valid_state - original_last_valid_changed = datetime.now() - timedelta(minutes=7) + original_last_valid_changed = datetime.now(tz=UTC) - timedelta(minutes=7) grace_period_minutes = 15 stored_last_valid_state = 1000.0 @@ -275,7 +296,7 @@ def test_restoration_after_brief_restart(self): restored_timestamp = datetime.fromisoformat(restored.last_valid_changed) # Check if still within grace period - time_since_last_valid = datetime.now() - restored_timestamp + time_since_last_valid = datetime.now(tz=UTC) - restored_timestamp grace_period_duration = timedelta(minutes=grace_period_minutes) assert time_since_last_valid <= grace_period_duration @@ -292,7 +313,7 @@ def test_restoration_after_long_restart(self): # 6. Grace period: 60 minutes (max) # Expected: Grace period expired, should report unknown - original_last_valid_changed = datetime.now() - timedelta(minutes=65) + original_last_valid_changed = datetime.now(tz=UTC) - timedelta(minutes=65) grace_period_minutes = 60 stored_last_valid_state = 2000.0 @@ -311,7 +332,7 @@ def test_restoration_after_long_restart(self): restored_timestamp = datetime.fromisoformat(restored.last_valid_changed) # Check if still within grace period - time_since_last_valid = datetime.now() - restored_timestamp + time_since_last_valid = datetime.now(tz=UTC) - restored_timestamp grace_period_duration = timedelta(minutes=grace_period_minutes) # Should be OUTSIDE grace period @@ -326,7 +347,7 @@ def test_restoration_panel_comes_back_online(self): # 4. Panel comes back online - normal update takes over stored_last_valid_state = 5000.0 - stored_timestamp = datetime.now() - timedelta(minutes=5) + stored_timestamp = datetime.now(tz=UTC) - timedelta(minutes=5) stored_data = SpanEnergyExtraStoredData( native_value=stored_last_valid_state, @@ -345,3 +366,168 @@ def test_restoration_panel_comes_back_online(self): # The sensor should update to use the new value from the panel # (This is handled by the sensor's normal update logic, not restoration) assert new_panel_value > stored_last_valid_state # Energy should increase + + +class DummyEnergySensor(SpanEnergySensorBase): + """Minimal concrete energy sensor for offline grace period tests.""" + + def __init__( # pylint: disable=super-init-not-called + self, grace_minutes: int | str = 15 + ) -> None: + # Bypass parent __init__ to avoid full HA dependencies for unit testing + self.coordinator = SimpleNamespace( + panel_offline=True, + config_entry=SimpleNamespace( + options={ + ENERGY_REPORTING_GRACE_PERIOD: grace_minutes, + ENABLE_ENERGY_DIP_COMPENSATION: False, + }, + ), + data=SimpleNamespace(), + ) + self.entity_description = SimpleNamespace( + device_class="energy", + state_class=SensorStateClass.TOTAL_INCREASING, + key="dummy", + value_fn=lambda _: self._mock_panel_value, + ) + self._attr_native_value = None + self._mock_panel_value = None + self._last_valid_state = None + self._last_valid_changed = None + self._grace_period_minutes = grace_minutes + self._previous_circuit_name = None + self._attr_unique_id = "dummy" + self._attr_name = "Dummy" + self._restored_from_storage: bool = False + + # Energy dip compensation state (disabled for grace period tests) + self._energy_offset: float = 0.0 + self._last_panel_reading: float | None = None + self._last_dip_delta: float | None = None + self._is_total_increasing: bool = True + self._dip_compensation_enabled: bool = False + + def _generate_unique_id(self, snapshot, description): + return "dummy" + + def _generate_friendly_name(self, snapshot, description): + return "dummy" + + def get_data_source(self, snapshot): + return "dummy_data" + + +class TestGracePeriodFallback: + """Tests for grace period fallback behavior when panel is offline.""" + + def test_offline_uses_restored_native_value_when_missing_last_valid(self): + """Ensure last known value is reused when grace metadata is absent.""" + + sensor = DummyEnergySensor() + sensor._attr_native_value = 123.0 + + sensor._handle_offline_grace_period() + + assert sensor._attr_native_value == 123.0 + assert sensor._last_valid_state == 123.0 + assert sensor._last_valid_changed is not None + + def test_offline_grace_expires_after_duration(self): + """Verify values drop to unknown after grace period expiration.""" + + sensor = DummyEnergySensor(grace_minutes=5) + sensor._last_valid_state = 10.0 + sensor._last_valid_changed = datetime.now(tz=UTC) - timedelta(minutes=10) + + sensor._handle_offline_grace_period() + + assert sensor._attr_native_value is None + + def test_grace_period_coerces_string_option(self): + """String grace period option is coerced to int for calculations.""" + + sensor = DummyEnergySensor(grace_minutes="15") + sensor._last_valid_state = 50.0 + sensor._last_valid_changed = datetime.now(tz=UTC) - timedelta(minutes=1) + + sensor._handle_offline_grace_period() + + assert sensor._attr_native_value == 50.0 + + +class TestMonotonicValidation: + """Tests for value tracking of total_increasing sensors.""" + + def test_accepts_decreasing_value(self): + """Ensure a lower value is accepted (firmware reset scenario).""" + sensor = DummyEnergySensor() + # Simulate online state + sensor.coordinator.panel_offline = False + + # Initial valid state + sensor._mock_panel_value = 1000.0 + sensor._update_native_value() # Should accept and set _last_valid_state + + assert sensor._last_valid_state == 1000.0 + + # Update with LOWER value (simulates firmware reset) + sensor._mock_panel_value = 900.0 + sensor._update_native_value() + + # Should accept 900 (decreasing values no longer blocked) + assert sensor._attr_native_value == 900.0 + assert sensor._last_valid_state == 900.0 + + def test_accepts_increasing_value(self): + """Ensure a higher value is accepted.""" + sensor = DummyEnergySensor() + sensor.coordinator.panel_offline = False + + # Initial valid state + sensor._mock_panel_value = 1000.0 + sensor._update_native_value() + + # Update with HIGHER value + sensor._mock_panel_value = 1100.0 + sensor._update_native_value() + + # Should accept 1100 + assert sensor._attr_native_value == 1100.0 + assert sensor._last_valid_state == 1100.0 + + def test_accepts_equal_value(self): + """Ensure an equal value is accepted.""" + sensor = DummyEnergySensor() + sensor.coordinator.panel_offline = False + + # Initial valid state + sensor._mock_panel_value = 1000.0 + sensor._update_native_value() + + # Update with EQUAL value + sensor._mock_panel_value = 1000.0 + sensor._update_native_value() + + # Should accept 1000 + assert sensor._attr_native_value == 1000.0 + assert sensor._last_valid_state == 1000.0 + + def test_ignores_validation_for_non_total_increasing(self): + """Ensure all values accepted regardless of state class.""" + sensor = DummyEnergySensor() + sensor.coordinator.panel_offline = False + # Change state class to measurement + sensor.entity_description.state_class = SensorStateClass.MEASUREMENT + + # Initial valid state + sensor._mock_panel_value = 1000.0 + sensor._update_native_value() + + # Update with LOWER value + sensor._mock_panel_value = 900.0 + sensor._update_native_value() + + # Should accept 900 + assert sensor._attr_native_value == 900.0 + assert sensor._last_valid_state == 900.0 diff --git a/tests/test_graph_horizon.py b/tests/test_graph_horizon.py new file mode 100644 index 00000000..bf1a7d18 --- /dev/null +++ b/tests/test_graph_horizon.py @@ -0,0 +1,249 @@ +"""Tests for the GraphHorizonManager class.""" + +import asyncio +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from custom_components.span_panel.const import ( + DEFAULT_GRAPH_HORIZON, + VALID_GRAPH_HORIZONS, +) +from custom_components.span_panel.graph_horizon import GraphHorizonManager + + +def _consume_coro(coro): + """Consume a coroutine to avoid 'never awaited' warnings in tests.""" + if asyncio.iscoroutine(coro): + coro.close() + return coro + + +def _make_hass(): + """Create a minimal mock hass object.""" + hass = MagicMock() + hass.async_create_task = MagicMock(side_effect=_consume_coro) + return hass + + +def _make_manager(hass=None, entry_id="test_entry"): + """Create a GraphHorizonManager with mocked hass and entry.""" + if hass is None: + hass = _make_hass() + entry = MagicMock() + entry.entry_id = entry_id + return GraphHorizonManager(hass, entry) + + +class TestGlobalHorizon: + """Tests for global horizon get/set.""" + + def test_default_global_horizon(self): + manager = _make_manager() + assert manager.get_global_horizon() == DEFAULT_GRAPH_HORIZON + + def test_set_global_horizon(self): + manager = _make_manager() + manager.set_global_horizon("1h") + assert manager.get_global_horizon() == "1h" + + def test_set_invalid_horizon_raises(self): + manager = _make_manager() + with pytest.raises(ValueError, match="Invalid graph horizon"): + manager.set_global_horizon("2h") + + def test_set_global_prunes_matching_overrides(self): + """When global changes to match an override, the override is removed.""" + manager = _make_manager() + manager.set_circuit_horizon("circuit_1", "1h") + assert manager.get_effective_horizon("circuit_1") == "1h" + manager.set_global_horizon("1h") + assert "circuit_1" not in manager._circuit_overrides + + +class TestCircuitOverrides: + """Tests for per-circuit horizon overrides.""" + + def test_effective_horizon_returns_global_when_no_override(self): + manager = _make_manager() + assert manager.get_effective_horizon("circuit_1") == DEFAULT_GRAPH_HORIZON + + def test_set_circuit_override(self): + manager = _make_manager() + manager.set_circuit_horizon("circuit_1", "1d") + assert manager.get_effective_horizon("circuit_1") == "1d" + + def test_set_circuit_invalid_horizon_raises(self): + manager = _make_manager() + with pytest.raises(ValueError, match="Invalid graph horizon"): + manager.set_circuit_horizon("circuit_1", "bad") + + def test_set_circuit_matching_global_removes_override(self): + """Setting a circuit to the global value removes the override.""" + manager = _make_manager() + manager.set_circuit_horizon("circuit_1", "1h") + assert "circuit_1" in manager._circuit_overrides + manager.set_circuit_horizon("circuit_1", DEFAULT_GRAPH_HORIZON) + assert "circuit_1" not in manager._circuit_overrides + + def test_clear_circuit_override(self): + manager = _make_manager() + manager.set_circuit_horizon("circuit_1", "1d") + manager.clear_circuit_horizon("circuit_1") + assert manager.get_effective_horizon("circuit_1") == DEFAULT_GRAPH_HORIZON + + def test_clear_nonexistent_override_is_noop(self): + manager = _make_manager() + manager.clear_circuit_horizon("nonexistent") # should not raise + + +class TestGetAllSettings: + """Tests for get_all_settings output.""" + + def test_returns_global_and_empty_circuits(self): + manager = _make_manager() + settings = manager.get_all_settings() + assert settings["global_horizon"] == DEFAULT_GRAPH_HORIZON + assert settings["circuits"] == {} + + def test_returns_overrides_with_has_override_flag(self): + manager = _make_manager() + manager.set_circuit_horizon("circuit_1", "1M") + settings = manager.get_all_settings() + assert settings["circuits"]["circuit_1"] == { + "horizon": "1M", + "has_override": True, + } + + +class TestStoragePersistence: + """Tests for async_load and async_save.""" + + @pytest.mark.asyncio + async def test_save_and_load_round_trip(self): + hass = _make_hass() + manager = _make_manager(hass) + manager.set_global_horizon("1d") + manager.set_circuit_horizon("c1", "1M") + manager.set_circuit_horizon("c2", "1h") + + saved_data = {} + + async def fake_save(data): + saved_data.update(data) + + manager._store = MagicMock() + manager._store.async_save = AsyncMock(side_effect=fake_save) + await manager.async_save() + + assert saved_data["global_horizon"] == "1d" + assert saved_data["circuit_overrides"] == {"c1": "1M", "c2": "1h"} + + manager2 = _make_manager(hass) + manager2._store = MagicMock() + manager2._store.async_load = AsyncMock(return_value=saved_data) + await manager2.async_load() + + assert manager2.get_global_horizon() == "1d" + assert manager2.get_effective_horizon("c1") == "1M" + assert manager2.get_effective_horizon("c2") == "1h" + + @pytest.mark.asyncio + async def test_load_handles_no_existing_data(self): + hass = _make_hass() + manager = _make_manager(hass) + manager._store = MagicMock() + manager._store.async_load = AsyncMock(return_value=None) + await manager.async_load() + assert manager.get_global_horizon() == DEFAULT_GRAPH_HORIZON + assert manager._circuit_overrides == {} + + +class TestSubDeviceOverrides: + """Tests for per-sub-device horizon overrides.""" + + def test_effective_subdevice_horizon_returns_global_when_no_override(self): + manager = _make_manager() + assert manager.get_effective_subdevice_horizon("bess_1") == DEFAULT_GRAPH_HORIZON + + def test_set_subdevice_override(self): + manager = _make_manager() + manager.set_subdevice_horizon("bess_1", "1d") + assert manager.get_effective_subdevice_horizon("bess_1") == "1d" + + def test_set_subdevice_invalid_horizon_raises(self): + manager = _make_manager() + with pytest.raises(ValueError, match="Invalid graph horizon"): + manager.set_subdevice_horizon("bess_1", "bad") + + def test_set_subdevice_matching_global_removes_override(self): + manager = _make_manager() + manager.set_subdevice_horizon("bess_1", "1h") + assert "bess_1" in manager._subdevice_overrides + manager.set_subdevice_horizon("bess_1", DEFAULT_GRAPH_HORIZON) + assert "bess_1" not in manager._subdevice_overrides + + def test_clear_subdevice_override(self): + manager = _make_manager() + manager.set_subdevice_horizon("bess_1", "1d") + manager.clear_subdevice_horizon("bess_1") + assert manager.get_effective_subdevice_horizon("bess_1") == DEFAULT_GRAPH_HORIZON + + def test_clear_nonexistent_subdevice_override_is_noop(self): + manager = _make_manager() + manager.clear_subdevice_horizon("nonexistent") + + def test_set_global_prunes_matching_subdevice_overrides(self): + manager = _make_manager() + manager.set_subdevice_horizon("bess_1", "1h") + manager.set_global_horizon("1h") + assert "bess_1" not in manager._subdevice_overrides + + +class TestGetAllSettingsWithSubDevices: + def test_returns_subdevices_key(self): + manager = _make_manager() + settings = manager.get_all_settings() + assert settings["sub_devices"] == {} + + def test_returns_subdevice_overrides_with_has_override_flag(self): + manager = _make_manager() + manager.set_subdevice_horizon("bess_1", "1M") + settings = manager.get_all_settings() + assert settings["sub_devices"]["bess_1"] == { + "horizon": "1M", + "has_override": True, + } + + +class TestSubDeviceStoragePersistence: + @pytest.mark.asyncio + async def test_save_and_load_round_trip_with_subdevices(self): + hass = _make_hass() + manager = _make_manager(hass) + manager.set_global_horizon("1d") + manager.set_circuit_horizon("c1", "1M") + manager.set_subdevice_horizon("bess_1", "1w") + manager.set_subdevice_horizon("evse_1", "1h") + + saved_data = {} + + async def fake_save(data): + saved_data.update(data) + + manager._store = MagicMock() + manager._store.async_save = AsyncMock(side_effect=fake_save) + await manager.async_save() + + assert saved_data["global_horizon"] == "1d" + assert saved_data["circuit_overrides"] == {"c1": "1M"} + assert saved_data["subdevice_overrides"] == {"bess_1": "1w", "evse_1": "1h"} + + manager2 = _make_manager(hass) + manager2._store = MagicMock() + manager2._store.async_load = AsyncMock(return_value=saved_data) + await manager2.async_load() + + assert manager2.get_global_horizon() == "1d" + assert manager2.get_effective_subdevice_horizon("bess_1") == "1w" + assert manager2.get_effective_subdevice_horizon("evse_1") == "1h" diff --git a/tests/test_graph_horizon_services.py b/tests/test_graph_horizon_services.py new file mode 100644 index 00000000..775593dd --- /dev/null +++ b/tests/test_graph_horizon_services.py @@ -0,0 +1,133 @@ +"""Tests for graph horizon service call handlers.""" + +import asyncio +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from custom_components.span_panel.const import DEFAULT_GRAPH_HORIZON + + +def _consume_coro(coro): + """Consume a coroutine to avoid 'never awaited' warnings in tests.""" + if asyncio.iscoroutine(coro): + coro.close() + return coro + + +def _make_hass(): + hass = MagicMock() + hass.async_create_task = MagicMock(side_effect=_consume_coro) + return hass + + +def _make_runtime_data(hass, entry_id="test_entry"): + """Create mock runtime data with a GraphHorizonManager.""" + from custom_components.span_panel.graph_horizon import GraphHorizonManager + + entry = MagicMock() + entry.entry_id = entry_id + + manager = GraphHorizonManager(hass, entry) + manager._store = MagicMock() + manager._store.async_save = AsyncMock() + manager._store.async_load = AsyncMock(return_value=None) + + coordinator = MagicMock() + coordinator.graph_horizon_manager = manager + + runtime_data = MagicMock() + runtime_data.coordinator = coordinator + + return runtime_data, manager + + +class TestSetGraphTimeHorizon: + """Tests for the set_graph_time_horizon service.""" + + @pytest.mark.asyncio + async def test_set_global_horizon(self): + from custom_components.span_panel.graph_horizon import GraphHorizonManager + + hass = _make_hass() + runtime_data, manager = _make_runtime_data(hass) + + manager.set_global_horizon("1h") + assert manager.get_global_horizon() == "1h" + + @pytest.mark.asyncio + async def test_set_invalid_horizon_raises(self): + hass = _make_hass() + _, manager = _make_runtime_data(hass) + + with pytest.raises(ValueError): + manager.set_global_horizon("invalid") + + +class TestSetCircuitGraphHorizon: + """Tests for the set_circuit_graph_horizon service.""" + + @pytest.mark.asyncio + async def test_set_circuit_override(self): + hass = _make_hass() + _, manager = _make_runtime_data(hass) + + manager.set_circuit_horizon("c1", "1d") + assert manager.get_effective_horizon("c1") == "1d" + + @pytest.mark.asyncio + async def test_clear_circuit_override(self): + hass = _make_hass() + _, manager = _make_runtime_data(hass) + + manager.set_circuit_horizon("c1", "1d") + manager.clear_circuit_horizon("c1") + assert manager.get_effective_horizon("c1") == DEFAULT_GRAPH_HORIZON + + +class TestSetSubDeviceGraphHorizon: + """Tests for the set_subdevice_graph_horizon service.""" + + @pytest.mark.asyncio + async def test_set_subdevice_override(self): + hass = _make_hass() + _, manager = _make_runtime_data(hass) + manager.set_subdevice_horizon("bess_1", "1d") + assert manager.get_effective_subdevice_horizon("bess_1") == "1d" + + @pytest.mark.asyncio + async def test_clear_subdevice_override(self): + hass = _make_hass() + _, manager = _make_runtime_data(hass) + manager.set_subdevice_horizon("bess_1", "1d") + manager.clear_subdevice_horizon("bess_1") + assert manager.get_effective_subdevice_horizon("bess_1") == DEFAULT_GRAPH_HORIZON + + +class TestGetGraphSettings: + """Tests for the get_graph_settings service.""" + + @pytest.mark.asyncio + async def test_returns_settings(self): + hass = _make_hass() + _, manager = _make_runtime_data(hass) + + manager.set_circuit_horizon("c1", "1M") + result = manager.get_all_settings() + + assert result["global_horizon"] == DEFAULT_GRAPH_HORIZON + assert result["circuits"]["c1"]["horizon"] == "1M" + assert result["circuits"]["c1"]["has_override"] is True + + +class TestGetGraphSettingsWithSubDevices: + """Tests for get_graph_settings with sub-device overrides.""" + + @pytest.mark.asyncio + async def test_returns_subdevice_settings(self): + hass = _make_hass() + _, manager = _make_runtime_data(hass) + manager.set_subdevice_horizon("bess_1", "1M") + result = manager.get_all_settings() + assert result["sub_devices"]["bess_1"]["horizon"] == "1M" + assert result["sub_devices"]["bess_1"]["has_override"] is True diff --git a/tests/test_helpers.py b/tests/test_helpers.py index 6464b9ec..7a5ca1da 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -1,18 +1,33 @@ """Tests for helper functions in the Span Panel integration.""" +# pylint: disable=reimported + from unittest.mock import MagicMock, patch -from homeassistant.util import slugify import pytest -from custom_components.span_panel.const import USE_CIRCUIT_NUMBERS, USE_DEVICE_PREFIX +from custom_components.span_panel.const import ( + USE_CIRCUIT_NUMBERS, + USE_DEVICE_PREFIX, +) from custom_components.span_panel.helpers import ( - construct_120v_synthetic_entity_id, - construct_240v_synthetic_entity_id, - construct_entity_id, + async_create_span_notification, + construct_circuit_identifier_from_tabs, construct_multi_circuit_entity_id, - construct_panel_synthetic_entity_id, + construct_unmapped_entity_id, + detect_capabilities, + get_suffix_from_sensor_key, + get_unmapped_circuit_entity_id, get_user_friendly_suffix, + is_panel_level_sensor_key, +) +from homeassistant.util import slugify + +from .factories import ( + SpanBatterySnapshotFactory, + SpanCircuitSnapshotFactory, + SpanEvseSnapshotFactory, + SpanPanelSnapshotFactory, ) @@ -41,10 +56,9 @@ def construct_synthetic_friendly_name( if len(valid_circuits) > 1: circuit_spec = "-".join(valid_circuits) return f"Circuit {circuit_spec} {suffix_description}" - elif len(valid_circuits) == 1: + if len(valid_circuits) == 1: return f"Circuit {valid_circuits[0]} {suffix_description}" - else: - return f"Unknown Circuit {suffix_description}" + return f"Unknown Circuit {suffix_description}" class TestHelperFunctions: @@ -64,63 +78,25 @@ def test_get_user_friendly_suffix(self): assert get_user_friendly_suffix("circuit_priority") == "priority" assert get_user_friendly_suffix("unknown_field") == "unknown_field" - def test_construct_entity_id_config_entry_none(self): - """Test construct_entity_id works with valid coordinator (None config_entry should be caught at coordinator level).""" - coordinator = MagicMock() - coordinator.config_entry.options = {USE_DEVICE_PREFIX: False, USE_CIRCUIT_NUMBERS: False} - coordinator.config_entry.title = "SPAN Panel" - span_panel = MagicMock() - - # This should work fine - the coordinator should validate config_entry at construction time - result = construct_entity_id(coordinator, span_panel, "sensor", "Kitchen", 1, "power") - # With empty options, should use legacy naming (no device prefix) - assert result == "sensor.kitchen_power" - - @patch("custom_components.span_panel.helpers.panel_to_device_info") - def test_construct_entity_id_empty_options_legacy(self, mock_device_info): - """Test construct_entity_id with empty options (legacy installation).""" - mock_device_info.return_value = {"name": "Span Panel"} - - coordinator = MagicMock() - coordinator.config_entry.options = {USE_DEVICE_PREFIX: False, USE_CIRCUIT_NUMBERS: False} - coordinator.config_entry.title = "SPAN Panel" - span_panel = MagicMock() - - result = construct_entity_id( - coordinator, span_panel, "sensor", "Kitchen Outlets", 1, "power" + def test_get_suffix_from_sensor_key(self): + """Test suffix extraction from panel and synthetic sensor keys.""" + assert get_suffix_from_sensor_key("span_abc123_solar_inverter_power") == "power" + assert ( + get_suffix_from_sensor_key("span_abc123_house_total_energy_produced") + == "energy_produced" + ) + assert get_suffix_from_sensor_key("plain_sensor_name") == "name" + + def test_is_panel_level_sensor_key(self): + """Test classification of panel-level and circuit-level sensor keys.""" + assert is_panel_level_sensor_key("span_span12345678_current_power") is True + assert ( + is_panel_level_sensor_key( + "span_span12345678_12ce227695cd44338864b0ef2ec4168b_instantPowerW" + ) + is False ) - assert result == "sensor.kitchen_outlets_power" - - @patch("custom_components.span_panel.helpers.panel_to_device_info") - def test_construct_entity_id_circuit_numbers_no_device_name(self, mock_device_info): - """Test construct_entity_id with circuit numbers but no device name.""" - mock_device_info.return_value = {"name": None} - - coordinator = MagicMock() - coordinator.config_entry.options = {USE_CIRCUIT_NUMBERS: True} - coordinator.config_entry.title = None - coordinator.config_entry.data = {"device_name": None} - span_panel = MagicMock() - - result = construct_entity_id(coordinator, span_panel, "sensor", "Kitchen", 1, "power") - assert result is None - - @patch("custom_components.span_panel.helpers.panel_to_device_info") - def test_construct_entity_id_device_prefix_no_device_name(self, mock_device_info): - """Test construct_entity_id with device prefix but no device name.""" - mock_device_info.return_value = {"name": None} - - coordinator = MagicMock() - coordinator.config_entry.options = { - USE_CIRCUIT_NUMBERS: False, - USE_DEVICE_PREFIX: True, - } - coordinator.config_entry.title = None - coordinator.config_entry.data = {"device_name": None} - span_panel = MagicMock() - - result = construct_entity_id(coordinator, span_panel, "sensor", "Kitchen", 1, "power") - assert result is None + assert is_panel_level_sensor_key("invalid_format") is False @patch("custom_components.span_panel.helpers.er.async_get") def test_construct_multi_circuit_entity_id_config_entry_none(self, mock_registry): @@ -144,16 +120,15 @@ def test_construct_multi_circuit_entity_id_config_entry_none(self, mock_registry # With empty options, should use legacy naming (no device prefix) assert result == "sensor.solar_inverter_power" - @patch("custom_components.span_panel.helpers.panel_to_device_info") @patch("custom_components.span_panel.helpers.er.async_get") - def test_construct_multi_circuit_entity_id_empty_options(self, mock_registry, mock_device_info): + def test_construct_multi_circuit_entity_id_empty_options(self, mock_registry): """Test construct_multi_circuit_entity_id with stable naming (synthetic sensors are always stable).""" mock_registry.return_value = None - mock_device_info.return_value = {"name": "Span Panel"} coordinator = MagicMock() coordinator.config_entry.options = {} coordinator.config_entry.title = "SPAN Panel" + coordinator.config_entry.data = {"device_name": "SPAN Panel"} span_panel = MagicMock() # Test with friendly name - legacy installation should not use device prefix @@ -178,14 +153,10 @@ def test_construct_multi_circuit_entity_id_empty_options(self, mock_registry, mo ) assert result == "sensor.solar_inverter_power" - @patch("custom_components.span_panel.helpers.panel_to_device_info") @patch("custom_components.span_panel.helpers.er.async_get") - def test_construct_multi_circuit_entity_id_no_device_name( - self, mock_registry, mock_device_info - ): + def test_construct_multi_circuit_entity_id_no_device_name(self, mock_registry): """Test construct_multi_circuit_entity_id with no device name - should return None.""" mock_registry.return_value = None - mock_device_info.return_value = {"name": None} coordinator = MagicMock() coordinator.config_entry.options = {USE_CIRCUIT_NUMBERS: True} @@ -204,23 +175,23 @@ def test_construct_multi_circuit_entity_id_no_device_name( ) assert result is None - @patch("custom_components.span_panel.helpers.panel_to_device_info") @patch("custom_components.span_panel.helpers.er.async_get") def test_construct_multi_circuit_entity_id_circuit_numbers_pattern( - self, mock_registry, mock_device_info + self, mock_registry ): """Test construct_multi_circuit_entity_id with circuit numbers pattern.""" mock_registry.return_value = MagicMock() mock_registry.return_value.async_get_entity_id = MagicMock(return_value=None) - mock_device_info.return_value = {"name": "SPAN Panel"} coordinator = MagicMock() - coordinator.config_entry.options = {USE_CIRCUIT_NUMBERS: True, USE_DEVICE_PREFIX: True} + coordinator.config_entry.options = { + USE_CIRCUIT_NUMBERS: True, + USE_DEVICE_PREFIX: True, + } coordinator.config_entry.title = "SPAN Panel" coordinator.config_entry.data = {"device_name": "SPAN Panel"} coordinator.hass = MagicMock() span_panel = MagicMock() - span_panel.status.serial_number = "TEST123456" # Test with multiple circuit numbers (solar inverter case) result = construct_multi_circuit_entity_id( @@ -236,27 +207,31 @@ def test_construct_multi_circuit_entity_id_circuit_numbers_pattern( # Test with different suffix result = construct_multi_circuit_entity_id( - coordinator, span_panel, "sensor", "energy_produced", circuit_numbers=[30, 32] + coordinator, + span_panel, + "sensor", + "energy_produced", + circuit_numbers=[30, 32], ) assert result == "sensor.span_panel_circuit_30_32_energy_produced" - @patch("custom_components.span_panel.helpers.panel_to_device_info") @patch("custom_components.span_panel.helpers.er.async_get") def test_construct_multi_circuit_entity_id_friendly_names_pattern( - self, mock_registry, mock_device_info + self, mock_registry ): """Test construct_multi_circuit_entity_id with friendly names pattern.""" mock_registry.return_value = MagicMock() mock_registry.return_value.async_get_entity_id = MagicMock(return_value=None) - mock_device_info.return_value = {"name": "SPAN Panel"} coordinator = MagicMock() - coordinator.config_entry.options = {USE_CIRCUIT_NUMBERS: False, USE_DEVICE_PREFIX: True} + coordinator.config_entry.options = { + USE_CIRCUIT_NUMBERS: False, + USE_DEVICE_PREFIX: True, + } coordinator.config_entry.title = "SPAN Panel" coordinator.config_entry.data = {"device_name": "SPAN Panel"} coordinator.hass = MagicMock() span_panel = MagicMock() - span_panel.status.serial_number = "TEST123456" # Test with friendly name (should ignore circuit_numbers when USE_CIRCUIT_NUMBERS is False) result = construct_multi_circuit_entity_id( @@ -275,19 +250,21 @@ def test_construct_multi_circuit_entity_id_friendly_names_pattern( ) assert result is None - @patch("custom_components.span_panel.helpers.panel_to_device_info") @patch("custom_components.span_panel.helpers.er.async_get") def test_construct_multi_circuit_entity_id_circuit_numbers_no_device_prefix( - self, mock_registry, mock_device_info + self, mock_registry ): """Test construct_multi_circuit_entity_id with circuit numbers but no device prefix.""" mock_registry.return_value = MagicMock() mock_registry.return_value.async_get_entity_id = MagicMock(return_value=None) - mock_device_info.return_value = {"name": "SPAN Panel"} coordinator = MagicMock() - coordinator.config_entry.options = {USE_CIRCUIT_NUMBERS: True, USE_DEVICE_PREFIX: False} + coordinator.config_entry.options = { + USE_CIRCUIT_NUMBERS: True, + USE_DEVICE_PREFIX: False, + } coordinator.config_entry.title = "SPAN Panel" + coordinator.config_entry.data = {"device_name": "SPAN Panel"} coordinator.hass = MagicMock() span_panel = MagicMock() @@ -297,19 +274,21 @@ def test_construct_multi_circuit_entity_id_circuit_numbers_no_device_prefix( ) assert result == "sensor.circuit_30_32_power" - @patch("custom_components.span_panel.helpers.panel_to_device_info") @patch("custom_components.span_panel.helpers.er.async_get") def test_construct_multi_circuit_entity_id_empty_circuit_numbers( - self, mock_registry, mock_device_info + self, mock_registry ): """Test construct_multi_circuit_entity_id with empty circuit numbers.""" mock_registry.return_value = MagicMock() mock_registry.return_value.async_get_entity_id = MagicMock(return_value=None) - mock_device_info.return_value = {"name": "SPAN Panel"} coordinator = MagicMock() - coordinator.config_entry.options = {USE_CIRCUIT_NUMBERS: True, USE_DEVICE_PREFIX: True} + coordinator.config_entry.options = { + USE_CIRCUIT_NUMBERS: True, + USE_DEVICE_PREFIX: True, + } coordinator.config_entry.title = "SPAN Panel" + coordinator.config_entry.data = {"device_name": "SPAN Panel"} coordinator.hass = MagicMock() span_panel = MagicMock() @@ -340,19 +319,18 @@ def test_construct_multi_circuit_entity_id_empty_circuit_numbers( coordinator, span_panel, "sensor", "power", circuit_numbers=[0, -1] ) - @patch("custom_components.span_panel.helpers.panel_to_device_info") @patch("custom_components.span_panel.helpers.er.async_get") def test_construct_multi_circuit_entity_id_legacy_compatibility( - self, mock_registry, mock_device_info + self, mock_registry ): """Test construct_multi_circuit_entity_id maintains legacy compatibility.""" mock_registry.return_value = MagicMock() mock_registry.return_value.async_get_entity_id = MagicMock(return_value=None) - mock_device_info.return_value = {"name": "SPAN Panel"} coordinator = MagicMock() coordinator.config_entry.options = {} # Legacy installation coordinator.config_entry.title = "SPAN Panel" + coordinator.config_entry.data = {"device_name": "SPAN Panel"} coordinator.hass = MagicMock() span_panel = MagicMock() @@ -380,7 +358,9 @@ def test_construct_multi_circuit_entity_id_legacy_compatibility( def test_construct_synthetic_friendly_name_with_user_name(self): """Test construct_synthetic_friendly_name with user-provided name.""" - result = construct_synthetic_friendly_name([30, 32], "Instant Power", "Solar Production") + result = construct_synthetic_friendly_name( + [30, 32], "Instant Power", "Solar Production" + ) assert result == "Solar Production Instant Power" def test_construct_synthetic_friendly_name_multiple_circuits(self): @@ -403,459 +383,68 @@ def test_construct_synthetic_friendly_name_empty_circuits(self): result = construct_synthetic_friendly_name([], "Instant Power") assert result == "Unknown Circuit Instant Power" - @patch("custom_components.span_panel.helpers.er.async_get") - def test_construct_panel_entity_id_with_device_prefix(self, mock_registry): - """Test construct_panel_entity_id with device prefix enabled.""" - mock_registry.return_value = MagicMock() - mock_registry.return_value.async_get_entity_id = MagicMock(return_value=None) - - coordinator = MagicMock() - coordinator.config_entry.options = {USE_DEVICE_PREFIX: True} - coordinator.hass = MagicMock() - span_panel = MagicMock() - - from custom_components.span_panel.helpers import construct_panel_entity_id - - # Test with device prefix enabled - result = construct_panel_entity_id( - coordinator, - span_panel, - "binary_sensor", - "wwanlink", - "SPAN Panel", - unique_id="test_unique_id", - use_device_prefix=True, - ) - assert result == "binary_sensor.span_panel_wwanlink" - - # Test with device prefix disabled - result = construct_panel_entity_id( - coordinator, - span_panel, - "binary_sensor", - "wwanlink", - "SPAN Panel", - unique_id="test_unique_id", - use_device_prefix=False, - ) - assert result == "binary_sensor.wwanlink" - - # Test with device prefix from config (True) - result = construct_panel_entity_id( - coordinator, - span_panel, - "binary_sensor", - "wwanlink", - "SPAN Panel", - unique_id="test_unique_id", - use_device_prefix=None, # Should use config - ) - assert result == "binary_sensor.span_panel_wwanlink" - - @patch("custom_components.span_panel.helpers.er.async_get") - def test_construct_panel_entity_id_without_device_prefix(self, mock_registry): - """Test construct_panel_entity_id with device prefix disabled.""" - mock_registry.return_value = MagicMock() - mock_registry.return_value.async_get_entity_id = MagicMock(return_value=None) - - coordinator = MagicMock() - coordinator.config_entry.options = {USE_DEVICE_PREFIX: False} - coordinator.hass = MagicMock() - span_panel = MagicMock() - - from custom_components.span_panel.helpers import construct_panel_entity_id - - # Test with device prefix from config (False) - result = construct_panel_entity_id( - coordinator, - span_panel, - "binary_sensor", - "wwanlink", - "SPAN Panel", - unique_id="test_unique_id", - use_device_prefix=None, # Should use config - ) - assert result == "binary_sensor.wwanlink" - - @patch("custom_components.span_panel.helpers.er.async_get") - def test_construct_panel_entity_id_registry_lookup(self, mock_registry): - """Test construct_panel_entity_id with existing entity in registry.""" - mock_registry.return_value = MagicMock() - mock_registry.return_value.async_get_entity_id = MagicMock( - return_value="binary_sensor.existing_entity" - ) - - coordinator = MagicMock() - coordinator.config_entry.options = {USE_DEVICE_PREFIX: True} - coordinator.hass = MagicMock() - span_panel = MagicMock() - - from custom_components.span_panel.helpers import construct_panel_entity_id - - # Test with existing entity in registry - result = construct_panel_entity_id( - coordinator, - span_panel, - "binary_sensor", - "wwanlink", - "SPAN Panel", - unique_id="test_unique_id", - use_device_prefix=True, + def test_construct_unmapped_entity_helpers(self): + """Test helper functions for unmapped circuits.""" + snapshot = SpanPanelSnapshotFactory.create( + circuits={ + "unmapped_tab_7": SpanCircuitSnapshotFactory.create( + circuit_id="unmapped_tab_7" + ) + } ) - assert result == "binary_sensor.existing_entity" - - @patch("custom_components.span_panel.helpers.er.async_get") - def test_construct_panel_entity_id_different_platforms(self, mock_registry): - """Test construct_panel_entity_id with different platforms.""" - mock_registry.return_value = MagicMock() - mock_registry.return_value.async_get_entity_id = MagicMock(return_value=None) - - coordinator = MagicMock() - coordinator.config_entry.options = {USE_DEVICE_PREFIX: True} - coordinator.hass = MagicMock() - span_panel = MagicMock() - - from custom_components.span_panel.helpers import construct_panel_entity_id - - # Test with binary_sensor platform - result = construct_panel_entity_id( - coordinator, - span_panel, - "binary_sensor", - "wwanlink", - "SPAN Panel", - use_device_prefix=True, - ) - assert result == "binary_sensor.span_panel_wwanlink" - - # Test with sensor platform - result = construct_panel_entity_id( - coordinator, - span_panel, - "sensor", - "current_power", - "SPAN Panel", - use_device_prefix=True, - ) - assert result == "sensor.span_panel_current_power" - - # Test with switch platform - result = construct_panel_entity_id( - coordinator, - span_panel, - "switch", - "test_switch", - "SPAN Panel", - use_device_prefix=True, - ) - assert result == "switch.span_panel_test_switch" - - @patch("custom_components.span_panel.helpers.er.async_get") - def test_binary_sensor_entity_id_construction_simulation(self, mock_registry): - """Test that simulates exactly what the binary sensor does when creating entity IDs.""" - mock_registry.return_value = MagicMock() - mock_registry.return_value.async_get_entity_id = MagicMock(return_value=None) - - # Simulate the binary sensor setup - coordinator = MagicMock() - coordinator.config_entry.options = {USE_DEVICE_PREFIX: True} - coordinator.config_entry.title = "SPAN Panel" - coordinator.config_entry.data = {"device_name": "SPAN Panel"} - coordinator.hass = MagicMock() - - span_panel = MagicMock() - span_panel.status.serial_number = "TEST123456" - - from custom_components.span_panel.helpers import construct_panel_entity_id - - # Simulate the wwanLink binary sensor - device_name = coordinator.config_entry.data.get("device_name", coordinator.config_entry.title) - use_device_prefix = coordinator.config_entry.options.get(USE_DEVICE_PREFIX, False) - - print(f"DEBUG: device_name={device_name}, use_device_prefix={use_device_prefix}") - entity_id = construct_panel_entity_id( - coordinator, - span_panel, - "binary_sensor", - "wwanlink", # description.key.lower() - device_name, - "test_unique_id", # self._attr_unique_id - use_device_prefix, - ) - - print(f"DEBUG: final entity_id={entity_id}") - - # This should match what we expect in the logs - assert entity_id == "binary_sensor.span_panel_wwanlink" - - @patch("custom_components.span_panel.helpers.er.async_get") - def test_sensor_entity_id_construction_simulation(self, mock_registry): - """Test that simulates exactly what the sensor does when creating entity IDs.""" - mock_registry.return_value = MagicMock() - mock_registry.return_value.async_get_entity_id = MagicMock(return_value=None) - - # Simulate the sensor setup - coordinator = MagicMock() - coordinator.config_entry.options = {USE_DEVICE_PREFIX: True} - coordinator.config_entry.title = "SPAN Panel" - coordinator.config_entry.data = {"device_name": "SPAN Panel"} - coordinator.hass = MagicMock() - - span_panel = MagicMock() - span_panel.status.serial_number = "TEST123456" - - from custom_components.span_panel.helpers import construct_panel_entity_id - - # Simulate the dsm_state sensor - device_name = "SPAN Panel" - use_device_prefix = True - - print(f"DEBUG: device_name={device_name}, use_device_prefix={use_device_prefix}") - - entity_id = construct_panel_entity_id( - coordinator, - span_panel, - "sensor", - "dsm_state", # suffix - device_name, - "test_unique_id", # unique_id - use_device_prefix, - ) - - print(f"DEBUG: final entity_id={entity_id}") - - # This should match what we expect in the logs - assert entity_id == "sensor.span_panel_dsm_state" - - @patch("custom_components.span_panel.helpers.er.async_get") - def test_construct_panel_synthetic_entity_id(self, mock_registry): - """Test construct_panel_synthetic_entity_id for panel-level synthetic sensors.""" - mock_registry.return_value = MagicMock() - # Mock the registry to return an existing entity ID for the unique_id - mock_registry.return_value.async_get_entity_id = MagicMock(return_value="sensor.existing_entity") - - coordinator = MagicMock() - coordinator.config_entry.options = {USE_DEVICE_PREFIX: True} - coordinator.hass = MagicMock() - span_panel = MagicMock() - - # Test with device prefix enabled and existing unique_id - result = construct_panel_synthetic_entity_id( - coordinator, - span_panel, - "sensor", - "current_power", - "SPAN Panel", - unique_id="test_unique_id", - ) - assert result == "sensor.existing_entity" - - # Test with device prefix enabled and no unique_id (should construct new entity_id) - result = construct_panel_synthetic_entity_id( - coordinator, - span_panel, - "sensor", - "current_power", - "SPAN Panel", - unique_id=None, - ) - assert result == "sensor.span_panel_current_power" - - # Test with device prefix disabled and no unique_id - coordinator.config_entry.options = {USE_DEVICE_PREFIX: False} - result = construct_panel_synthetic_entity_id( - coordinator, - span_panel, - "sensor", - "current_power", - "SPAN Panel", - unique_id=None, - ) - assert result == "sensor.current_power" - - def test_construct_240v_synthetic_entity_id_circuit_numbers(self): - """Test construct_240v_synthetic_entity_id with circuit numbers pattern.""" - coordinator = MagicMock() - coordinator.config_entry.options = {USE_CIRCUIT_NUMBERS: True, USE_DEVICE_PREFIX: True} - coordinator.config_entry.title = "SPAN Panel" - coordinator.config_entry.data = {"device_name": "SPAN Panel"} - coordinator.hass = MagicMock() - span_panel = MagicMock() - - # Test 240V solar sensor with circuit numbers - result = construct_240v_synthetic_entity_id( - coordinator, - span_panel, - "sensor", - "power", - friendly_name="Solar", - tab1=30, - tab2=32, - ) - assert result == "sensor.span_panel_circuit_30_32_power" - - # Test with different suffix - result = construct_240v_synthetic_entity_id( - coordinator, - span_panel, - "sensor", - "energy_produced", - friendly_name="Solar", - tab1=30, - tab2=32, - ) - assert result == "sensor.span_panel_circuit_30_32_energy_produced" - - def test_construct_240v_synthetic_entity_id_friendly_names(self): - """Test construct_240v_synthetic_entity_id with friendly names pattern.""" - coordinator = MagicMock() - coordinator.config_entry.options = {USE_CIRCUIT_NUMBERS: False, USE_DEVICE_PREFIX: True} - coordinator.config_entry.title = "SPAN Panel" - coordinator.config_entry.data = {"device_name": "SPAN Panel"} - coordinator.hass = MagicMock() - span_panel = MagicMock() - - # Test 240V solar sensor with friendly names - result = construct_240v_synthetic_entity_id( - coordinator, - span_panel, - "sensor", - "power", - friendly_name="Solar", - tab1=30, - tab2=32, + assert ( + construct_unmapped_entity_id( + snapshot, "unmapped_tab_7", "power", "SPAN Panel" + ) + == "sensor.span_panel_unmapped_tab_7_power" ) - assert result == "sensor.span_panel_solar_power" - - # Test 240V named circuit with friendly name - result = construct_240v_synthetic_entity_id( - coordinator, - span_panel, - "sensor", - "power", - friendly_name="Air Conditioner", - tab1=15, - tab2=17, + assert ( + get_unmapped_circuit_entity_id(snapshot, 7, "power", "SPAN Panel") + == "sensor.span_panel_unmapped_tab_7_power" ) - assert result == "sensor.span_panel_air_conditioner_power" - - def test_construct_120v_synthetic_entity_id_circuit_numbers(self): - """Test construct_120v_synthetic_entity_id with circuit numbers pattern.""" - coordinator = MagicMock() - coordinator.config_entry.options = {USE_CIRCUIT_NUMBERS: True, USE_DEVICE_PREFIX: True} - coordinator.config_entry.title = "SPAN Panel" - coordinator.config_entry.data = {"device_name": "SPAN Panel"} - coordinator.hass = MagicMock() - span_panel = MagicMock() - - # Test 120V solar sensor with circuit numbers - result = construct_120v_synthetic_entity_id( - coordinator, - span_panel, - "sensor", - "power", - friendly_name="Solar", - tab=30, + assert ( + get_unmapped_circuit_entity_id(snapshot, 99, "power", "SPAN Panel") is None ) - assert result == "sensor.span_panel_circuit_30_power" - # Test with different suffix - result = construct_120v_synthetic_entity_id( - coordinator, - span_panel, - "sensor", - "energy_consumed", - friendly_name="Solar", - tab=30, + def test_construct_circuit_identifier_from_tabs(self): + """Test fallback circuit naming from tabs.""" + assert construct_circuit_identifier_from_tabs([5, 6], "c1") == "Circuit 5 6" + assert construct_circuit_identifier_from_tabs([7], "c1") == "Circuit 7" + assert ( + construct_circuit_identifier_from_tabs([], "fallback") == "Circuit fallback" ) - assert result == "sensor.span_panel_circuit_30_energy_consumed" - def test_construct_120v_synthetic_entity_id_friendly_names(self): - """Test construct_120v_synthetic_entity_id with friendly names pattern.""" - coordinator = MagicMock() - coordinator.config_entry.options = {USE_CIRCUIT_NUMBERS: False, USE_DEVICE_PREFIX: True} - coordinator.config_entry.title = "SPAN Panel" - coordinator.config_entry.data = {"device_name": "SPAN Panel"} - coordinator.hass = MagicMock() - span_panel = MagicMock() + @patch("custom_components.span_panel.helpers.async_create") + @pytest.mark.asyncio + async def test_async_create_span_notification_logs_and_forwards(self, mock_create): + """Test notification helper forwarding.""" + hass = MagicMock() - # Test 120V solar sensor with friendly names - result = construct_120v_synthetic_entity_id( - coordinator, - span_panel, - "sensor", - "power", - friendly_name="Solar", - tab=30, + await async_create_span_notification( + hass, + "Panel connection lost", + "SPAN Alert", + "notif-1", + level="error", ) - assert result == "sensor.span_panel_solar_power" - # Test 120V named circuit with friendly name - result = construct_120v_synthetic_entity_id( - coordinator, - span_panel, - "sensor", - "power", - friendly_name="Kitchen Outlets", - tab=16, + mock_create.assert_called_once_with( + hass, + message="Panel connection lost", + title="SPAN Alert", + notification_id="notif-1", ) - assert result == "sensor.span_panel_kitchen_outlets_power" - - def test_construct_synthetic_entity_id_no_device_prefix(self): - """Test synthetic entity ID construction without device prefix.""" - coordinator = MagicMock() - coordinator.config_entry.options = {USE_CIRCUIT_NUMBERS: True, USE_DEVICE_PREFIX: False} - coordinator.config_entry.title = "SPAN Panel" - coordinator.hass = MagicMock() - span_panel = MagicMock() - # Test 240V without device prefix - result = construct_240v_synthetic_entity_id( - coordinator, - span_panel, - "sensor", - "power", - friendly_name="Solar", - tab1=30, - tab2=32, + def test_detect_capabilities_helper(self): + """Test capability detection from a populated snapshot.""" + snapshot = SpanPanelSnapshotFactory.create( + battery=SpanBatterySnapshotFactory.create(soe_percentage=88.0), + power_flow_pv=1200.0, + power_flow_site=3000.0, + evse={"evse-0": SpanEvseSnapshotFactory.create()}, ) - assert result == "sensor.circuit_30_32_power" - # Test 120V without device prefix - result = construct_120v_synthetic_entity_id( - coordinator, - span_panel, - "sensor", - "power", - friendly_name="Solar", - tab=30, - ) - assert result == "sensor.circuit_30_power" - - @patch("custom_components.span_panel.helpers.er.async_get") - def test_construct_synthetic_entity_id_registry_lookup(self, mock_registry): - """Test synthetic entity ID construction with existing entity in registry.""" - mock_registry.return_value = MagicMock() - mock_registry.return_value.async_get_entity_id = MagicMock( - return_value="sensor.existing_solar_current_power" - ) - - coordinator = MagicMock() - coordinator.config_entry.options = {USE_CIRCUIT_NUMBERS: True, USE_DEVICE_PREFIX: True} - coordinator.config_entry.title = "SPAN Panel" - coordinator.hass = MagicMock() - span_panel = MagicMock() - - # Test registry lookup with existing entity - result = construct_240v_synthetic_entity_id( - coordinator, - span_panel, - "sensor", - "power", - friendly_name="Solar", - tab1=30, - tab2=32, - unique_id="test_unique_id", + assert detect_capabilities(snapshot) == frozenset( + {"bess", "evse", "power_flows", "pv"} ) - assert result == "sensor.existing_solar_current_power" diff --git a/tests/test_init_helpers.py b/tests/test_init_helpers.py new file mode 100644 index 00000000..a13774aa --- /dev/null +++ b/tests/test_init_helpers.py @@ -0,0 +1,187 @@ +"""Tests for Span Panel helper functions in __init__.py.""" + +from __future__ import annotations + +import asyncio +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from custom_components.span_panel import ( + SpanPanelRuntimeData, + async_remove_config_entry_device, + async_unload_entry, + ensure_device_registered, + update_listener, +) +from custom_components.span_panel.const import DOMAIN +from homeassistant.const import CONF_HOST +from homeassistant.core import CoreState, HomeAssistant +from homeassistant.helpers import device_registry as dr + +from .factories import SpanPanelSnapshotFactory + +from pytest_homeassistant_custom_component.common import MockConfigEntry + + +async def test_async_remove_config_entry_device_allows_removal_without_runtime_data( + hass: HomeAssistant, +) -> None: + """Entries without runtime data should not block device removal.""" + entry = MockConfigEntry(domain=DOMAIN, data={}) + device = MagicMock() + + assert await async_remove_config_entry_device(hass, entry, device) is True + + +async def test_async_remove_config_entry_device_rejects_main_panel_device( + hass: HomeAssistant, +) -> None: + """The main panel device cannot be manually removed.""" + snapshot = SpanPanelSnapshotFactory.create(serial_number="sp3-main-001") + coordinator = MagicMock() + coordinator.data = snapshot + entry = MockConfigEntry(domain=DOMAIN, data={}) + entry.runtime_data = SpanPanelRuntimeData(coordinator=coordinator) + device = MagicMock() + device.identifiers = {(DOMAIN, "sp3-main-001")} + + assert await async_remove_config_entry_device(hass, entry, device) is False + + +async def test_async_remove_config_entry_device_allows_subdevice_removal( + hass: HomeAssistant, +) -> None: + """Sub-devices should remain removable when runtime data exists.""" + snapshot = SpanPanelSnapshotFactory.create(serial_number="sp3-main-001") + coordinator = MagicMock() + coordinator.data = snapshot + entry = MockConfigEntry(domain=DOMAIN, data={}) + entry.runtime_data = SpanPanelRuntimeData(coordinator=coordinator) + device = MagicMock() + device.identifiers = {(DOMAIN, "sp3-main-001_evse")} + + assert await async_remove_config_entry_device(hass, entry, device) is True + + +async def test_update_listener_reloads_running_entry(hass: HomeAssistant) -> None: + """Options updates should reload the entry once Home Assistant is running.""" + entry = MockConfigEntry(domain=DOMAIN, data={}, entry_id="entry-123") + hass.state = CoreState.running + + with patch.object(hass.config_entries, "async_reload", AsyncMock()) as mock_reload: + await update_listener(hass, entry) + + mock_reload.assert_awaited_once_with("entry-123") + + +async def test_update_listener_skips_reload_when_not_running( + hass: HomeAssistant, +) -> None: + """Options updates should be ignored before Home Assistant is running.""" + entry = MockConfigEntry(domain=DOMAIN, data={}, entry_id="entry-123") + hass.state = CoreState.starting + + with patch.object(hass.config_entries, "async_reload", AsyncMock()) as mock_reload: + await update_listener(hass, entry) + + mock_reload.assert_not_awaited() + + +async def test_update_listener_propagates_cancelled_error(hass: HomeAssistant) -> None: + """Cancellation should not be swallowed by the update listener.""" + entry = MockConfigEntry(domain=DOMAIN, data={}, entry_id="entry-123") + hass.state = CoreState.running + + with ( + patch.object( + hass.config_entries, + "async_reload", + AsyncMock(side_effect=asyncio.CancelledError), + ), + pytest.raises(asyncio.CancelledError), + ): + await update_listener(hass, entry) + + +async def test_update_listener_logs_unexpected_reload_failure( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Unexpected reload errors should be logged for diagnosis.""" + entry = MockConfigEntry(domain=DOMAIN, data={}, entry_id="entry-123") + hass.state = CoreState.running + caplog.set_level("ERROR") + + with patch.object( + hass.config_entries, + "async_reload", + AsyncMock(side_effect=RuntimeError("reload boom")), + ): + await update_listener(hass, entry) + + assert "Failed to reload SPAN Panel integration: reload boom" in caplog.text + + +async def test_ensure_device_registered_renames_placeholder_device( + hass: HomeAssistant, +) -> None: + """An existing placeholder device name should be replaced with the panel name.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "192.168.1.20"}, + entry_id="entry-123", + unique_id="sp3-rename-001", + ) + entry.add_to_hass(hass) + snapshot = SpanPanelSnapshotFactory.create(serial_number="sp3-rename-001") + device_registry = dr.async_get(hass) + existing = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, "sp3-rename-001")}, + name="sp3-rename-001", + ) + + await ensure_device_registered(hass, entry, snapshot, "SPAN Panel") + + updated = device_registry.async_get(existing.id) + assert updated is not None + assert updated.name == "SPAN Panel" + + +async def test_ensure_device_registered_creates_missing_device( + hass: HomeAssistant, +) -> None: + """A missing panel device should be created from the snapshot.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "192.168.1.21"}, + entry_id="entry-456", + unique_id="sp3-create-001", + ) + entry.add_to_hass(hass) + snapshot = SpanPanelSnapshotFactory.create(serial_number="sp3-create-001") + device_registry = dr.async_get(hass) + + await ensure_device_registered(hass, entry, snapshot, "SPAN Panel") + + created = device_registry.async_get_device(identifiers={(DOMAIN, "sp3-create-001")}) + assert created is not None + assert created.name == "SPAN Panel" + + +async def test_async_unload_entry_shuts_down_runtime_data( + hass: HomeAssistant, +) -> None: + """Unload should stop the coordinator before unloading platforms.""" + coordinator = MagicMock() + coordinator.async_shutdown = AsyncMock() + entry = MockConfigEntry(domain=DOMAIN, data={}, entry_id="entry-789") + entry.runtime_data = SpanPanelRuntimeData(coordinator=coordinator) + + with patch.object( + hass.config_entries, "async_unload_platforms", AsyncMock(return_value=True) + ) as mock_unload: + assert await async_unload_entry(hass, entry) is True + + coordinator.async_shutdown.assert_awaited_once() + mock_unload.assert_awaited_once() diff --git a/tests/test_migration_1_0_10_to_1_2_0.py b/tests/test_migration_1_0_10_to_1_2_0.py deleted file mode 100644 index 708ab7e7..00000000 --- a/tests/test_migration_1_0_10_to_1_2_0.py +++ /dev/null @@ -1,348 +0,0 @@ -#!/usr/bin/env python3 -"""Test SPAN Panel migration from v1.0.10 to v1.2.0. - -This test validates the complete migration process: -1. Loads real v1.0.10 entity registry data -2. Performs unique_id normalization (Phase 1) -3. Generates complete YAML configuration in migration mode -4. Validates that all expected sensors are created: - - Panel sensors (no collisions, proper entity_ids) - - Named circuit power sensors (for all circuits) - - Circuit energy sensors - - Status sensors -""" - -import asyncio -import json -from pathlib import Path -import sys - -import yaml - -# Add the project root to Python path -project_root = Path(__file__).parent.parent -sys.path.insert(0, str(project_root)) - -async def test_migration_1_0_10_to_1_2_0(): # noqa: C901 - """Test complete migration from v1.0.10 to v1.2.0.""" - - print("🧪 SPAN Panel Migration Test: v1.0.10 → v1.2.0") - print("=" * 60) - - # Load the real v1.0.10 registry data - registry_source = Path(__file__).parent / "migration_storage" / "1_0_10" / "core.entity_registry" - - if not registry_source.exists(): - print(f"❌ Registry file not found: {registry_source}") - return False - - print(f"📁 Loading v1.0.10 registry: {registry_source}") - - with open(registry_source) as f: - registry_data = json.load(f) - - # Extract SPAN Panel entities - span_entities = [] - for entity in registry_data["data"]["entities"]: - if entity.get("platform") == "span_panel": - span_entities.append(entity) - - print(f"📊 Found {len(span_entities)} SPAN Panel entities in v1.0.10 registry") - - # Categorize entities - panel_entities = [] - circuit_entities = [] - status_entities = [] - - for entity in span_entities: - unique_id = entity["unique_id"] - if any(key in unique_id for key in ["mainMeterEnergy", "feedthroughEnergy", "feedthroughPowerW", "instantGridPowerW"]): - panel_entities.append(entity) - elif any(key in unique_id for key in ["instantPowerW", "producedEnergyWh", "consumedEnergyWh"]): - # Check if this is a circuit entity (has UUID in 3rd position) - parts = unique_id.split('_') - if len(parts) >= 4 and len(parts[2]) == 32 and all(c in '0123456789abcdef' for c in parts[2]): - circuit_entities.append(entity) - else: - status_entities.append(entity) - else: - status_entities.append(entity) - - print(f" • Panel sensors: {len(panel_entities)}") - print(f" • Circuit sensors: {len(circuit_entities)}") - print(f" • Status sensors: {len(status_entities)}") - - # Extract circuit IDs from registry - circuit_ids = set() - circuit_power_sensors = [] - circuit_energy_sensors = [] - - for entity in circuit_entities: - unique_id = entity["unique_id"] - # Circuit pattern: span_serial_circuitid_sensor - # Look for entities with UUID pattern (32 char hex) - parts = unique_id.split('_') - if len(parts) >= 3: - potential_circuit_id = parts[2] # Circuit UUID is 3rd part - # Check if it looks like a UUID (32 hex characters) - if len(potential_circuit_id) == 32 and all(c in '0123456789abcdef' for c in potential_circuit_id): - circuit_ids.add(potential_circuit_id) - - if "instantPowerW" in unique_id: - circuit_power_sensors.append(entity) - elif "EnergyWh" in unique_id: - circuit_energy_sensors.append(entity) - - expected_circuits = sorted(circuit_ids) - print(f" • Unique circuits: {len(expected_circuits)}") - print(f" • Circuit power sensors: {len(circuit_power_sensors)}") - print(f" • Circuit energy sensors: {len(circuit_energy_sensors)}") - - # Phase 1: Test migration normalization - print("\n🔄 Phase 1: Testing unique_id normalization...") - - from custom_components.span_panel.migration import _compute_normalized_unique_id - - normalization_tests = 0 - normalization_passed = 0 - - # Test all panel entities - panel_test_cases = [ - ("mainMeterEnergy.producedEnergyWh", "main_meter_produced_energy"), - ("mainMeterEnergy.consumedEnergyWh", "main_meter_consumed_energy"), - ("feedthroughEnergy.producedEnergyWh", "feed_through_produced_energy"), - ("feedthroughEnergy.consumedEnergyWh", "feed_through_consumed_energy"), - ("feedthroughPowerW", "feed_through_power"), - ("instantGridPowerW", "current_power"), - ] - - for original_suffix, expected_suffix in panel_test_cases: - original_unique_id = f"span_nj-2316-005k6_{original_suffix}" - expected_unique_id = f"span_nj-2316-005k6_{expected_suffix}" - - normalized = _compute_normalized_unique_id(original_unique_id) - normalization_tests += 1 - - if normalized == expected_unique_id: - print(f" ✅ Panel: {original_suffix} → {expected_suffix}") - normalization_passed += 1 - else: - print(f" ❌ Panel: {original_suffix}") - print(f" Expected: {expected_unique_id}") - print(f" Got: {normalized}") - - # Test circuit entities - for circuit_id in expected_circuits[:3]: # Test first 3 circuits - test_cases = [ - (f"span_nj-2316-005k6_{circuit_id}_instantPowerW", f"span_nj-2316-005k6_{circuit_id}_power"), - (f"span_nj-2316-005k6_{circuit_id}_producedEnergyWh", f"span_nj-2316-005k6_{circuit_id}_energy_produced"), - (f"span_nj-2316-005k6_{circuit_id}_consumedEnergyWh", f"span_nj-2316-005k6_{circuit_id}_energy_consumed"), - ] - - for original, expected in test_cases: - normalized = _compute_normalized_unique_id(original) - normalization_tests += 1 - - if normalized == expected: - normalization_passed += 1 - else: - print(f" ❌ Circuit {circuit_id[:8]}...: {original.split('_')[-1]}") - print(f" Expected: {expected}") - print(f" Got: {normalized}") - - print(f" 📊 Normalization: {normalization_passed}/{normalization_tests} passed") - - # Phase 2: Validate expected YAML structure based on migration - print("\n🔍 Phase 2: Validating expected post-migration YAML structure...") - - # After migration, we expect these normalized unique_ids to exist - # and they should match what synthetic generation would produce - from custom_components.span_panel.helpers import ( - construct_synthetic_unique_id, - ) - - expected_sensors = {} - - # Panel sensors - these should exist after migration normalization - panel_api_mapping = { - "instantGridPowerW": "current_power", - "feedthroughPowerW": "feed_through_power", - "mainMeterEnergyProducedWh": "main_meter_produced_energy", - "mainMeterEnergyConsumedWh": "main_meter_consumed_energy", - "feedthroughEnergyProducedWh": "feed_through_produced_energy", - "feedthroughEnergyConsumedWh": "feed_through_consumed_energy", - } - - for api_key, expected_suffix in panel_api_mapping.items(): - synthetic_unique_id = construct_synthetic_unique_id("nj-2316-005k6", expected_suffix) - expected_sensors[synthetic_unique_id] = { - "type": "panel", - "api_key": api_key, - "suffix": expected_suffix - } - - # Circuit sensors - these should be created for all circuits - for circuit_id in expected_circuits: - # Power sensor (this is what was missing!) - power_unique_id = construct_synthetic_unique_id("nj-2316-005k6", f"{circuit_id}_power") - expected_sensors[power_unique_id] = { - "type": "circuit_power", - "circuit_id": circuit_id - } - - # Energy sensors - for energy_type in ["energy_produced", "energy_consumed"]: - energy_unique_id = construct_synthetic_unique_id("nj-2316-005k6", f"{circuit_id}_{energy_type}") - expected_sensors[energy_unique_id] = { - "type": f"circuit_{energy_type}", - "circuit_id": circuit_id - } - - print(" 📊 Expected sensors after migration:") - print(f" • Panel sensors: {len([s for s in expected_sensors.values() if s['type'] == 'panel'])}") - print(f" • Circuit power sensors: {len([s for s in expected_sensors.values() if s['type'] == 'circuit_power'])}") - print(f" • Circuit energy sensors: {len([s for s in expected_sensors.values() if 'circuit_energy' in s['type']])}") - - yaml_content = """version: '1.0' -global_settings: - device_identifier: nj-2316-005k6 - variables: - energy_grace_period_minutes: "15" - -sensors:""" - - # Add a few example sensors to show structure - for unique_id, _sensor_info in list(expected_sensors.items())[:5]: - yaml_content += f""" - "{unique_id}": - name: "Test Sensor" - entity_id: sensor.test_entity - formula: state""" - - print(" ✅ Expected YAML structure validated") - - # Phase 3: Validate that live YAML would have the correct structure - print("\n🔍 Phase 3: Comparing with live YAML (v1.0.10 → v1.2.0)...") - - # Read the actual live YAML from the attached file or generated test file - live_yaml_path = "/Volumes/config/span_panel_sensor_config.yaml" - test_yaml_path = "/tmp/span_migration_test_config.yaml" - live_yaml_content = None - - # Try test YAML first (generated from fixed implementation), then live YAML - for yaml_path in [test_yaml_path, live_yaml_path]: - try: - with open(yaml_path) as f: - live_yaml_content = f.read() - print(f" ✅ Loaded YAML: {yaml_path}") - break - except Exception as e: - print(f" ❌ Could not load YAML from {yaml_path}: {e}") - - if not live_yaml_content: - print(" ℹ️ Continuing with validation based on expected structure...") - - validation_results = { - "panel_sensors": 0, - "circuit_power_sensors": 0, - "circuit_energy_sensors": 0, - "collisions": 0, - "missing_power_sensors": [] - } - - if live_yaml_content: - try: - live_yaml_data = yaml.safe_load(live_yaml_content) - live_sensors = live_yaml_data.get("sensors", {}) - print(f" Live YAML has {len(live_sensors)} sensors") - - # Check what we actually have vs what we expect - panel_sensor_count = 0 - circuit_power_count = 0 - collision_count = 0 - - # Check panel sensors for collisions - expected_panel_keys = [s for s in expected_sensors if expected_sensors[s]["type"] == "panel"] - for sensor_key in expected_panel_keys: - if sensor_key in live_sensors: - panel_sensor_count += 1 - entity_id = live_sensors[sensor_key].get("entity_id", "") - if "_2" in entity_id: - collision_count += 1 - print(f" Live collision detected: {entity_id} (key: {sensor_key})") - else: - print(f" Panel sensor OK: {sensor_key}") - else: - print(f" Missing panel sensor: {sensor_key}") - - # Check circuit power sensors - THE KEY ISSUE - expected_circuit_power_keys = [s for s in expected_sensors if expected_sensors[s]["type"] == "circuit_power"] - for sensor_key in expected_circuit_power_keys: - if sensor_key in live_sensors: - circuit_power_count += 1 - else: - circuit_id = expected_sensors[sensor_key]["circuit_id"] - validation_results["missing_power_sensors"].append(circuit_id) - - validation_results["panel_sensors"] = panel_sensor_count - validation_results["circuit_power_sensors"] = circuit_power_count - validation_results["collisions"] = collision_count - - print(" Live YAML analysis:") - print(f" • Panel sensors found: {panel_sensor_count}/{len(expected_panel_keys)}") - print(f" • Circuit power sensors found: {circuit_power_count}/{len(expected_circuit_power_keys)}") - print(f" • Collisions detected: {collision_count}") - - except Exception as e: - print(f" Could not parse live YAML: {e}") - return False - else: - # Fallback validation based on expected structure - validation_results["panel_sensors"] = len([s for s in expected_sensors.values() if s["type"] == "panel"]) - validation_results["circuit_power_sensors"] = len([s for s in expected_sensors.values() if s["type"] == "circuit_power"]) - validation_results["collisions"] = 0 # Assume our fix works - - # Final Results - print("\n" + "=" * 60) - print("MIGRATION TEST RESULTS:") - print(f" Panel sensors: {validation_results['panel_sensors']}/6") - print(f" Circuit power sensors: {validation_results['circuit_power_sensors']}/{len(expected_circuits)}") - print(f" Circuit energy sensors: {validation_results['circuit_energy_sensors']}/{len(expected_circuits) * 2}") - print(f" Entity ID collisions: {validation_results['collisions']}") - - # Check for missing circuit power sensors - if validation_results["missing_power_sensors"]: - print("\nMISSING CIRCUIT POWER SENSORS:") - for circuit_id in validation_results["missing_power_sensors"]: - print(f" • {circuit_id[:8]}... (span_nj-2316-005k6_{circuit_id}_power)") - - # Overall success criteria - success = ( - validation_results["panel_sensors"] == 6 and # We have 6 panel sensors, not 4 - validation_results["circuit_power_sensors"] == len(expected_circuits) and - validation_results["collisions"] == 0 and - normalization_passed == normalization_tests - ) - - print("\n" + "=" * 60) - if success: - print("MIGRATION TEST PASSED!") - print(" All panel sensors created without collisions") - print(f" All {len(expected_circuits)} named circuit power sensors created") - print(" Unique_id normalization working correctly") - print(" Ready for v1.0.10 → v1.2.0 migration!") - else: - print("MIGRATION TEST FAILED!") - if validation_results["collisions"] > 0: - print(f" • {validation_results['collisions']} entity ID collisions detected") - if validation_results["circuit_power_sensors"] < len(expected_circuits): - missing = len(expected_circuits) - validation_results["circuit_power_sensors"] - print(f" • {missing} circuit power sensors missing") - if normalization_passed < normalization_tests: - failed = normalization_tests - normalization_passed - print(f" • {failed} normalization tests failed") - - return success - -if __name__ == "__main__": - asyncio.run(test_migration_1_0_10_to_1_2_0()) diff --git a/tests/test_migration_1_0_4_to_1_2_0.py b/tests/test_migration_1_0_4_to_1_2_0.py deleted file mode 100644 index 10e24e88..00000000 --- a/tests/test_migration_1_0_4_to_1_2_0.py +++ /dev/null @@ -1,370 +0,0 @@ -#!/usr/bin/env python3 -"""Test SPAN Panel migration from v1.0.4 to v1.2.0. - -This test validates the complete migration process: -1. Loads real v1.0.4 entity registry data -2. Performs unique_id normalization (Phase 1) -3. Generates complete YAML configuration in migration mode -4. Validates that all expected sensors are created: - - Panel sensors (no collisions, proper entity_ids) - - Named circuit power sensors (for all circuits) - - Circuit energy sensors - - Status sensors -""" - -import asyncio -import json -from pathlib import Path -import shutil -import sys -import tempfile - -import yaml - -# Add the project root to Python path -project_root = Path(__file__).parent.parent -sys.path.insert(0, str(project_root)) - -# Import the real migration module -from custom_components.span_panel.migration import _compute_normalized_unique_id - - -async def test_migration_1_0_4_to_1_2_0(): # noqa: C901 - """Test complete migration from v1.0.4 to v1.2.0.""" - - print("🧪 SPAN Panel Migration Test: v1.0.4 → v1.2.0") - print("=" * 60) - - # Create temporary directory for test registry files - with tempfile.TemporaryDirectory() as temp_dir: - temp_path = Path(temp_dir) - - # Copy registry files to temp location to avoid polluting original test data - source_registry_dir = Path(__file__).parent / "migration_storage" / "1_0_4" - temp_registry_dir = temp_path / "migration_storage" / "1_0_4" - temp_registry_dir.mkdir(parents=True, exist_ok=True) - - # Copy all registry files - for registry_file in ["core.entity_registry", "core.device_registry", "core.config_entries"]: - source_file = source_registry_dir / registry_file - temp_file = temp_registry_dir / registry_file - if source_file.exists(): - shutil.copy2(source_file, temp_file) - print(f"📁 Copied {registry_file} to temp location") - else: - print(f"⚠️ Warning: {registry_file} not found in source") - - # Load the copied registry data - registry_source = temp_registry_dir / "core.entity_registry" - - if not registry_source.exists(): - print(f"❌ Registry file not found: {registry_source}") - return False - - print(f"📁 Loading v1.0.4 registry from temp location: {registry_source}") - - with open(registry_source) as f: - registry_data = json.load(f) - - # Extract SPAN Panel entities - span_entities = [] - for entity in registry_data["data"]["entities"]: - if entity.get("platform") == "span_panel": - span_entities.append(entity) - - print(f"📊 Found {len(span_entities)} SPAN Panel entities in v1.0.4 registry") - - # Categorize entities - panel_entities = [] - circuit_entities = [] - status_entities = [] - - for entity in span_entities: - unique_id = entity["unique_id"] - if any(key in unique_id for key in ["mainMeterEnergy", "feedthroughEnergy", "feedthroughPowerW", "instantGridPowerW"]): - panel_entities.append(entity) - elif any(key in unique_id for key in ["instantPowerW", "producedEnergyWh", "consumedEnergyWh"]): - # Check if this is a circuit entity (has UUID in 3rd position) - parts = unique_id.split('_') - if len(parts) >= 4 and len(parts[2]) == 32 and all(c in '0123456789abcdef' for c in parts[2]): - circuit_entities.append(entity) - else: - status_entities.append(entity) - else: - status_entities.append(entity) - - print(f" • Panel sensors: {len(panel_entities)}") - print(f" • Circuit sensors: {len(circuit_entities)}") - print(f" • Status sensors: {len(status_entities)}") - - # Extract circuit IDs from registry - circuit_ids = set() - circuit_power_sensors = [] - circuit_energy_sensors = [] - - for entity in circuit_entities: - unique_id = entity["unique_id"] - # Circuit pattern: span_serial_circuitid_sensor - # Look for entities with UUID pattern (32 char hex) - parts = unique_id.split('_') - if len(parts) >= 3: - potential_circuit_id = parts[2] # Circuit UUID is 3rd part - # Check if it looks like a UUID (32 hex characters) - if len(potential_circuit_id) == 32 and all(c in '0123456789abcdef' for c in potential_circuit_id): - circuit_ids.add(potential_circuit_id) - - if "instantPowerW" in unique_id: - circuit_power_sensors.append(entity) - elif "EnergyWh" in unique_id: - circuit_energy_sensors.append(entity) - - expected_circuits = sorted(circuit_ids) - print(f" • Unique circuits: {len(expected_circuits)}") - print(f" • Circuit power sensors: {len(circuit_power_sensors)}") - print(f" • Circuit energy sensors: {len(circuit_energy_sensors)}") - - # Phase 1: Test migration normalization - print("\n🔄 Phase 1: Testing unique_id normalization...") - - - normalization_tests = 0 - normalization_passed = 0 - - # Test all panel entities - panel_test_cases = [ - ("mainMeterEnergy.producedEnergyWh", "main_meter_produced_energy"), - ("mainMeterEnergy.consumedEnergyWh", "main_meter_consumed_energy"), - ("feedthroughEnergy.producedEnergyWh", "feed_through_produced_energy"), - ("feedthroughEnergy.consumedEnergyWh", "feed_through_consumed_energy"), - ("feedthroughPowerW", "feed_through_power"), - ("instantGridPowerW", "current_power"), - ] - - for original_suffix, expected_suffix in panel_test_cases: - original_unique_id = f"span_nj-2316-005k6_{original_suffix}" - expected_unique_id = f"span_nj-2316-005k6_{expected_suffix}" - - normalized = _compute_normalized_unique_id(original_unique_id) - normalization_tests += 1 - - if normalized == expected_unique_id: - print(f" ✅ Panel: {original_suffix} → {expected_suffix}") - normalization_passed += 1 - else: - print(f" ❌ Panel: {original_suffix}") - print(f" Expected: {expected_unique_id}") - print(f" Got: {normalized}") - - # Test circuit entities - for circuit_id in expected_circuits[:3]: # Test first 3 circuits - test_cases = [ - (f"span_nj-2316-005k6_{circuit_id}_instantPowerW", f"span_nj-2316-005k6_{circuit_id}_power"), - (f"span_nj-2316-005k6_{circuit_id}_producedEnergyWh", f"span_nj-2316-005k6_{circuit_id}_energy_produced"), - (f"span_nj-2316-005k6_{circuit_id}_consumedEnergyWh", f"span_nj-2316-005k6_{circuit_id}_energy_consumed"), - ] - - for original, expected in test_cases: - normalized = _compute_normalized_unique_id(original) - normalization_tests += 1 - - if normalized == expected: - normalization_passed += 1 - else: - print(f" ❌ Circuit {circuit_id[:8]}...: {original.split('_')[-1]}") - print(f" Expected: {expected}") - print(f" Got: {normalized}") - - print(f" 📊 Normalization: {normalization_passed}/{normalization_tests} passed") - - # Phase 2: Validate expected YAML structure based on migration - print("\n🔍 Phase 2: Validating expected post-migration YAML structure...") - - # After migration, we expect these normalized unique_ids to exist - # and they should match what synthetic generation would produce - from custom_components.span_panel.helpers import ( - construct_synthetic_unique_id, - ) - - expected_sensors = {} - - # Panel sensors - these should exist after migration normalization - panel_api_mapping = { - "instantGridPowerW": "current_power", - "feedthroughPowerW": "feed_through_power", - "mainMeterEnergyProducedWh": "main_meter_produced_energy", - "mainMeterEnergyConsumedWh": "main_meter_consumed_energy", - "feedthroughEnergyProducedWh": "feed_through_produced_energy", - "feedthroughEnergyConsumedWh": "feed_through_consumed_energy", - } - - for api_key, expected_suffix in panel_api_mapping.items(): - synthetic_unique_id = construct_synthetic_unique_id("nj-2316-005k6", expected_suffix) - expected_sensors[synthetic_unique_id] = { - "type": "panel", - "api_key": api_key, - "suffix": expected_suffix - } - - # Circuit sensors - these should be created for all circuits - for circuit_id in expected_circuits: - # Power sensor (this is what was missing!) - power_unique_id = construct_synthetic_unique_id("nj-2316-005k6", f"{circuit_id}_power") - expected_sensors[power_unique_id] = { - "type": "circuit_power", - "circuit_id": circuit_id - } - - # Energy sensors - for energy_type in ["energy_produced", "energy_consumed"]: - energy_unique_id = construct_synthetic_unique_id("nj-2316-005k6", f"{circuit_id}_{energy_type}") - expected_sensors[energy_unique_id] = { - "type": f"circuit_{energy_type}", - "circuit_id": circuit_id - } - - print(" 📊 Expected sensors after migration:") - print(f" • Panel sensors: {len([s for s in expected_sensors.values() if s['type'] == 'panel'])}") - print(f" • Circuit power sensors: {len([s for s in expected_sensors.values() if s['type'] == 'circuit_power'])}") - print(f" • Circuit energy sensors: {len([s for s in expected_sensors.values() if 'circuit_energy' in s['type']])}") - - yaml_content = """version: '1.0' -global_settings: - device_identifier: nj-2316-005k6 - variables: - energy_grace_period_minutes: "15" - -sensors:""" - - # Add a few example sensors to show structure - for unique_id, _sensor_info in list(expected_sensors.items())[:5]: - yaml_content += f""" - "{unique_id}": - name: "Test Sensor - entity_id: sensor.test_entity - formula: state""" - - print(" ✅ Expected YAML structure validated") - - # Phase 3: Validate that live YAML would have the correct structure - print("\n🔍 Phase 3: Comparing with live YAML (v1.0.10 → v1.2.0)...") - - # Read the test YAML if it exists (generated from fixed implementation) - test_yaml_path = "/tmp/span_migration_test_config.yaml" - live_yaml_content = None - - # Try test YAML if it exists - try: - with open(test_yaml_path) as f: - live_yaml_content = f.read() - print(f" ✅ Loaded test YAML: {test_yaml_path}") - except Exception as e: - print(f" ℹ️ No test YAML found at {test_yaml_path}: {e}") - print(" ℹ️ Continuing with validation based on expected structure...") - - if not live_yaml_content: - print(" ℹ️ Continuing with validation based on expected structure...") - - validation_results = { - "panel_sensors": 0, - "circuit_power_sensors": 0, - "circuit_energy_sensors": 0, - "collisions": 0, - "missing_power_sensors": [] - } - - if live_yaml_content: - try: - live_yaml_data = yaml.safe_load(live_yaml_content) - live_sensors = live_yaml_data.get("sensors", {}) - print(f" Live YAML has {len(live_sensors)} sensors") - - # Check what we actually have vs what we expect - panel_sensor_count = 0 - circuit_power_count = 0 - collision_count = 0 - - # Check panel sensors for collisions - expected_panel_keys = [s for s in expected_sensors if expected_sensors[s]["type"] == "panel"] - for sensor_key in expected_panel_keys: - if sensor_key in live_sensors: - panel_sensor_count += 1 - entity_id = live_sensors[sensor_key].get("entity_id", "") - if "_2" in entity_id: - collision_count += 1 - print(f" Live collision detected: {entity_id} (key: {sensor_key})") - else: - print(f" Panel sensor OK: {sensor_key}") - else: - print(f" Missing panel sensor: {sensor_key}") - - # Check circuit power sensors - THE KEY ISSUE - expected_circuit_power_keys = [s for s in expected_sensors if expected_sensors[s]["type"] == "circuit_power"] - for sensor_key in expected_circuit_power_keys: - if sensor_key in live_sensors: - circuit_power_count += 1 - else: - circuit_id = expected_sensors[sensor_key]["circuit_id"] - validation_results["missing_power_sensors"].append(circuit_id) - - validation_results["panel_sensors"] = panel_sensor_count - validation_results["circuit_power_sensors"] = circuit_power_count - validation_results["collisions"] = collision_count - - print(" Live YAML analysis:") - print(f" • Panel sensors found: {panel_sensor_count}/{len(expected_panel_keys)}") - print(f" • Circuit power sensors found: {circuit_power_count}/{len(expected_circuit_power_keys)}") - print(f" • Collisions detected: {collision_count}") - - except Exception as e: - print(f" Could not parse live YAML: {e}") - return False - else: - # Fallback validation based on expected structure - validation_results["panel_sensors"] = len([s for s in expected_sensors.values() if s["type"] == "panel"]) - validation_results["circuit_power_sensors"] = len([s for s in expected_sensors.values() if s["type"] == "circuit_power"]) - validation_results["collisions"] = 0 # Assume our fix works - - # Final Results - print("\n" + "=" * 60) - print("MIGRATION TEST RESULTS:") - print(f" Panel sensors: {validation_results['panel_sensors']}/6") - print(f" Circuit power sensors: {validation_results['circuit_power_sensors']}/{len(expected_circuits)}") - print(f" Circuit energy sensors: {validation_results['circuit_energy_sensors']}/{len(expected_circuits) * 2}") - print(f" Entity ID collisions: {validation_results['collisions']}") - - # Check for missing circuit power sensors - if validation_results["missing_power_sensors"]: - print("\nMISSING CIRCUIT POWER SENSORS:") - for circuit_id in validation_results["missing_power_sensors"]: - print(f" • {circuit_id[:8]}... (span_nj-2316-005k6_{circuit_id}_power)") - - # Overall success criteria - success = ( - validation_results["panel_sensors"] == 6 and # We have 6 panel sensors, not 4 - validation_results["circuit_power_sensors"] == len(expected_circuits) and - validation_results["collisions"] == 0 and - normalization_passed == normalization_tests - ) - - print("\n" + "=" * 60) - if success: - print("MIGRATION TEST PASSED!") - print(" All panel sensors created without collisions") - print(f" All {len(expected_circuits)} named circuit power sensors created") - print(" Unique_id normalization working correctly") - print(" Ready for v1.0.4 → v1.2.0 migration!") - else: - print("MIGRATION TEST FAILED!") - if validation_results["collisions"] > 0: - print(f" • {validation_results['collisions']} entity ID collisions detected") - if validation_results["circuit_power_sensors"] < len(expected_circuits): - missing = len(expected_circuits) - validation_results["circuit_power_sensors"] - print(f" • {missing} circuit power sensors missing") - if normalization_passed < normalization_tests: - failed = normalization_tests - normalization_passed - print(f" • {failed} normalization tests failed") - - return success - -if __name__ == "__main__": - asyncio.run(test_migration_1_0_4_to_1_2_0()) diff --git a/tests/test_offline_simulation.py b/tests/test_offline_simulation.py deleted file mode 100644 index 305b3f39..00000000 --- a/tests/test_offline_simulation.py +++ /dev/null @@ -1,83 +0,0 @@ -"""Test offline simulation functionality.""" - -from datetime import datetime, timedelta - -from custom_components.span_panel.span_panel_api import SpanPanelApi - - -class TestOfflineSimulation: - """Test offline simulation functionality.""" - - def test_offline_simulation_disabled(self): - """Test that offline simulation is disabled by default.""" - api = SpanPanelApi( - host="test-host", - simulation_mode=False, - simulation_offline_minutes=0, - ) - - # Should not be offline when simulation mode is disabled - assert not api._is_panel_offline() - - def test_offline_simulation_zero_minutes(self): - """Test that offline simulation is disabled when minutes is 0.""" - api = SpanPanelApi( - host="test-host", - simulation_mode=True, - simulation_offline_minutes=0, - simulation_start_time=datetime.now(), - ) - - # Should not be offline when minutes is 0 - assert not api._is_panel_offline() - - def test_offline_simulation_no_start_time(self): - """Test that offline simulation is disabled when no start time.""" - api = SpanPanelApi( - host="test-host", - simulation_mode=True, - simulation_offline_minutes=5, - simulation_start_time=None, - ) - - # Should not be offline when no start time - assert not api._is_panel_offline() - - def test_offline_simulation_active(self): - """Test that offline simulation is active within the time window.""" - start_time = datetime.now() - api = SpanPanelApi( - host="test-host", - simulation_mode=True, - simulation_offline_minutes=5, - simulation_start_time=start_time, - ) - - # Should be offline immediately after start - assert api._is_panel_offline() - - def test_offline_simulation_expired(self): - """Test that offline simulation expires after the time window.""" - start_time = datetime.now() - timedelta(minutes=10) # 10 minutes ago - api = SpanPanelApi( - host="test-host", - simulation_mode=True, - simulation_offline_minutes=5, - simulation_start_time=start_time, - ) - - # Should not be offline after 10 minutes (window was 5 minutes) - assert not api._is_panel_offline() - - def test_offline_simulation_boundary(self): - """Test that offline simulation expires exactly at the boundary.""" - start_time = datetime.now() - timedelta(minutes=5) # Exactly 5 minutes ago - api = SpanPanelApi( - host="test-host", - simulation_mode=True, - simulation_offline_minutes=5, - simulation_start_time=start_time, - ) - - # Should not be offline at exactly the boundary (5 minutes) - assert not api._is_panel_offline() diff --git a/tests/test_panel_registration.py b/tests/test_panel_registration.py new file mode 100644 index 00000000..ff524b50 --- /dev/null +++ b/tests/test_panel_registration.py @@ -0,0 +1,172 @@ +"""Tests for integration panel registration.""" + +import pytest +from unittest.mock import AsyncMock, MagicMock, patch + +_ENSURE_RESOURCE_PATCH = ( + "custom_components.span_panel._async_ensure_lovelace_resource" +) + + +async def _fake_executor_job(func, *args): + """Run a function synchronously, mimicking async_add_executor_job.""" + return func(*args) + + +class TestPanelRegistration: + """Tests for sidebar panel registration.""" + + @pytest.mark.asyncio + async def test_panel_registered_on_setup(self): + """Panel is registered when async_setup runs.""" + from custom_components.span_panel import async_setup + + hass = MagicMock() + hass.data = {} + hass.http = MagicMock() + hass.http.async_register_static_paths = AsyncMock() + hass.async_add_executor_job = AsyncMock(side_effect=_fake_executor_job) + hass.services = MagicMock() + hass.services.async_register = MagicMock() + + mock_register = AsyncMock() + + with ( + patch( + "custom_components.span_panel.async_register_panel", + mock_register, + ), + patch( + "custom_components.span_panel.async_load_panel_settings", + return_value={}, + ), + patch( + "custom_components.span_panel.async_remove_panel", + ), + patch(_ENSURE_RESOURCE_PATCH, new_callable=AsyncMock) as mock_ensure, + ): + result = await async_setup(hass, {}) + + assert result is True + hass.http.async_register_static_paths.assert_called_once() + mock_register.assert_called_once() + mock_ensure.assert_called_once() + assert "span-panel-card.js" in mock_ensure.call_args[0][1] + + call_kwargs = mock_register.call_args + assert call_kwargs[1]["sidebar_title"] == "Span Panel" + assert call_kwargs[1]["sidebar_icon"] == "mdi:lightning-bolt" + assert call_kwargs[1]["frontend_url_path"] == "span-panel" + assert "span-panel.js" in call_kwargs[1]["module_url"] + assert call_kwargs[1]["require_admin"] is False + + @pytest.mark.asyncio + async def test_panel_hidden_when_show_panel_false(self): + """Panel is not registered when show_panel is False.""" + from custom_components.span_panel import async_setup + + hass = MagicMock() + hass.data = {} + hass.http = MagicMock() + hass.http.async_register_static_paths = AsyncMock() + hass.async_add_executor_job = AsyncMock(side_effect=_fake_executor_job) + hass.services = MagicMock() + hass.services.async_register = MagicMock() + + mock_register = AsyncMock() + mock_remove = MagicMock() + + with ( + patch( + "custom_components.span_panel.async_register_panel", + mock_register, + ), + patch( + "custom_components.span_panel.async_load_panel_settings", + return_value={"show_panel": False}, + ), + patch( + "custom_components.span_panel.async_remove_panel", + mock_remove, + ), + patch(_ENSURE_RESOURCE_PATCH, new_callable=AsyncMock) as mock_ensure, + ): + result = await async_setup(hass, {}) + + assert result is True + mock_register.assert_not_called() + mock_remove.assert_called_once_with(hass, "span-panel", warn_if_unknown=False) + # Card JS is still registered even when sidebar panel is hidden + mock_ensure.assert_called_once() + + @pytest.mark.asyncio + async def test_panel_admin_only_when_configured(self): + """Panel require_admin matches panel_admin_only setting.""" + from custom_components.span_panel import async_setup + + hass = MagicMock() + hass.data = {} + hass.http = MagicMock() + hass.http.async_register_static_paths = AsyncMock() + hass.async_add_executor_job = AsyncMock(side_effect=_fake_executor_job) + hass.services = MagicMock() + hass.services.async_register = MagicMock() + + mock_register = AsyncMock() + + with ( + patch( + "custom_components.span_panel.async_register_panel", + mock_register, + ), + patch( + "custom_components.span_panel.async_load_panel_settings", + return_value={"show_panel": True, "panel_admin_only": True}, + ), + patch( + "custom_components.span_panel.async_remove_panel", + ), + patch(_ENSURE_RESOURCE_PATCH, new_callable=AsyncMock), + ): + result = await async_setup(hass, {}) + + assert result is True + mock_register.assert_called_once() + assert mock_register.call_args[1]["require_admin"] is True + + @pytest.mark.asyncio + async def test_static_path_serves_frontend_dir(self): + """Static path points to the frontend dist directory.""" + from custom_components.span_panel import async_setup, PANEL_URL + + hass = MagicMock() + hass.data = {} + hass.http = MagicMock() + hass.http.async_register_static_paths = AsyncMock() + hass.async_add_executor_job = AsyncMock(side_effect=_fake_executor_job) + hass.services = MagicMock() + hass.services.async_register = MagicMock() + + with ( + patch( + "custom_components.span_panel.async_register_panel", + AsyncMock(), + ), + patch( + "custom_components.span_panel.async_load_panel_settings", + return_value={}, + ), + patch( + "custom_components.span_panel.async_remove_panel", + ), + patch(_ENSURE_RESOURCE_PATCH, new_callable=AsyncMock), + ): + await async_setup(hass, {}) + + static_call = hass.http.async_register_static_paths.call_args + configs = static_call[0][0] + assert len(configs) == 1 + config = configs[0] + assert config.url_path == PANEL_URL + assert "frontend" in config.path + assert "dist" in config.path diff --git a/tests/test_panel_sensors.py b/tests/test_panel_sensors.py index 0eca0f86..746ed1c9 100644 --- a/tests/test_panel_sensors.py +++ b/tests/test_panel_sensors.py @@ -3,323 +3,241 @@ from unittest.mock import MagicMock import pytest - -from tests.factories import SpanPanelApiResponseFactory -from tests.helpers import make_span_panel_entry - - -class MockSpanPanel: - """Mock SpanPanel for testing.""" - - def __init__(self): - """Initialize mock span panel with test data.""" - # Lazy imports to avoid collection issues - from custom_components.span_panel.span_panel_data import SpanPanelData - from custom_components.span_panel.span_panel_hardware_status import SpanPanelHardwareStatus - - # Create realistic test data - api_responses = SpanPanelApiResponseFactory.create_complete_panel_response() - - # Create real data objects from the factory data - self.status = SpanPanelHardwareStatus.from_dict(api_responses["status"]) - self.panel = SpanPanelData.from_dict(api_responses["panel"]) - - # Add circuits dict (empty for panel sensor tests) - self.circuits = {} - - # Add host attribute that panel sensors expect - self.host = "192.168.1.100" - - # Add host attribute that the sensors expect - self.host = "192.168.1.100" - - # Add api attribute that util functions expect - self.api = MagicMock() - self.api.simulation_mode = False +from span_panel_api import SpanPanelSnapshot + +from custom_components.span_panel.const import ( + DSM_OFF_GRID, + DSM_ON_GRID, + PANEL_ON_GRID, +) +from custom_components.span_panel.sensor import ( + SpanPanelPanelStatus, + SpanPanelStatus, +) +from custom_components.span_panel.sensor_definitions import ( + PANEL_DATA_STATUS_SENSORS, + STATUS_SENSORS, +) + +from .factories import SpanPanelSnapshotFactory +from .helpers import make_span_panel_entry class TestPanelSensors: """Test panel-level sensors.""" @pytest.fixture - def mock_coordinator(self): + def mock_snapshot(self) -> SpanPanelSnapshot: + """Create a snapshot for testing.""" + return SpanPanelSnapshotFactory.create_complete() + + @pytest.fixture + def mock_coordinator(self, mock_snapshot: SpanPanelSnapshot) -> MagicMock: """Create a mock coordinator for testing.""" coordinator = MagicMock() - coordinator.data = MockSpanPanel() + coordinator.data = mock_snapshot coordinator.config_entry = make_span_panel_entry() return coordinator - @pytest.fixture - def mock_span_panel(self): - """Create a mock span panel for testing.""" - return MockSpanPanel() - - def test_panel_data_status_sensors_creation(self, mock_coordinator, mock_span_panel): + def test_panel_data_status_sensors_creation( + self, mock_coordinator: MagicMock, mock_snapshot: SpanPanelSnapshot + ) -> None: """Test that panel data status sensors are created correctly.""" - # Lazy imports to avoid collection issues - from custom_components.span_panel.sensor import SpanPanelPanelStatus - from custom_components.span_panel.sensor_definitions import PANEL_DATA_STATUS_SENSORS - - # Test each panel data status sensor for description in PANEL_DATA_STATUS_SENSORS: - sensor = SpanPanelPanelStatus(mock_coordinator, description, mock_span_panel) - - # Verify sensor properties + sensor = SpanPanelPanelStatus(mock_coordinator, description, mock_snapshot) assert sensor.entity_description == description assert sensor.coordinator == mock_coordinator - # Verify data source is panel data - data_source = sensor.get_data_source(mock_span_panel) - assert data_source == mock_span_panel.panel + # Panel data sensors return the snapshot itself as data source + data_source = sensor.get_data_source(mock_snapshot) + assert data_source is mock_snapshot - def test_hardware_status_sensors_creation(self, mock_coordinator, mock_span_panel): + def test_hardware_status_sensors_creation( + self, mock_coordinator: MagicMock, mock_snapshot: SpanPanelSnapshot + ) -> None: """Test that hardware status sensors are created correctly.""" - # Lazy imports to avoid collection issues - from custom_components.span_panel.sensor import SpanPanelStatus - from custom_components.span_panel.sensor_definitions import STATUS_SENSORS - - # Test each hardware status sensor for description in STATUS_SENSORS: - sensor = SpanPanelStatus(mock_coordinator, description, mock_span_panel) - - # Verify sensor properties + sensor = SpanPanelStatus(mock_coordinator, description, mock_snapshot) assert sensor.entity_description == description assert sensor.coordinator == mock_coordinator - # Verify data source is hardware status - data_source = sensor.get_data_source(mock_span_panel) - assert data_source == mock_span_panel.status + # Status sensors also return the snapshot + data_source = sensor.get_data_source(mock_snapshot) + assert data_source is mock_snapshot - def test_main_relay_state_sensor(self, mock_coordinator, mock_span_panel): + def test_main_relay_state_sensor(self) -> None: """Test the main relay state sensor specifically.""" - # Lazy imports to avoid collection issues - from custom_components.span_panel.sensor import SpanPanelPanelStatus - from custom_components.span_panel.sensor_definitions import PANEL_DATA_STATUS_SENSORS - - # Find the main relay state sensor description - main_relay_description = None - for description in PANEL_DATA_STATUS_SENSORS: - if description.key == "main_relay_state": - main_relay_description = description - break - - assert main_relay_description is not None, "Main relay state sensor not found" - - # Create the sensor - SpanPanelPanelStatus(mock_coordinator, main_relay_description, mock_span_panel) + main_relay_description = next( + d for d in PANEL_DATA_STATUS_SENSORS if d.key == "main_relay_state" + ) - # Test with different relay states - test_cases = [ + test_cases = ( ("CLOSED", "CLOSED"), ("OPEN", "OPEN"), ("UNKNOWN", "UNKNOWN"), - ] + ) for input_state, expected_output in test_cases: - # Set the relay state in mock data - mock_span_panel.panel.main_relay_state = input_state - - # Get the sensor value - sensor_value = main_relay_description.value_fn(mock_span_panel.panel) - + snapshot = SpanPanelSnapshotFactory.create(main_relay_state=input_state) + sensor_value = main_relay_description.value_fn(snapshot) assert sensor_value == expected_output, ( f"Expected {expected_output} for input {input_state}, got {sensor_value}" ) - def test_dsm_state_sensor(self, mock_coordinator, mock_span_panel): - """Test the DSM state sensor.""" - # Lazy imports to avoid collection issues - from custom_components.span_panel.const import DSM_GRID_UP, DSM_ON_GRID - from custom_components.span_panel.sensor import SpanPanelPanelStatus - from custom_components.span_panel.sensor_definitions import PANEL_DATA_STATUS_SENSORS + def test_grid_forming_entity_sensor(self) -> None: + """Test the grid forming entity sensor.""" + description = next( + d for d in PANEL_DATA_STATUS_SENSORS if d.key == "grid_forming_entity" + ) - # Find the DSM state sensor description - dsm_description = None - for description in PANEL_DATA_STATUS_SENSORS: - if description.key == "dsm_state": - dsm_description = description - break + for source in ("GRID", "BATTERY", "PV", "GENERATOR", "NONE"): + snapshot = SpanPanelSnapshotFactory.create(dominant_power_source=source) + assert description.value_fn(snapshot) == source - assert dsm_description is not None, "DSM state sensor not found" + # None defaults to "unknown" + snapshot_none = SpanPanelSnapshotFactory.create(dominant_power_source=None) + assert description.value_fn(snapshot_none) == "unknown" - # Create the sensor - SpanPanelPanelStatus(mock_coordinator, dsm_description, mock_span_panel) + def test_vendor_cloud_sensor(self) -> None: + """Test the vendor cloud sensor.""" + description = next( + d for d in PANEL_DATA_STATUS_SENSORS if d.key == "vendor_cloud" + ) - # Test with valid DSM states - test_states = [DSM_ON_GRID, DSM_GRID_UP] + for state in ("CONNECTED", "UNCONNECTED"): + snapshot = SpanPanelSnapshotFactory.create(vendor_cloud=state) + assert description.value_fn(snapshot) == state - for state in test_states: - mock_span_panel.panel.dsm_state = state - sensor_value = dsm_description.value_fn(mock_span_panel.panel) - assert sensor_value == state + # None defaults to "unknown" + snapshot_none = SpanPanelSnapshotFactory.create(vendor_cloud=None) + assert description.value_fn(snapshot_none) == "unknown" - def test_software_version_sensor(self, mock_coordinator, mock_span_panel): + def test_software_version_sensor(self) -> None: """Test the software version sensor.""" - # Lazy imports to avoid collection issues - from custom_components.span_panel.sensor import SpanPanelStatus - from custom_components.span_panel.sensor_definitions import STATUS_SENSORS - - # Find the software version sensor description - version_description = None - for description in STATUS_SENSORS: - if description.key == "software_version": - version_description = description - break - - assert version_description is not None, "Software version sensor not found" - - # Create the sensor - SpanPanelStatus(mock_coordinator, version_description, mock_span_panel) - - # Test with different firmware versions - test_versions = ["1.2.3", "2.0.1", "1.5.0-beta"] + version_description = next( + d for d in STATUS_SENSORS if d.key == "software_version" + ) - for version in test_versions: - mock_span_panel.status.firmware_version = version - sensor_value = version_description.value_fn(mock_span_panel.status) - assert sensor_value == version + for version in ("1.2.3", "2.0.1", "1.5.0-beta"): + snapshot = SpanPanelSnapshotFactory.create(firmware_version=version) + assert version_description.value_fn(snapshot) == version - def test_current_run_config_sensor(self, mock_coordinator, mock_span_panel): + def test_current_run_config_sensor(self) -> None: """Test the current run config sensor.""" - # Lazy imports to avoid collection issues - from custom_components.span_panel.const import PANEL_ON_GRID - from custom_components.span_panel.sensor import SpanPanelPanelStatus - from custom_components.span_panel.sensor_definitions import PANEL_DATA_STATUS_SENSORS - - # Find the current run config sensor description - config_description = None - for description in PANEL_DATA_STATUS_SENSORS: - if description.key == "current_run_config": - config_description = description - break - - assert config_description is not None, "Current run config sensor not found" - - # Create the sensor - SpanPanelPanelStatus(mock_coordinator, config_description, mock_span_panel) - - # Test with valid run configs - test_configs = [PANEL_ON_GRID] + config_description = next( + d for d in PANEL_DATA_STATUS_SENSORS if d.key == "current_run_config" + ) - for config in test_configs: - mock_span_panel.panel.current_run_config = config - sensor_value = config_description.value_fn(mock_span_panel.panel) - assert sensor_value == config + snapshot = SpanPanelSnapshotFactory.create(current_run_config=PANEL_ON_GRID) + assert config_description.value_fn(snapshot) == PANEL_ON_GRID - def test_sensor_unique_ids(self, mock_coordinator, mock_span_panel): + def test_sensor_unique_ids( + self, mock_coordinator: MagicMock, mock_snapshot: SpanPanelSnapshot + ) -> None: """Test that sensors have correct unique IDs.""" - # Lazy imports to avoid collection issues - from custom_components.span_panel.sensor import SpanPanelPanelStatus - from custom_components.span_panel.sensor_definitions import PANEL_DATA_STATUS_SENSORS - - # Test panel data status sensor unique ID - main_relay_description = None - for description in PANEL_DATA_STATUS_SENSORS: - if description.key == "main_relay_state": - main_relay_description = description - break - - sensor = SpanPanelPanelStatus(mock_coordinator, main_relay_description, mock_span_panel) + main_relay_description = next( + d for d in PANEL_DATA_STATUS_SENSORS if d.key == "main_relay_state" + ) - # Check that unique ID includes the serial number and key (serial is lowercased) + sensor = SpanPanelPanelStatus( + mock_coordinator, main_relay_description, mock_snapshot + ) expected_unique_id = ( - f"span_{mock_span_panel.status.serial_number.lower()}_{main_relay_description.key}" + f"span_{mock_snapshot.serial_number.lower()}_{main_relay_description.key}" ) assert sensor.unique_id == expected_unique_id - def test_sensor_names(self, mock_coordinator, mock_span_panel): - """Test that sensors have correct names.""" - # Lazy imports to avoid collection issues - from custom_components.span_panel.sensor import SpanPanelPanelStatus - from custom_components.span_panel.sensor_definitions import PANEL_DATA_STATUS_SENSORS - - # Test each panel data status sensor name + def test_sensor_translation_keys( + self, mock_coordinator: MagicMock, mock_snapshot: SpanPanelSnapshot + ) -> None: + """Test that panel sensors have translation keys set.""" for description in PANEL_DATA_STATUS_SENSORS: - sensor = SpanPanelPanelStatus(mock_coordinator, description, mock_span_panel) - - # Name should include description name - assert description.name in sensor.name - - # Lazy imports to avoid collection issues - from custom_components.span_panel.sensor import SpanPanelStatus - from custom_components.span_panel.sensor_definitions import STATUS_SENSORS + sensor = SpanPanelPanelStatus(mock_coordinator, description, mock_snapshot) + assert description.translation_key is not None + assert ( + sensor.entity_description.translation_key == description.translation_key + ) - # Test each hardware status sensor name for description in STATUS_SENSORS: - sensor = SpanPanelStatus(mock_coordinator, description, mock_span_panel) - - # Name should include description name - assert description.name in sensor.name - - def test_main_relay_state_with_real_data(self): - """Test main relay state sensor with real factory data.""" - # Lazy imports to avoid collection issues - from custom_components.span_panel.sensor_definitions import PANEL_DATA_STATUS_SENSORS - from custom_components.span_panel.span_panel_data import SpanPanelData - - # Create test data using the factory - api_responses = SpanPanelApiResponseFactory.create_complete_panel_response() - - # Ensure main relay state is CLOSED in the test data - api_responses["panel"]["mainRelayState"] = "CLOSED" + sensor = SpanPanelStatus(mock_coordinator, description, mock_snapshot) + assert description.translation_key is not None + assert ( + sensor.entity_description.translation_key == description.translation_key + ) - # Create real panel data object - panel_data = SpanPanelData.from_dict(api_responses["panel"]) + def test_main_relay_state_with_real_data(self) -> None: + """Test main relay state sensor with real snapshot data.""" + main_relay_description = next( + d for d in PANEL_DATA_STATUS_SENSORS if d.key == "main_relay_state" + ) - # Find the main relay state sensor description - main_relay_description = None - for description in PANEL_DATA_STATUS_SENSORS: - if description.key == "main_relay_state": - main_relay_description = description - break - - # Test the value function - sensor_value = main_relay_description.value_fn(panel_data) - assert sensor_value == "CLOSED" - - # Test with OPEN state - api_responses["panel"]["mainRelayState"] = "OPEN" - panel_data = SpanPanelData.from_dict(api_responses["panel"]) - sensor_value = main_relay_description.value_fn(panel_data) - assert sensor_value == "OPEN" - - def test_dsm_states_with_real_data(self): - """Test DSM state sensors with real factory data.""" - # Lazy imports to avoid collection issues - from custom_components.span_panel.const import DSM_GRID_UP, DSM_ON_GRID - from custom_components.span_panel.sensor_definitions import PANEL_DATA_STATUS_SENSORS - from custom_components.span_panel.span_panel_data import SpanPanelData - - # Create test data using the factory - api_responses = SpanPanelApiResponseFactory.create_complete_panel_response() - - # Test DSM state - api_responses["panel"]["dsmState"] = DSM_ON_GRID - panel_data = SpanPanelData.from_dict(api_responses["panel"]) - - # Find the DSM state sensor description - dsm_description = None - for description in PANEL_DATA_STATUS_SENSORS: - if description.key == "dsm_state": - dsm_description = description - break + snapshot_closed = SpanPanelSnapshotFactory.create(main_relay_state="CLOSED") + assert main_relay_description.value_fn(snapshot_closed) == "CLOSED" - sensor_value = dsm_description.value_fn(panel_data) - assert sensor_value == DSM_ON_GRID + snapshot_open = SpanPanelSnapshotFactory.create(main_relay_state="OPEN") + assert main_relay_description.value_fn(snapshot_open) == "OPEN" - # Test DSM grid state - api_responses["panel"]["dsmGridState"] = DSM_GRID_UP - panel_data = SpanPanelData.from_dict(api_responses["panel"]) + def test_dsm_state_with_real_data(self) -> None: + """Test DSM state sensor with real snapshot data.""" + dsm_description = next( + d for d in PANEL_DATA_STATUS_SENSORS if d.key == "dsm_state" + ) - # Find the DSM grid state sensor description - dsm_grid_description = None - for description in PANEL_DATA_STATUS_SENSORS: - if description.key == "dsm_grid_state": - dsm_grid_description = description - break + snapshot_on = SpanPanelSnapshotFactory.create(dsm_state=DSM_ON_GRID) + assert dsm_description.value_fn(snapshot_on) == DSM_ON_GRID + + snapshot_off = SpanPanelSnapshotFactory.create(dsm_state=DSM_OFF_GRID) + assert dsm_description.value_fn(snapshot_off) == DSM_OFF_GRID + + def test_software_version_extra_state_attributes_panel_size( + self, mock_coordinator: MagicMock + ) -> None: + """Test that panel_size is always present in extra_state_attributes.""" + description = next(d for d in STATUS_SENSORS if d.key == "software_version") + + snapshot_with_size = SpanPanelSnapshotFactory.create(panel_size=32) + mock_coordinator.data = snapshot_with_size + sensor = SpanPanelStatus(mock_coordinator, description, snapshot_with_size) + attrs = sensor.extra_state_attributes + assert attrs is not None + assert attrs["panel_size"] == 32 + + def test_software_version_extra_state_attributes_wifi_ssid( + self, mock_coordinator: MagicMock + ) -> None: + """Test that wifi_ssid appears in extra_state_attributes when present.""" + description = next(d for d in STATUS_SENSORS if d.key == "software_version") + + snapshot = SpanPanelSnapshotFactory.create(panel_size=24, wifi_ssid="MyNetwork") + mock_coordinator.data = snapshot + sensor = SpanPanelStatus(mock_coordinator, description, snapshot) + attrs = sensor.extra_state_attributes + assert attrs is not None + assert attrs["panel_size"] == 24 + assert attrs["wifi_ssid"] == "MyNetwork" + + def test_software_version_extra_state_attributes_no_wifi( + self, mock_coordinator: MagicMock + ) -> None: + """Test that wifi_ssid is omitted when None.""" + description = next(d for d in STATUS_SENSORS if d.key == "software_version") + + snapshot = SpanPanelSnapshotFactory.create(panel_size=16, wifi_ssid=None) + mock_coordinator.data = snapshot + sensor = SpanPanelStatus(mock_coordinator, description, snapshot) + attrs = sensor.extra_state_attributes + assert attrs is not None + assert "wifi_ssid" not in attrs + assert attrs["panel_size"] == 16 + + def test_dsm_grid_state_deprecated_alias(self) -> None: + """Test DSM Grid State reads from dsm_state (deprecated alias).""" + dsm_grid_description = next( + d for d in PANEL_DATA_STATUS_SENSORS if d.key == "dsm_grid_state" + ) - sensor_value = dsm_grid_description.value_fn(panel_data) - assert sensor_value == DSM_GRID_UP + snapshot = SpanPanelSnapshotFactory.create(dsm_state=DSM_ON_GRID) + assert dsm_grid_description.value_fn(snapshot) == DSM_ON_GRID if __name__ == "__main__": diff --git a/tests/test_pattern_detection.py b/tests/test_pattern_detection.py deleted file mode 100644 index d888a586..00000000 --- a/tests/test_pattern_detection.py +++ /dev/null @@ -1,96 +0,0 @@ -#!/usr/bin/env python3 -"""Test script to verify entity naming pattern detection logic.""" - -import os -import sys - -# Add the custom_components path to sys.path -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "custom_components")) - -from custom_components.span_panel.const import ( - USE_CIRCUIT_NUMBERS, - EntityNamingPattern, -) - - -def test_pattern_detection(): - """Test the pattern detection logic.""" - - class MockEntry: - def __init__(self, options): - self.options = options - - class MockOptionsFlowHandler: - def __init__(self, entry): - self.entry = entry - - def _get_current_naming_pattern(self) -> str: - """Determine the current entity naming pattern from configuration flags.""" - use_circuit_numbers = self.entry.options.get(USE_CIRCUIT_NUMBERS, False) - - if use_circuit_numbers: - return EntityNamingPattern.CIRCUIT_NUMBERS.value - else: - return EntityNamingPattern.FRIENDLY_NAMES.value - - # Test cases - test_cases = [ - { - "name": "New installation (1.0.9+)", - "options": {USE_CIRCUIT_NUMBERS: True, "use_device_prefix": True}, - "expected": EntityNamingPattern.CIRCUIT_NUMBERS.value, - }, - { - "name": "Post-1.0.4 installation", - "options": {USE_CIRCUIT_NUMBERS: False, "use_device_prefix": True}, - "expected": EntityNamingPattern.FRIENDLY_NAMES.value, - }, - { - "name": "Pre-1.0.4 installation", - "options": {USE_CIRCUIT_NUMBERS: False, "use_device_prefix": False}, - "expected": EntityNamingPattern.FRIENDLY_NAMES.value, - }, - { - "name": "Empty options (default)", - "options": {}, - "expected": EntityNamingPattern.FRIENDLY_NAMES.value, - }, - { - "name": "Only circuit numbers flag", - "options": {USE_CIRCUIT_NUMBERS: True}, - "expected": EntityNamingPattern.CIRCUIT_NUMBERS.value, - }, - ] - - print("Testing entity naming pattern detection...") - print() - - all_passed = True - for test_case in test_cases: - entry = MockEntry(test_case["options"]) - handler = MockOptionsFlowHandler(entry) - - result = handler._get_current_naming_pattern() - passed = result == test_case["expected"] - - status = "✅ PASS" if passed else "❌ FAIL" - print(f"{status} {test_case['name']}") - print(f" Options: {test_case['options']}") - print(f" Expected: {test_case['expected']}") - print(f" Got: {result}") - print() - - if not passed: - all_passed = False - - print("=" * 50) - if all_passed: - print("✅ All tests passed! Pattern detection logic is correct.") - else: - print("❌ Some tests failed! Check the logic.") - - assert all_passed, "Some pattern detection tests failed" - - -if __name__ == "__main__": - test_pattern_detection() diff --git a/tests/test_promoted_sensors.py b/tests/test_promoted_sensors.py new file mode 100644 index 00000000..d1111c0c --- /dev/null +++ b/tests/test_promoted_sensors.py @@ -0,0 +1,441 @@ +"""Tests for promoted sensor entities (Phase 1-4 of sensor promotion plan). + +Covers: +- Panel diagnostic sensors (voltage, lug currents, breaker rating) +- Grid islandable binary sensor +- BESS metadata sensors and sub-device info +- PV metadata sensors +- EVSE sensor value functions +- Conditional sensor creation gating +- BESS unique ID helpers +""" + +# ruff: noqa: D102 + +from span_panel_api import SpanPVSnapshot + +from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass +from custom_components.span_panel.binary_sensor import GRID_ISLANDABLE_SENSOR +from custom_components.span_panel.const import DOMAIN +from custom_components.span_panel.helpers import ( + build_bess_unique_id, + has_bess, + has_evse, + has_pv, +) +from custom_components.span_panel.sensor_definitions import ( + BESS_METADATA_SENSORS, + CIRCUIT_BREAKER_RATING_SENSOR, + CIRCUIT_CURRENT_SENSOR, + DOWNSTREAM_L1_CURRENT_SENSOR, + DOWNSTREAM_L2_CURRENT_SENSOR, + EVSE_SENSORS, + L1_VOLTAGE_SENSOR, + L2_VOLTAGE_SENSOR, + MAIN_BREAKER_RATING_SENSOR, + PV_METADATA_SENSORS, + UPSTREAM_L1_CURRENT_SENSOR, + UPSTREAM_L2_CURRENT_SENSOR, +) +from custom_components.span_panel.util import bess_device_info +from homeassistant.helpers.entity import EntityCategory + +from .factories import ( + SpanBatterySnapshotFactory, + SpanEvseSnapshotFactory, + SpanPanelSnapshotFactory, +) + +PANEL_DIAGNOSTIC_SENSORS = ( + L1_VOLTAGE_SENSOR, + L2_VOLTAGE_SENSOR, + UPSTREAM_L1_CURRENT_SENSOR, + UPSTREAM_L2_CURRENT_SENSOR, + DOWNSTREAM_L1_CURRENT_SENSOR, + DOWNSTREAM_L2_CURRENT_SENSOR, + MAIN_BREAKER_RATING_SENSOR, +) + +# --------------------------------------------------------------------------- +# Phase 1: Panel diagnostic sensors +# --------------------------------------------------------------------------- + + +class TestPanelDiagnosticSensorDefinitions: + """Test panel diagnostic sensor definitions (voltage, currents, breaker).""" + + def test_l1_voltage_definition(self): + desc = next(d for d in PANEL_DIAGNOSTIC_SENSORS if d.key == "l1_voltage") + assert desc.translation_key == "l1_voltage" + assert desc.device_class == SensorDeviceClass.VOLTAGE + assert desc.state_class == SensorStateClass.MEASUREMENT + assert desc.entity_category == EntityCategory.DIAGNOSTIC + + def test_l2_voltage_definition(self): + desc = next(d for d in PANEL_DIAGNOSTIC_SENSORS if d.key == "l2_voltage") + assert desc.translation_key == "l2_voltage" + assert desc.device_class == SensorDeviceClass.VOLTAGE + + def test_upstream_l1_current_definition(self): + desc = next( + d for d in PANEL_DIAGNOSTIC_SENSORS if d.key == "upstream_l1_current" + ) + assert desc.translation_key == "upstream_l1_current" + assert desc.device_class == SensorDeviceClass.CURRENT + assert desc.state_class == SensorStateClass.MEASUREMENT + assert desc.entity_category == EntityCategory.DIAGNOSTIC + + def test_downstream_l2_current_definition(self): + desc = next( + d for d in PANEL_DIAGNOSTIC_SENSORS if d.key == "downstream_l2_current" + ) + assert desc.device_class == SensorDeviceClass.CURRENT + + def test_main_breaker_rating_definition(self): + desc = next( + d for d in PANEL_DIAGNOSTIC_SENSORS if d.key == "main_breaker_rating" + ) + assert desc.translation_key == "main_breaker_rating" + assert desc.device_class is None + assert desc.entity_category == EntityCategory.DIAGNOSTIC + assert desc.entity_registry_enabled_default is False + + def test_voltage_value_functions(self): + snapshot = SpanPanelSnapshotFactory.create(l1_voltage=121.5, l2_voltage=119.3) + l1 = next(d for d in PANEL_DIAGNOSTIC_SENSORS if d.key == "l1_voltage") + l2 = next(d for d in PANEL_DIAGNOSTIC_SENSORS if d.key == "l2_voltage") + assert l1.value_fn(snapshot) == 121.5 + assert l2.value_fn(snapshot) == 119.3 + + def test_lug_current_value_functions(self): + snapshot = SpanPanelSnapshotFactory.create() + # Default snapshot has None for these fields + for key in ( + "upstream_l1_current", + "upstream_l2_current", + "downstream_l1_current", + "downstream_l2_current", + ): + desc = next(d for d in PANEL_DIAGNOSTIC_SENSORS if d.key == key) + assert desc.value_fn(snapshot) is None + + def test_main_breaker_rating_value_function(self): + snapshot = SpanPanelSnapshotFactory.create(main_breaker_rating_a=200) + desc = next( + d for d in PANEL_DIAGNOSTIC_SENSORS if d.key == "main_breaker_rating" + ) + assert desc.value_fn(snapshot) == 200 + + def test_main_breaker_rating_none(self): + snapshot = SpanPanelSnapshotFactory.create(main_breaker_rating_a=None) + desc = next( + d for d in PANEL_DIAGNOSTIC_SENSORS if d.key == "main_breaker_rating" + ) + assert desc.value_fn(snapshot) is None + + def test_all_diagnostic_sensors_have_translation_keys(self): + for desc in PANEL_DIAGNOSTIC_SENSORS: + assert desc.translation_key is not None, ( + f"Sensor {desc.key} missing translation_key" + ) + + +# --------------------------------------------------------------------------- +# Phase 1.4: Grid islandable binary sensor +# --------------------------------------------------------------------------- + + +class TestGridIslandableBinarySensor: + """Test grid_islandable binary sensor definition.""" + + def test_definition_exists(self): + assert GRID_ISLANDABLE_SENSOR.key == "grid_islandable" + assert GRID_ISLANDABLE_SENSOR.translation_key == "grid_islandable" + assert GRID_ISLANDABLE_SENSOR.entity_category == EntityCategory.DIAGNOSTIC + + def test_value_function_true(self): + snapshot = SpanPanelSnapshotFactory.create(grid_islandable=True) + assert GRID_ISLANDABLE_SENSOR.value_fn(snapshot) is True + + def test_value_function_false(self): + snapshot = SpanPanelSnapshotFactory.create(grid_islandable=False) + assert GRID_ISLANDABLE_SENSOR.value_fn(snapshot) is False + + def test_value_function_none(self): + snapshot = SpanPanelSnapshotFactory.create(grid_islandable=None) + assert GRID_ISLANDABLE_SENSOR.value_fn(snapshot) is None + + +# --------------------------------------------------------------------------- +# Phase 2: Circuit current and breaker rating sensors +# --------------------------------------------------------------------------- + + +class TestCircuitSensorDefinitions: + """Test circuit-level promoted sensor definitions.""" + + def test_circuit_current_definition(self): + assert CIRCUIT_CURRENT_SENSOR.key == "circuit_current" + assert CIRCUIT_CURRENT_SENSOR.device_class == SensorDeviceClass.CURRENT + assert CIRCUIT_CURRENT_SENSOR.state_class == SensorStateClass.MEASUREMENT + assert CIRCUIT_CURRENT_SENSOR.name == "Current" + + def test_circuit_breaker_rating_definition(self): + assert CIRCUIT_BREAKER_RATING_SENSOR.key == "circuit_breaker_rating" + assert CIRCUIT_BREAKER_RATING_SENSOR.device_class is None + assert ( + CIRCUIT_BREAKER_RATING_SENSOR.entity_category == EntityCategory.DIAGNOSTIC + ) + assert CIRCUIT_BREAKER_RATING_SENSOR.name == "Breaker Rating" + assert CIRCUIT_BREAKER_RATING_SENSOR.entity_registry_enabled_default is False + + +# --------------------------------------------------------------------------- +# Phase 3: BESS sub-device and metadata sensors +# --------------------------------------------------------------------------- + + +class TestBessDeviceInfo: + """Test BESS device info construction.""" + + def test_bess_device_info_basic(self): + battery = SpanBatterySnapshotFactory.create( + vendor_name="Tesla", + product_name="Powerwall 2", + serial_number="TW-001", + software_version="2.1.0", + ) + info = bess_device_info("sp3-242424-001", battery, "My Panel") + assert info.get("identifiers") == {(DOMAIN, "sp3-242424-001_bess")} + assert info.get("name") == "My Panel Battery" + assert info.get("manufacturer") == "Tesla" + assert info.get("model") == "Powerwall 2" + assert info.get("serial_number") == "TW-001" + assert info.get("sw_version") == "2.1.0" + assert info.get("via_device") == (DOMAIN, "sp3-242424-001") + + def test_bess_device_info_defaults_when_none(self): + battery = SpanBatterySnapshotFactory.create() + info = bess_device_info("serial", battery, "Panel") + assert info.get("manufacturer") == "Unknown" + assert info.get("model") == "Battery Storage" + assert info.get("serial_number") is None + assert info.get("sw_version") is None + + +class TestBessMetadataSensorDefinitions: + """Test BESS metadata sensor definitions.""" + + def test_sensor_count(self): + assert len(BESS_METADATA_SENSORS) == 6 + + def test_all_have_translation_keys(self): + for desc in BESS_METADATA_SENSORS: + assert desc.translation_key is not None, ( + f"BESS sensor {desc.key} missing translation_key" + ) + + def test_all_are_diagnostic(self): + for desc in BESS_METADATA_SENSORS: + assert desc.entity_category == EntityCategory.DIAGNOSTIC, ( + f"BESS sensor {desc.key} not diagnostic" + ) + + def test_vendor_value_function(self): + battery = SpanBatterySnapshotFactory.create(vendor_name="Enphase") + desc = next(d for d in BESS_METADATA_SENSORS if d.key == "vendor") + assert desc.value_fn(battery) == "Enphase" + + def test_model_value_function(self): + battery = SpanBatterySnapshotFactory.create(product_name="IQ Battery 10") + desc = next(d for d in BESS_METADATA_SENSORS if d.key == "model") + assert desc.value_fn(battery) == "IQ Battery 10" + + def test_serial_number_value_function(self): + battery = SpanBatterySnapshotFactory.create(serial_number="BESS-12345") + desc = next(d for d in BESS_METADATA_SENSORS if d.key == "serial_number") + assert desc.value_fn(battery) == "BESS-12345" + + def test_firmware_version_value_function(self): + battery = SpanBatterySnapshotFactory.create(software_version="3.0.1") + desc = next(d for d in BESS_METADATA_SENSORS if d.key == "firmware_version") + assert desc.value_fn(battery) == "3.0.1" + + def test_nameplate_capacity_value_function(self): + battery = SpanBatterySnapshotFactory.create(nameplate_capacity_kwh=13.5) + desc = next(d for d in BESS_METADATA_SENSORS if d.key == "nameplate_capacity") + assert desc.value_fn(battery) == 13.5 + assert desc.device_class is None + assert desc.entity_registry_enabled_default is False + + def test_soe_kwh_value_function(self): + battery = SpanBatterySnapshotFactory.create(soe_kwh=10.2) + desc = next(d for d in BESS_METADATA_SENSORS if d.key == "soe_kwh") + assert desc.value_fn(battery) == 10.2 + assert desc.device_class == SensorDeviceClass.ENERGY_STORAGE + assert desc.state_class == SensorStateClass.MEASUREMENT + + def test_none_metadata_returns_none(self): + battery = SpanBatterySnapshotFactory.create() + for desc in BESS_METADATA_SENSORS: + if desc.key in ("vendor", "model", "serial_number", "firmware_version"): + assert desc.value_fn(battery) is None, f"{desc.key} should be None" + + +class TestBessUniqueId: + """Test BESS unique ID helper.""" + + def test_build_bess_unique_id(self): + result = build_bess_unique_id("sp3-serial-001", "vendor") + assert result == "span_sp3-serial-001_bess_vendor" + + def test_build_bess_unique_id_different_keys(self): + for key in ( + "vendor", + "model", + "serial_number", + "firmware_version", + "nameplate_capacity", + "soe_kwh", + ): + result = build_bess_unique_id("serial", key) + assert result == f"span_serial_bess_{key}" + + +# --------------------------------------------------------------------------- +# Phase 4: PV metadata sensors +# --------------------------------------------------------------------------- + + +class TestPVMetadataSensorDefinitions: + """Test PV metadata sensor definitions.""" + + def test_sensor_count(self): + assert len(PV_METADATA_SENSORS) == 3 + + def test_all_have_translation_keys(self): + for desc in PV_METADATA_SENSORS: + assert desc.translation_key is not None, ( + f"PV sensor {desc.key} missing translation_key" + ) + + def test_all_are_diagnostic(self): + for desc in PV_METADATA_SENSORS: + assert desc.entity_category == EntityCategory.DIAGNOSTIC, ( + f"PV sensor {desc.key} not diagnostic" + ) + + def test_pv_vendor_value_function(self): + snapshot = SpanPanelSnapshotFactory.create( + pv=SpanPVSnapshot(vendor_name="SolarEdge") + ) + desc = next(d for d in PV_METADATA_SENSORS if d.key == "pv_vendor") + assert desc.value_fn(snapshot) == "SolarEdge" + + def test_pv_product_value_function(self): + snapshot = SpanPanelSnapshotFactory.create( + pv=SpanPVSnapshot(product_name="SE7600H") + ) + desc = next(d for d in PV_METADATA_SENSORS if d.key == "pv_product") + assert desc.value_fn(snapshot) == "SE7600H" + + def test_pv_nameplate_capacity_value_function(self): + snapshot = SpanPanelSnapshotFactory.create( + pv=SpanPVSnapshot(nameplate_capacity_w=7600.0) + ) + desc = next(d for d in PV_METADATA_SENSORS if d.key == "pv_nameplate_capacity") + assert desc.value_fn(snapshot) == 7600.0 + assert desc.device_class is None + assert desc.entity_registry_enabled_default is False + + def test_pv_none_metadata(self): + snapshot = SpanPanelSnapshotFactory.create(pv=SpanPVSnapshot()) + for desc in PV_METADATA_SENSORS: + assert desc.value_fn(snapshot) is None, f"PV {desc.key} should be None" + + +# --------------------------------------------------------------------------- +# EVSE sensor value functions +# --------------------------------------------------------------------------- + + +class TestEvseSensorDefinitions: + """Test EVSE sensor definitions.""" + + def test_sensor_count(self): + assert len(EVSE_SENSORS) == 3 + + def test_all_have_translation_keys(self): + for desc in EVSE_SENSORS: + assert desc.translation_key is not None, ( + f"EVSE sensor {desc.key} missing translation_key" + ) + + def test_evse_status_value_function(self): + evse = SpanEvseSnapshotFactory.create(status="CHARGING") + desc = next(d for d in EVSE_SENSORS if d.key == "evse_status") + assert desc.value_fn(evse) == "CHARGING" + + def test_evse_status_empty_falls_back(self): + evse = SpanEvseSnapshotFactory.create(status="") + desc = next(d for d in EVSE_SENSORS if d.key == "evse_status") + assert desc.value_fn(evse) == "unknown" + + def test_evse_advertised_current(self): + evse = SpanEvseSnapshotFactory.create(advertised_current_a=32.0) + desc = next(d for d in EVSE_SENSORS if d.key == "evse_advertised_current") + assert desc.value_fn(evse) == 32.0 + + def test_evse_lock_state(self): + evse = SpanEvseSnapshotFactory.create(lock_state="LOCKED") + desc = next(d for d in EVSE_SENSORS if d.key == "evse_lock_state") + assert desc.value_fn(evse) == "LOCKED" + + def test_evse_lock_state_empty_falls_back(self): + evse = SpanEvseSnapshotFactory.create(lock_state="") + desc = next(d for d in EVSE_SENSORS if d.key == "evse_lock_state") + assert desc.value_fn(evse) == "unknown" + + +# --------------------------------------------------------------------------- +# Conditional creation gating +# --------------------------------------------------------------------------- + + +class TestConditionalSensorCreation: + """Test that conditional sensors are gated on snapshot data.""" + + def test_has_bess_true(self): + battery = SpanBatterySnapshotFactory.create(soe_percentage=50.0) + snapshot = SpanPanelSnapshotFactory.create(battery=battery) + assert has_bess(snapshot) is True + + def test_has_bess_false_no_soe(self): + battery = SpanBatterySnapshotFactory.create(soe_percentage=None) + snapshot = SpanPanelSnapshotFactory.create(battery=battery) + assert has_bess(snapshot) is False + + def test_has_pv_true(self): + snapshot = SpanPanelSnapshotFactory.create(power_flow_pv=-3500.0) + assert has_pv(snapshot) is True + + def test_has_pv_false(self): + snapshot = SpanPanelSnapshotFactory.create(power_flow_pv=None) + assert has_pv(snapshot) is False + + def test_has_evse_true(self): + evse = {"evse-0": SpanEvseSnapshotFactory.create()} + snapshot = SpanPanelSnapshotFactory.create(evse=evse) + assert has_evse(snapshot) is True + + def test_has_evse_false(self): + snapshot = SpanPanelSnapshotFactory.create(evse={}) + assert has_evse(snapshot) is False + + def test_diagnostic_sensors_none_gated(self): + snapshot = SpanPanelSnapshotFactory.create( + l1_voltage=None, l2_voltage=None, main_breaker_rating_a=None + ) + for desc in PANEL_DIAGNOSTIC_SENSORS: + if desc.key in ("l1_voltage", "l2_voltage", "main_breaker_rating"): + assert desc.value_fn(snapshot) is None diff --git a/tests/test_retry_configuration.py b/tests/test_retry_configuration.py deleted file mode 100644 index 84a7c535..00000000 --- a/tests/test_retry_configuration.py +++ /dev/null @@ -1,129 +0,0 @@ -"""Test retry configuration in config flow and options.""" - - -from homeassistant.config_entries import ConfigEntry - -from custom_components.span_panel.config_flow import create_config_client -from custom_components.span_panel.const import ( - CONF_API_RETRIES, - CONF_API_RETRY_BACKOFF_MULTIPLIER, - CONF_API_RETRY_TIMEOUT, - CONFIG_API_RETRIES, - CONFIG_TIMEOUT, - DEFAULT_API_RETRIES, - DEFAULT_API_RETRY_BACKOFF_MULTIPLIER, - DEFAULT_API_RETRY_TIMEOUT, -) -from custom_components.span_panel.options import Options - - -class TestRetryConfiguration: - """Test retry configuration functionality.""" - - def test_create_config_client_uses_config_settings(self): - """Test that create_config_client function exists and can be called.""" - # Simple test to verify the function works without complex mocking - # The actual SpanPanelClient behavior is tested elsewhere - try: - client = create_config_client("192.168.1.100", use_ssl=False) - # Just verify we get some kind of object back - assert client is not None - except Exception as e: - # If span_panel_api isn't available, that's expected in test environment - # The important thing is that the function exists and has the right signature - assert "span_panel_api" in str(e) or "SpanPanelClient" in str(e) - - def test_options_with_default_retry_settings(self): - """Test that Options class uses default retry settings when not configured.""" - # Create a mock config entry with no retry options - mock_entry = ConfigEntry( - version=1, - minor_version=1, - domain="span_panel", - title="Test Panel", - data={}, - options={}, # No retry options - source="test", - entry_id="test_entry", - discovery_keys={}, - subentries_data={}, - unique_id="test_unique_id", - ) - - options = Options(mock_entry) - - # Verify default values are used - assert options.api_retries == DEFAULT_API_RETRIES - assert options.api_retry_timeout == DEFAULT_API_RETRY_TIMEOUT - assert options.api_retry_backoff_multiplier == DEFAULT_API_RETRY_BACKOFF_MULTIPLIER - - def test_options_with_custom_retry_settings(self): - """Test that Options class respects custom retry settings.""" - # Create a mock config entry with custom retry options - custom_options = { - CONF_API_RETRIES: 5, - CONF_API_RETRY_TIMEOUT: 1.0, - CONF_API_RETRY_BACKOFF_MULTIPLIER: 3.0, - } - - mock_entry = ConfigEntry( - version=1, - minor_version=1, - domain="span_panel", - title="Test Panel", - data={}, - options=custom_options, - source="test", - entry_id="test_entry", - discovery_keys={}, - subentries_data={}, - unique_id="test_unique_id", - ) - - options = Options(mock_entry) - - # Verify custom values are used - assert options.api_retries == 5 - assert options.api_retry_timeout == 1.0 - assert options.api_retry_backoff_multiplier == 3.0 - - def test_options_get_options_includes_retry_settings(self): - """Test that get_options() includes retry configuration.""" - custom_options = { - CONF_API_RETRIES: 2, - CONF_API_RETRY_TIMEOUT: 0.8, - CONF_API_RETRY_BACKOFF_MULTIPLIER: 1.5, - } - - mock_entry = ConfigEntry( - version=1, - minor_version=1, - domain="span_panel", - title="Test Panel", - data={}, - options=custom_options, - source="test", - entry_id="test_entry", - discovery_keys={}, - subentries_data={}, - unique_id="test_unique_id", - ) - - options = Options(mock_entry) - result = options.get_options() - - # Verify retry settings are included in the returned options - assert result[CONF_API_RETRIES] == 2 - assert result[CONF_API_RETRY_TIMEOUT] == 0.8 - assert result[CONF_API_RETRY_BACKOFF_MULTIPLIER] == 1.5 - - def test_config_vs_normal_operation_settings(self): - """Test that config and normal operation settings are different.""" - # Config settings should have no retries and shorter timeout - assert CONFIG_API_RETRIES == 0 - assert CONFIG_TIMEOUT == 15 - - # Normal operation defaults should have retries and longer timeout - assert DEFAULT_API_RETRIES == 3 - assert DEFAULT_API_RETRY_TIMEOUT == 0.5 - assert DEFAULT_API_RETRY_BACKOFF_MULTIPLIER == 2.0 diff --git a/tests/test_schema_validation.py b/tests/test_schema_validation.py new file mode 100644 index 00000000..9bd2ade3 --- /dev/null +++ b/tests/test_schema_validation.py @@ -0,0 +1,269 @@ +"""Tests for schema validation and sensor-to-field mapping.""" + +from __future__ import annotations + +import logging +from unittest.mock import MagicMock + +import pytest +from span_panel_api import ( + SpanBatterySnapshot, + SpanCircuitSnapshot, + SpanEvseSnapshot, + SpanPanelSnapshot, + SpanPVSnapshot, +) + +from custom_components.span_panel.schema_expectations import ( + SENSOR_FIELD_MAP, + all_referenced_field_paths, +) +from custom_components.span_panel.schema_validation import ( + validate_field_metadata, +) +from custom_components.span_panel.sensor_definitions import ( + BATTERY_POWER_SENSOR, + BATTERY_SENSOR, + BESS_METADATA_SENSORS, + CIRCUIT_BREAKER_RATING_SENSOR, + CIRCUIT_CURRENT_SENSOR, + CIRCUIT_SENSORS, + DOWNSTREAM_L1_CURRENT_SENSOR, + DOWNSTREAM_L2_CURRENT_SENSOR, + EVSE_SENSORS, + GRID_POWER_FLOW_SENSOR, + L1_VOLTAGE_SENSOR, + L2_VOLTAGE_SENSOR, + MAIN_BREAKER_RATING_SENSOR, + PANEL_DATA_STATUS_SENSORS, + PANEL_ENERGY_SENSORS, + PANEL_POWER_SENSORS, + PV_METADATA_SENSORS, + PV_POWER_SENSOR, + SITE_POWER_SENSOR, + STATUS_SENSORS, + UNMAPPED_SENSORS, + UPSTREAM_L1_CURRENT_SENSOR, + UPSTREAM_L2_CURRENT_SENSOR, +) + +_LOGGER_NAME = "custom_components.span_panel.schema_validation" + + +# --------------------------------------------------------------------------- +# Sensor field mapping tests +# --------------------------------------------------------------------------- + + +class TestSensorFieldMap: + """Tests for the sensor-to-snapshot-field mapping.""" + + def test_no_empty_keys_or_paths(self) -> None: + """Every entry must have non-empty sensor key and field path.""" + for sensor_key, field_path in SENSOR_FIELD_MAP.items(): + assert sensor_key, "Empty sensor key in SENSOR_FIELD_MAP" + assert field_path, f"Empty field path for sensor key '{sensor_key}'" + + def test_field_paths_follow_convention(self) -> None: + """All field paths must be {snapshot_type}.{field_name}.""" + valid_prefixes = {"panel", "circuit", "battery", "pv", "evse"} + for sensor_key, field_path in SENSOR_FIELD_MAP.items(): + parts = field_path.split(".", 1) + assert len(parts) == 2, ( + f"Field path '{field_path}' for sensor '{sensor_key}' " + f"does not follow 'type.field' convention" + ) + assert parts[0] in valid_prefixes, ( + f"Field path '{field_path}' for sensor '{sensor_key}' " + f"has unknown prefix '{parts[0]}'" + ) + + def test_sensor_keys_exist_in_definitions(self) -> None: + """Every sensor key should match a real sensor definition.""" + all_defs = [ + *PANEL_DATA_STATUS_SENSORS, + *STATUS_SENSORS, + *UNMAPPED_SENSORS, + BATTERY_SENSOR, + L1_VOLTAGE_SENSOR, + L2_VOLTAGE_SENSOR, + UPSTREAM_L1_CURRENT_SENSOR, + UPSTREAM_L2_CURRENT_SENSOR, + DOWNSTREAM_L1_CURRENT_SENSOR, + DOWNSTREAM_L2_CURRENT_SENSOR, + MAIN_BREAKER_RATING_SENSOR, + CIRCUIT_CURRENT_SENSOR, + CIRCUIT_BREAKER_RATING_SENSOR, + *BESS_METADATA_SENSORS, + *PV_METADATA_SENSORS, + *PANEL_POWER_SENSORS, + BATTERY_POWER_SENSOR, + PV_POWER_SENSOR, + GRID_POWER_FLOW_SENSOR, + SITE_POWER_SENSOR, + *PANEL_ENERGY_SENSORS, + *CIRCUIT_SENSORS, + *EVSE_SENSORS, + ] + known_keys = {d.key for d in all_defs} + + for sensor_key in SENSOR_FIELD_MAP: + assert sensor_key in known_keys, ( + f"Sensor key '{sensor_key}' in SENSOR_FIELD_MAP not found in sensor definitions" + ) + + def test_field_paths_match_snapshot_attrs(self) -> None: + """Field names should match actual snapshot dataclass attributes.""" + snapshot_classes = { + "panel": SpanPanelSnapshot, + "circuit": SpanCircuitSnapshot, + "battery": SpanBatterySnapshot, + "pv": SpanPVSnapshot, + "evse": SpanEvseSnapshot, + } + + for sensor_key, field_path in SENSOR_FIELD_MAP.items(): + prefix, field_name = field_path.split(".", 1) + cls = snapshot_classes[prefix] + assert hasattr(cls, field_name) or field_name in { + f.name for f in cls.__dataclass_fields__.values() + }, ( + f"Field '{field_name}' from path '{field_path}' " + f"(sensor '{sensor_key}') not found on {cls.__name__}" + ) + + def test_all_referenced_field_paths(self) -> None: + """all_referenced_field_paths should return all unique values.""" + paths = all_referenced_field_paths() + assert paths == frozenset(SENSOR_FIELD_MAP.values()) + + +# --------------------------------------------------------------------------- +# Unit cross-check tests +# --------------------------------------------------------------------------- + + +def _make_sensor_def(key: str, unit: str | None) -> MagicMock: + """Create a minimal mock SensorEntityDescription with key and unit.""" + mock = MagicMock(spec=["key", "native_unit_of_measurement"]) + mock.key = key + mock.native_unit_of_measurement = unit + return mock + + +class TestUnitCrossCheck: + """Tests for field metadata unit vs sensor definition unit cross-checking.""" + + def test_matching_units_no_cross_check_message( + self, caplog: pytest.LogCaptureFixture + ) -> None: + """Matching units should produce no cross-check log messages.""" + metadata = {"panel.instant_grid_power_w": {"unit": "W", "datatype": "float"}} + sensor_defs = {"instantGridPowerW": _make_sensor_def("instantGridPowerW", "W")} + with caplog.at_level(logging.DEBUG, logger=_LOGGER_NAME): + validate_field_metadata(metadata, sensor_defs=sensor_defs) + assert not any("cross-check" in r.lower() for r in caplog.messages) + + def test_mismatched_units_logs_debug( + self, caplog: pytest.LogCaptureFixture + ) -> None: + """Unit mismatch should produce a debug message naming both units.""" + metadata = {"panel.instant_grid_power_w": {"unit": "kW", "datatype": "float"}} + sensor_defs = {"instantGridPowerW": _make_sensor_def("instantGridPowerW", "W")} + with caplog.at_level(logging.DEBUG, logger=_LOGGER_NAME): + validate_field_metadata(metadata, sensor_defs=sensor_defs) + cross_msgs = [m for m in caplog.messages if "cross-check" in m.lower()] + assert len(cross_msgs) == 1 + assert "'kW'" in cross_msgs[0] + assert "'W'" in cross_msgs[0] + + def test_missing_metadata_logs_debug( + self, caplog: pytest.LogCaptureFixture + ) -> None: + """Sensor reading a field with no metadata should log debug.""" + metadata: dict[str, dict[str, object]] = {} + sensor_defs = {"l1_voltage": _make_sensor_def("l1_voltage", "V")} + with caplog.at_level(logging.DEBUG, logger=_LOGGER_NAME): + validate_field_metadata(metadata, sensor_defs=sensor_defs) + assert any("no metadata" in m for m in caplog.messages) + + def test_missing_schema_unit_logs_debug( + self, caplog: pytest.LogCaptureFixture + ) -> None: + """Field with no unit in metadata but unit in sensor def should log debug.""" + metadata = {"panel.l1_voltage": {"datatype": "float"}} + sensor_defs = {"l1_voltage": _make_sensor_def("l1_voltage", "V")} + with caplog.at_level(logging.DEBUG, logger=_LOGGER_NAME): + validate_field_metadata(metadata, sensor_defs=sensor_defs) + assert any("no unit" in m for m in caplog.messages) + + def test_sensor_without_unit_skipped( + self, caplog: pytest.LogCaptureFixture + ) -> None: + """Sensor with no native_unit_of_measurement should be skipped.""" + metadata = {"panel.main_relay_state": {"datatype": "enum"}} + sensor_defs = {"main_relay_state": _make_sensor_def("main_relay_state", None)} + with caplog.at_level(logging.DEBUG, logger=_LOGGER_NAME): + validate_field_metadata(metadata, sensor_defs=sensor_defs) + assert not any("cross-check" in m.lower() for m in caplog.messages) + + def test_all_output_is_debug_level(self, caplog: pytest.LogCaptureFixture) -> None: + """All schema validation output should be DEBUG — never visible to users.""" + metadata = { + "panel.instant_grid_power_w": {"unit": "kW", "datatype": "float"}, + "panel.l1_voltage": {"datatype": "float"}, + "panel.new_fancy_field": {"unit": "W", "datatype": "float"}, + } + sensor_defs = { + "instantGridPowerW": _make_sensor_def("instantGridPowerW", "W"), + "l1_voltage": _make_sensor_def("l1_voltage", "V"), + } + with caplog.at_level(logging.DEBUG, logger=_LOGGER_NAME): + validate_field_metadata(metadata, sensor_defs=sensor_defs) + above_debug = [r for r in caplog.records if r.levelno > logging.DEBUG] + assert len(above_debug) == 0, ( + f"Expected all DEBUG, got: {[(r.levelname, r.getMessage()) for r in above_debug]}" + ) + + +# --------------------------------------------------------------------------- +# Unmapped field detection tests +# --------------------------------------------------------------------------- + + +class TestUnmappedFields: + """Tests for detecting fields the integration doesn't consume.""" + + def test_unmapped_field_logs_debug(self, caplog: pytest.LogCaptureFixture) -> None: + """Field not in SENSOR_FIELD_MAP values should log at DEBUG.""" + metadata = {"panel.new_fancy_field": {"unit": "W", "datatype": "float"}} + with caplog.at_level(logging.DEBUG, logger=_LOGGER_NAME): + validate_field_metadata(metadata) + assert any( + r.levelno == logging.DEBUG and "new_fancy_field" in r.getMessage() + for r in caplog.records + ) + + def test_mapped_field_not_reported(self, caplog: pytest.LogCaptureFixture) -> None: + """Field that IS in SENSOR_FIELD_MAP should not be reported as unmapped.""" + metadata = {"panel.instant_grid_power_w": {"unit": "W", "datatype": "float"}} + with caplog.at_level(logging.DEBUG, logger=_LOGGER_NAME): + validate_field_metadata(metadata) + assert not any("not mapped" in m for m in caplog.messages) + + +# --------------------------------------------------------------------------- +# No-op when metadata unavailable +# --------------------------------------------------------------------------- + + +class TestNoOp: + """Tests for graceful handling when library doesn't expose metadata.""" + + def test_none_metadata_is_noop(self, caplog: pytest.LogCaptureFixture) -> None: + """None metadata should produce no output above DEBUG.""" + with caplog.at_level(logging.DEBUG, logger=_LOGGER_NAME): + validate_field_metadata(None) + assert any("skipped" in m for m in caplog.messages) + above_debug = [r for r in caplog.records if r.levelno > logging.DEBUG] + assert len(above_debug) == 0 diff --git a/tests/test_select.py b/tests/test_select.py index b41c9ccd..54af76ed 100644 --- a/tests/test_select.py +++ b/tests/test_select.py @@ -1,154 +1,627 @@ -from unittest.mock import AsyncMock, MagicMock, patch +"""Tests for select entity functionality.""" + +from dataclasses import replace +from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch -from homeassistant.exceptions import ServiceNotFound import pytest from span_panel_api.exceptions import SpanPanelServerError -try: - from custom_components.span_panel.span_panel_circuit import SpanPanelCircuit - - _HAS_REAL_CIRCUIT = True -except ImportError: - # If this fails, you must adjust your PYTHONPATH or test runner so custom_components is importable - SpanPanelCircuit = None - _HAS_REAL_CIRCUIT = False +from custom_components.span_panel.const import CircuitPriority +from custom_components.span_panel.select import ( + CIRCUIT_PRIORITY_DESCRIPTION, + SpanPanelCircuitsSelect, + async_setup_entry, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceNotFound +from .factories import SpanCircuitSnapshotFactory, SpanPanelSnapshotFactory -class DummySpanPanel: - """Dummy span panel class for testing.""" - circuits = {} - status = MagicMock(serial_number="123") +def _make_coordinator_with_circuit( + circuit_id: str = "id", + circuit_name: str = "name", + priority: str = "SOC_THRESHOLD", +) -> MagicMock: + """Build a mock coordinator whose .data contains a single circuit.""" + circuit = SpanCircuitSnapshotFactory.create( + circuit_id=circuit_id, + name=circuit_name, + relay_state="CLOSED", + instant_power_w=100.0, + produced_energy_wh=0.0, + consumed_energy_wh=50.0, + tabs=[1], + priority=priority, + is_user_controllable=True, + ) + snapshot = SpanPanelSnapshotFactory.create( + circuits={circuit_id: circuit}, + ) -class DummyCoordinator: - """Dummy coordinator class for testing.""" + coordinator = MagicMock() + coordinator.data = snapshot + coordinator.config_entry = MagicMock() + coordinator.config_entry.title = "SPAN Panel" + coordinator.config_entry.data = {} + coordinator.config_entry.options = {} + return coordinator - data = DummySpanPanel() +def test_select_init_missing_circuit() -> None: + """Test that initializing with a missing circuit_id raises ValueError.""" + # Coordinator with no circuits + snapshot = SpanPanelSnapshotFactory.create(circuits={}) + coordinator = MagicMock() + coordinator.data = snapshot + coordinator.config_entry = MagicMock() + coordinator.config_entry.data = {} + coordinator.config_entry.options = {} -class DummySpanPanelCircuit: - """Dummy span panel circuit class for testing.""" + with pytest.raises(ValueError): + SpanPanelCircuitsSelect( + coordinator, CIRCUIT_PRIORITY_DESCRIPTION, "bad_id", "name", "Test Device" + ) - def __init__(self, circuit_id, name, tabs, priority, is_user_controllable): - """Initialize dummy span panel circuit.""" - self.circuit_id = circuit_id - self.name = name - self.tabs = tabs - self.priority = priority - self.is_user_controllable = is_user_controllable +@pytest.mark.asyncio +async def test_async_select_option_service_not_found() -> None: + """Test that ServiceNotFound triggers a notification.""" + coordinator = _make_coordinator_with_circuit() + circuit = coordinator.data.circuits["id"] -def test_select_init_missing_circuit(): - # Lazy imports to avoid collection issues - from custom_components.span_panel.select import ( - CIRCUIT_PRIORITY_DESCRIPTION, - SpanPanelCircuitsSelect, - ) + with patch( + "custom_components.span_panel.select.async_create_span_notification", + new_callable=AsyncMock, + ) as mock_notification: + select = SpanPanelCircuitsSelect( + coordinator, CIRCUIT_PRIORITY_DESCRIPTION, "id", "name", "Test Device" + ) + select.coordinator = coordinator + select.hass = MagicMock() - coordinator = DummyCoordinator() - with pytest.raises(ValueError): - SpanPanelCircuitsSelect(coordinator, CIRCUIT_PRIORITY_DESCRIPTION, "bad_id", "name", "Test Device") + # Make the client's set_circuit_priority raise ServiceNotFound + coordinator.client = AsyncMock() + coordinator.client.set_circuit_priority = AsyncMock( + side_effect=ServiceNotFound("test_domain", "test_service") + ) + select._get_circuit = MagicMock(return_value=circuit) + await select.async_select_option(CircuitPriority.SOC_THRESHOLD.value) -@pytest.mark.asyncio -async def test_async_select_option_service_not_found(monkeypatch): - if not _HAS_REAL_CIRCUIT: - import pytest + mock_notification.assert_called_once() - pytest.skip("SpanPanelCircuit import failed; adjust PYTHONPATH or test runner.") - # Lazy imports to avoid collection issues - from custom_components.span_panel.select import ( - CIRCUIT_PRIORITY_DESCRIPTION, - SpanPanelCircuitsSelect, - ) +@pytest.mark.asyncio +async def test_async_select_option_server_error() -> None: + """Test that SpanPanelServerError triggers a notification.""" + coordinator = _make_coordinator_with_circuit() + circuit = coordinator.data.circuits["id"] with patch( - "custom_components.span_panel.select.async_create_span_notification", new_callable=AsyncMock + "custom_components.span_panel.select.async_create_span_notification", + new_callable=AsyncMock, ) as mock_notification: - coordinator = MagicMock() - coordinator.config_entry = MagicMock() - coordinator.config_entry.title = "SPAN Panel" - coordinator.config_entry.data = {} - coordinator.config_entry.options = {} - circuit = SpanPanelCircuit( - circuit_id="id", - name="name", - relay_state="CLOSED", - instant_power=100.0, - instant_power_update_time=123456, - produced_energy=0.0, - consumed_energy=50.0, - energy_accum_update_time=123456, - tabs=[1], - priority="MUST_HAVE", - is_user_controllable=True, - is_sheddable=False, - is_never_backup=False, - ) - coordinator.data.circuits = {"id": circuit} - span_panel = coordinator.data - span_panel.api.set_priority = AsyncMock( - side_effect=ServiceNotFound("test_domain", "test_service") + select = SpanPanelCircuitsSelect( + coordinator, CIRCUIT_PRIORITY_DESCRIPTION, "id", "name", "Test Device" ) - select = SpanPanelCircuitsSelect(coordinator, CIRCUIT_PRIORITY_DESCRIPTION, "id", "name", "Test Device") select.coordinator = coordinator select.hass = MagicMock() + + coordinator.client = AsyncMock() + coordinator.client.set_circuit_priority = AsyncMock( + side_effect=SpanPanelServerError("test error") + ) + select._get_circuit = MagicMock(return_value=circuit) - await select.async_select_option("Must Have") + await select.async_select_option(CircuitPriority.SOC_THRESHOLD.value) - # Verify notification was called mock_notification.assert_called_once() @pytest.mark.asyncio -async def test_async_select_option_server_error(monkeypatch): - if not _HAS_REAL_CIRCUIT: - import pytest +async def test_async_select_option_success_refreshes_coordinator() -> None: + """Successful priority changes should refresh coordinator data.""" + coordinator = _make_coordinator_with_circuit() + coordinator.hass = MagicMock() + coordinator.async_request_refresh = AsyncMock() - pytest.skip("SpanPanelCircuit import failed; adjust PYTHONPATH or test runner.") + with patch( + "custom_components.span_panel.select.er.async_get" + ) as mock_async_get: + registry = MagicMock() + registry.async_get_entity_id.return_value = None + mock_async_get.return_value = registry + select = SpanPanelCircuitsSelect( + coordinator, CIRCUIT_PRIORITY_DESCRIPTION, "id", "Kitchen", "SPAN Panel" + ) + + coordinator.client = MagicMock() + coordinator.client.set_circuit_priority = AsyncMock() + select.hass = MagicMock() + + await select.async_select_option(CircuitPriority.SOC_THRESHOLD.value) - # Lazy imports to avoid collection issues - from custom_components.span_panel.select import ( - CIRCUIT_PRIORITY_DESCRIPTION, - SpanPanelCircuitsSelect, + coordinator.client.set_circuit_priority.assert_awaited_once_with( + "id", "SOC_THRESHOLD" ) + coordinator.async_request_refresh.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_async_select_option_without_priority_support_returns_early( + caplog: pytest.LogCaptureFixture, +) -> None: + """Clients without priority support should log and return without refresh.""" + coordinator = _make_coordinator_with_circuit() + coordinator.hass = MagicMock() + coordinator.async_request_refresh = AsyncMock() with patch( - "custom_components.span_panel.select.async_create_span_notification", new_callable=AsyncMock - ) as mock_notification: - coordinator = MagicMock() - coordinator.config_entry = MagicMock() - coordinator.config_entry.title = "SPAN Panel" - coordinator.config_entry.data = {} - coordinator.config_entry.options = {} - circuit = SpanPanelCircuit( - circuit_id="id", - name="name", - relay_state="CLOSED", - instant_power=100.0, - instant_power_update_time=123456, - produced_energy=0.0, - consumed_energy=50.0, - energy_accum_update_time=123456, - tabs=[1], - priority="MUST_HAVE", - is_user_controllable=True, - is_sheddable=False, - is_never_backup=False, + "custom_components.span_panel.select.er.async_get" + ) as mock_async_get: + registry = MagicMock() + registry.async_get_entity_id.return_value = None + mock_async_get.return_value = registry + select = SpanPanelCircuitsSelect( + coordinator, CIRCUIT_PRIORITY_DESCRIPTION, "id", "Kitchen", "SPAN Panel" + ) + + coordinator.client = object() + select.hass = MagicMock() + caplog.set_level("WARNING") + + await select.async_select_option(CircuitPriority.SOC_THRESHOLD.value) + + assert "Client does not support priority control" in caplog.text + coordinator.async_request_refresh.assert_not_awaited() + + +def test_select_uses_circuit_number_name_when_option_enabled() -> None: + """Number-based naming should use breaker tabs when configured.""" + coordinator = _make_coordinator_with_circuit() + coordinator.config_entry.options = {"use_circuit_numbers": True} + coordinator.hass = MagicMock() + + with patch( + "custom_components.span_panel.select.er.async_get" + ) as mock_async_get: + registry = MagicMock() + registry.async_get_entity_id.return_value = None + mock_async_get.return_value = registry + + select = SpanPanelCircuitsSelect( + coordinator, CIRCUIT_PRIORITY_DESCRIPTION, "id", "Kitchen", "SPAN Panel" + ) + + assert select.name == "Circuit 1 Circuit Priority" + + +def test_select_unnamed_friendly_mode_leaves_name_none() -> None: + """Unnamed selects in friendly-name mode should defer to HA naming.""" + coordinator = _make_coordinator_with_circuit(circuit_name="") + coordinator.hass = MagicMock() + + with patch( + "custom_components.span_panel.select.er.async_get" + ) as mock_async_get: + registry = MagicMock() + registry.async_get_entity_id.return_value = None + mock_async_get.return_value = registry + + select = SpanPanelCircuitsSelect( + coordinator, CIRCUIT_PRIORITY_DESCRIPTION, "id", "", "SPAN Panel" + ) + + assert select.name is None + + +def test_select_existing_entity_uses_solar_fallback_name() -> None: + """Existing unnamed PV entities should use the solar fallback name.""" + circuit = replace( + SpanCircuitSnapshotFactory.create( + circuit_id="pv-1", + name=None, + tabs=[9, 10], + priority="SOC_THRESHOLD", + ), + device_type="pv", + ) + snapshot = SpanPanelSnapshotFactory.create(circuits={"pv-1": circuit}) + coordinator = MagicMock() + coordinator.data = snapshot + coordinator.hass = MagicMock() + coordinator.config_entry = MagicMock() + coordinator.config_entry.title = "SPAN Panel" + coordinator.config_entry.data = {} + coordinator.config_entry.options = {} + + with patch( + "custom_components.span_panel.select.er.async_get" + ) as mock_async_get: + registry = MagicMock() + registry.async_get_entity_id.return_value = "select.solar_circuit_priority" + mock_async_get.return_value = registry + + select = SpanPanelCircuitsSelect( + coordinator, CIRCUIT_PRIORITY_DESCRIPTION, "pv-1", "", "SPAN Panel" + ) + + assert select.name == "Solar Circuit Priority" + + +def test_select_available_false_when_panel_offline() -> None: + """Select entities become unavailable when the panel is offline.""" + coordinator = _make_coordinator_with_circuit() + coordinator.panel_offline = True + + select = SpanPanelCircuitsSelect( + coordinator, CIRCUIT_PRIORITY_DESCRIPTION, "id", "Kitchen", "SPAN Panel" + ) + + assert select.available is False + + +def test_select_extra_state_attributes_include_tabs_and_voltage() -> None: + """Select attributes should expose breaker tabs and circuit voltage.""" + coordinator = _make_coordinator_with_circuit() + coordinator.hass = MagicMock() + with patch( + "custom_components.span_panel.select.er.async_get" + ) as mock_async_get: + registry = MagicMock() + registry.async_get_entity_id.return_value = None + mock_async_get.return_value = registry + + select = SpanPanelCircuitsSelect( + coordinator, CIRCUIT_PRIORITY_DESCRIPTION, "id", "Kitchen", "SPAN Panel" + ) + + assert select.extra_state_attributes == {"tabs": "tabs [1]", "voltage": 120} + + +def test_handle_coordinator_update_requests_reload_on_first_sync() -> None: + """First update for an entity not yet in the registry should request reload.""" + coordinator = _make_coordinator_with_circuit(circuit_name="Kitchen") + coordinator.hass = MagicMock() + with patch( + "custom_components.span_panel.select.er.async_get" + ) as mock_async_get: + registry = MagicMock() + registry.async_get_entity_id.return_value = None + registry.async_get.return_value = None + mock_async_get.return_value = registry + + select = SpanPanelCircuitsSelect( + coordinator, CIRCUIT_PRIORITY_DESCRIPTION, "id", "Kitchen", "SPAN Panel" ) - coordinator.data.circuits = {"id": circuit} - span_panel = coordinator.data - span_panel.api.set_priority = AsyncMock(side_effect=SpanPanelServerError("test error")) - select = SpanPanelCircuitsSelect(coordinator, CIRCUIT_PRIORITY_DESCRIPTION, "id", "name", "Test Device") - select.coordinator = coordinator select.hass = MagicMock() - select._get_circuit = MagicMock(return_value=circuit) - await select.async_select_option("Must Have") + select.async_write_ha_state = MagicMock() + select.entity_id = "select.kitchen_circuit_priority" - # Verify notification was called - mock_notification.assert_called_once() + select._handle_coordinator_update() + + coordinator.request_reload.assert_called_once() + + +def test_handle_coordinator_update_user_override_skips_reload() -> None: + """Customized select names should suppress automatic name sync reloads.""" + coordinator = _make_coordinator_with_circuit(circuit_name="Kitchen") + coordinator.hass = MagicMock() + with patch( + "custom_components.span_panel.select.er.async_get" + ) as mock_async_get: + registry = MagicMock() + registry.async_get_entity_id.return_value = "select.kitchen_circuit_priority" + mock_async_get.return_value = registry + + select = SpanPanelCircuitsSelect( + coordinator, CIRCUIT_PRIORITY_DESCRIPTION, "id", "Kitchen", "SPAN Panel" + ) + + updated_circuit = replace(coordinator.data.circuits["id"], name="Renamed Kitchen") + coordinator.data = SpanPanelSnapshotFactory.create(circuits={"id": updated_circuit}) + select.hass = MagicMock() + select.async_write_ha_state = MagicMock() + select.entity_id = "select.kitchen_circuit_priority" + + with patch( + "custom_components.span_panel.select.er.async_get" + ) as mock_async_get: + runtime_registry = MagicMock() + runtime_registry.async_get.return_value = MagicMock( + name="Custom Kitchen Priority" + ) + mock_async_get.return_value = runtime_registry + select._handle_coordinator_update() + coordinator.request_reload.assert_not_called() + assert select._previous_circuit_name == "Renamed Kitchen" + + +def test_handle_coordinator_update_requests_reload_on_name_change() -> None: + """Later circuit renames should request a select reload.""" + coordinator = _make_coordinator_with_circuit(circuit_name="Kitchen") + coordinator.hass = MagicMock() + with patch( + "custom_components.span_panel.select.er.async_get" + ) as mock_async_get: + registry = MagicMock() + registry.async_get_entity_id.return_value = "select.kitchen_circuit_priority" + mock_async_get.return_value = registry + + select = SpanPanelCircuitsSelect( + coordinator, CIRCUIT_PRIORITY_DESCRIPTION, "id", "Kitchen", "SPAN Panel" + ) -# Additional async_select_option tests can be added with more mocks for ServiceNotFound, etc. + updated_circuit = replace(coordinator.data.circuits["id"], name="Renamed Kitchen") + coordinator.data = SpanPanelSnapshotFactory.create(circuits={"id": updated_circuit}) + select.hass = MagicMock() + select.async_write_ha_state = MagicMock() + select.entity_id = "select.kitchen_circuit_priority" + + with patch( + "custom_components.span_panel.select.er.async_get" + ) as mock_async_get: + runtime_registry = MagicMock() + runtime_registry.async_get.return_value = None + mock_async_get.return_value = runtime_registry + select._handle_coordinator_update() + + coordinator.request_reload.assert_called_once() + assert select._previous_circuit_name == "Renamed Kitchen" + + +def test_handle_coordinator_update_skips_when_circuit_missing_from_snapshot() -> None: + """Select entity should not crash when its circuit is temporarily absent from a snapshot.""" + coordinator = _make_coordinator_with_circuit(circuit_name="Kitchen") + coordinator.hass = MagicMock() + with patch( + "custom_components.span_panel.select.er.async_get" + ) as mock_async_get: + registry = MagicMock() + registry.async_get_entity_id.return_value = None + mock_async_get.return_value = registry + + select = SpanPanelCircuitsSelect( + coordinator, CIRCUIT_PRIORITY_DESCRIPTION, "id", "Kitchen", "SPAN Panel" + ) + + # Simulate a partial snapshot missing this circuit + coordinator.data = SpanPanelSnapshotFactory.create(circuits={}) + select.hass = MagicMock() + select.async_write_ha_state = MagicMock() + select.entity_id = "select.kitchen_circuit_priority" + + # Should not raise KeyError + select._handle_coordinator_update() + + # async_write_ha_state should NOT be called since we returned early + select.async_write_ha_state.assert_not_called() + + +@pytest.mark.asyncio +async def test_async_setup_entry_filters_supported_circuits() -> None: + """Platform setup should only create selects for supported controllable circuits.""" + controllable = SpanCircuitSnapshotFactory.create( + circuit_id="main-1", + name="Kitchen", + is_user_controllable=True, + tabs=[1], + ) + not_controllable = SpanCircuitSnapshotFactory.create( + circuit_id="main-2", + name="Locked", + is_user_controllable=False, + tabs=[2], + ) + evse_upstream = replace( + SpanCircuitSnapshotFactory.create( + circuit_id="evse-1", + name="EV Upstream", + is_user_controllable=True, + tabs=[3, 4], + ), + device_type="evse", + relative_position="UPSTREAM", + ) + pv_downstream = replace( + SpanCircuitSnapshotFactory.create( + circuit_id="pv-1", + name="Solar", + is_user_controllable=True, + tabs=[5, 6], + ), + device_type="pv", + relative_position="DOWNSTREAM", + ) + + coordinator = MagicMock() + coordinator.data = SpanPanelSnapshotFactory.create( + circuits={ + "main-1": controllable, + "main-2": not_controllable, + "evse-1": evse_upstream, + "pv-1": pv_downstream, + } + ) + config_entry = MagicMock() + config_entry.title = "SPAN Panel" + config_entry.data = {} + config_entry.runtime_data = MagicMock(coordinator=coordinator) + async_add_entities = MagicMock() + + await async_setup_entry(MagicMock(), config_entry, async_add_entities) + + entities = async_add_entities.call_args.args[0] + assert len(entities) == 2 + assert {entity.id for entity in entities} == {"main-1", "pv-1"} + + +def test_select_circuit_numbers_entity_id_stable_after_reload( + hass: HomeAssistant, +) -> None: + """Entity_id must stay circuit-based after name sync sets friendly display name.""" + circuit = SpanCircuitSnapshotFactory.create( + circuit_id="2", + name="Air Conditioner", + tabs=[15, 17], + is_user_controllable=True, + ) + snapshot = SpanPanelSnapshotFactory.create(circuits={"2": circuit}) + coordinator = MagicMock() + coordinator.data = snapshot + coordinator.config_entry = MagicMock() + coordinator.config_entry.title = "SPAN Panel" + coordinator.config_entry.data = {} + coordinator.config_entry.options = {"use_circuit_numbers": True} + coordinator.hass = hass + + # --- Initial install: entity NOT in registry --- + with pytest.MonkeyPatch.context() as mp: + registry = MagicMock() + registry.async_get_entity_id.return_value = None + mp.setattr( + "custom_components.span_panel.select.er.async_get", + lambda _hass: registry, + ) + select = SpanPanelCircuitsSelect( + coordinator, + CIRCUIT_PRIORITY_DESCRIPTION, + "2", + "Air Conditioner", + "SPAN Panel", + ) + + assert select.name == "Circuit 15 17 Circuit Priority" + assert select.entity_id == "select.span_panel_circuit_15_17_circuit_priority" + + # --- After reload: entity EXISTS in registry --- + with pytest.MonkeyPatch.context() as mp: + registry = MagicMock() + registry.async_get_entity_id.return_value = ( + "select.span_panel_circuit_15_17_circuit_priority" + ) + mp.setattr( + "custom_components.span_panel.select.er.async_get", + lambda _hass: registry, + ) + select2 = SpanPanelCircuitsSelect( + coordinator, + CIRCUIT_PRIORITY_DESCRIPTION, + "2", + "Air Conditioner", + "SPAN Panel", + ) + + # Entity_id must still be circuit-based + assert select2.name == "Circuit 15 17 Circuit Priority" + assert select2.entity_id == "select.span_panel_circuit_15_17_circuit_priority" + + +def test_select_circuit_numbers_entity_id_120v_single_tab( + hass: HomeAssistant, +) -> None: + """120V single-tab circuit should produce entity_id with one tab number.""" + circuit = SpanCircuitSnapshotFactory.create( + circuit_id="5", + name="Kitchen Outlets", + tabs=[10], + is_user_controllable=True, + ) + snapshot = SpanPanelSnapshotFactory.create(circuits={"5": circuit}) + coordinator = MagicMock() + coordinator.data = snapshot + coordinator.config_entry = MagicMock() + coordinator.config_entry.title = "SPAN Panel" + coordinator.config_entry.data = {} + coordinator.config_entry.options = {"use_circuit_numbers": True} + coordinator.hass = hass + + with pytest.MonkeyPatch.context() as mp: + registry = MagicMock() + registry.async_get_entity_id.return_value = None + mp.setattr( + "custom_components.span_panel.select.er.async_get", + lambda _hass: registry, + ) + select = SpanPanelCircuitsSelect( + coordinator, + CIRCUIT_PRIORITY_DESCRIPTION, + "5", + "Kitchen Outlets", + "SPAN Panel", + ) + + assert select.name == "Circuit 10 Circuit Priority" + assert select.entity_id == "select.span_panel_circuit_10_circuit_priority" + + +def test_select_coordinator_update_circuit_numbers_updates_registry( + hass: HomeAssistant, +) -> None: + """In circuit-numbers mode, a name change should update the registry display name.""" + circuit = SpanCircuitSnapshotFactory.create( + circuit_id="2", + name="Air Conditioner", + tabs=[15, 17], + is_user_controllable=True, + ) + snapshot = SpanPanelSnapshotFactory.create(circuits={"2": circuit}) + coordinator = MagicMock() + coordinator.data = snapshot + coordinator.config_entry = MagicMock() + coordinator.config_entry.title = "SPAN Panel" + coordinator.config_entry.data = {} + coordinator.config_entry.options = {"use_circuit_numbers": True} + coordinator.hass = hass + + # Create select with entity already in registry (existing entity) + with pytest.MonkeyPatch.context() as mp: + registry = MagicMock() + registry.async_get_entity_id.return_value = ( + "select.span_panel_circuit_15_17_circuit_priority" + ) + entity_entry = MagicMock() + type(entity_entry).name = PropertyMock( + return_value="Air Conditioner Circuit Priority" + ) + registry.async_get.return_value = entity_entry + mp.setattr( + "custom_components.span_panel.select.er.async_get", + lambda _hass: registry, + ) + select = SpanPanelCircuitsSelect( + coordinator, + CIRCUIT_PRIORITY_DESCRIPTION, + "2", + "Air Conditioner", + "SPAN Panel", + ) + + # Simulate a circuit name change from "Air Conditioner" to "Kitchen AC" + renamed = replace(circuit, name="Kitchen AC") + coordinator.data = SpanPanelSnapshotFactory.create(circuits={"2": renamed}) + select.hass = hass + select.entity_id = "select.span_panel_circuit_15_17_circuit_priority" + select.async_write_ha_state = MagicMock() + + with pytest.MonkeyPatch.context() as mp: + runtime_registry = MagicMock() + runtime_entry = MagicMock() + type(runtime_entry).name = PropertyMock( + return_value="Air Conditioner Circuit Priority" + ) + runtime_registry.async_get.return_value = runtime_entry + mp.setattr( + "custom_components.span_panel.select.er.async_get", + lambda _hass: runtime_registry, + ) + select._handle_coordinator_update() + + runtime_registry.async_update_entity.assert_called_once_with( + "select.span_panel_circuit_15_17_circuit_priority", + name="Kitchen AC Circuit Priority", + ) + coordinator.request_reload.assert_not_called() diff --git a/tests/test_sensor_entities.py b/tests/test_sensor_entities.py new file mode 100644 index 00000000..61def958 --- /dev/null +++ b/tests/test_sensor_entities.py @@ -0,0 +1,1193 @@ +"""Direct tests for Span Panel sensor entity classes.""" + +from __future__ import annotations + +import asyncio +from datetime import UTC, datetime, timedelta +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from span_panel_api import SpanPVSnapshot + +from homeassistant.components.sensor import SensorDeviceClass +from custom_components.span_panel.const import ( + ENABLE_ENERGY_DIP_COMPENSATION, + USE_CIRCUIT_NUMBERS, +) +from custom_components.span_panel.options import ENERGY_REPORTING_GRACE_PERIOD +from custom_components.span_panel.sensor_base import ( + SpanEnergyExtraStoredData, + _parse_numeric_state, +) +from custom_components.span_panel.sensor_circuit import ( + SpanCircuitEnergySensor, + SpanCircuitPowerSensor, + SpanUnmappedCircuitSensor, + _resolve_circuit_identifier_for_sync, + _unnamed_circuit_fallback, +) +from custom_components.span_panel.sensor_definitions import ( + BATTERY_SENSOR, + BESS_METADATA_SENSORS, + CIRCUIT_BREAKER_RATING_SENSOR, + CIRCUIT_CURRENT_SENSOR, + CIRCUIT_SENSORS, + EVSE_SENSORS, + PANEL_DATA_STATUS_SENSORS, + PANEL_ENERGY_SENSORS, + PANEL_POWER_SENSORS, + PV_METADATA_SENSORS, + STATUS_SENSORS, + UNMAPPED_SENSORS, + SpanPanelDataSensorEntityDescription, +) +from custom_components.span_panel.sensor_evse import SpanEvseSensor +from custom_components.span_panel.sensor_panel import ( + SpanBessMetadataSensor, + SpanPanelBattery, + SpanPanelEnergySensor, + SpanPanelPanelStatus, + SpanPanelPowerSensor, + SpanPanelStatus, + SpanPVMetadataSensor, +) +from homeassistant.const import CONF_HOST, STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.core import State + +from .factories import ( + SpanBatterySnapshotFactory, + SpanCircuitSnapshotFactory, + SpanEvseSnapshotFactory, + SpanPanelSnapshotFactory, +) + +from pytest_homeassistant_custom_component.common import MockConfigEntry + + +@pytest.fixture(autouse=True) +def _mock_entity_registry(): + """Patch entity registry lookups used during sensor construction.""" + registry = MagicMock() + registry.async_get_entity_id.return_value = None + with patch( + "custom_components.span_panel.sensor_base.er.async_get", + return_value=registry, + ): + yield registry + + +def _make_coordinator(snapshot, *, options: dict | None = None) -> MagicMock: + """Create a coordinator-like mock for direct sensor tests.""" + coordinator = MagicMock() + coordinator.data = snapshot + coordinator.hass = MagicMock() + coordinator.panel_offline = False + coordinator.config_entry = MockConfigEntry( + domain="span_panel", + data={CONF_HOST: "192.168.1.50"}, + options=options or {}, + title="SPAN Panel", + unique_id=snapshot.serial_number, + ) + coordinator.request_reload = MagicMock() + coordinator.register_circuit_energy_sensor = MagicMock() + coordinator.get_circuit_dip_offset = MagicMock(return_value=0.0) + return coordinator + + +def test_panel_power_sensor_extra_state_attributes_include_amperage() -> None: + """Panel power sensors should expose 240V and derived amperage.""" + snapshot = SpanPanelSnapshotFactory.create(instant_grid_power_w=480.0) + coordinator = _make_coordinator(snapshot) + description = next(desc for desc in PANEL_POWER_SENSORS if desc.key == "instantGridPowerW") + + sensor = SpanPanelPowerSensor(coordinator, description, snapshot) + + sensor._update_native_value() + + assert sensor.native_value == 480.0 + assert sensor.extra_state_attributes == {"voltage": 240, "amperage": 2.0} + + +def test_panel_power_sensor_defaults_amperage_when_value_not_numeric() -> None: + """Panel power attributes should fall back to 0.0 amperage when value is unknown.""" + snapshot = SpanPanelSnapshotFactory.create(instant_grid_power_w=480.0) + coordinator = _make_coordinator(snapshot) + description = next(desc for desc in PANEL_POWER_SENSORS if desc.key == "instantGridPowerW") + + sensor = SpanPanelPowerSensor(coordinator, description, snapshot) + + sensor._attr_native_value = STATE_UNKNOWN + + assert sensor.extra_state_attributes == {"voltage": 240, "amperage": 0.0} + + +def test_panel_sensor_default_friendly_names_cover_fallback_branches() -> None: + """Panel sensor classes should return fallback names when descriptions are unnamed.""" + battery = SpanBatterySnapshotFactory.create(soe_percentage=77.0) + snapshot = SpanPanelSnapshotFactory.create( + battery=battery, + pv=SpanPVSnapshot(vendor_name="SolarEdge"), + ) + coordinator = _make_coordinator(snapshot) + + panel_data_desc = next( + desc for desc in PANEL_DATA_STATUS_SENSORS if desc.key == "main_relay_state" + ) + status_desc = next(desc for desc in STATUS_SENSORS if desc.key == "software_version") + panel_power_desc = next(desc for desc in PANEL_POWER_SENSORS if desc.key == "instantGridPowerW") + panel_energy_desc = next( + desc for desc in PANEL_ENERGY_SENSORS if desc.key == "mainMeterEnergyConsumedWh" + ) + panel_data_sensor = SpanPanelPanelStatus(coordinator, panel_data_desc, snapshot) + status_sensor = SpanPanelStatus(coordinator, status_desc, snapshot) + battery_sensor = SpanPanelBattery(coordinator, BATTERY_SENSOR, snapshot) + power_sensor = SpanPanelPowerSensor(coordinator, panel_power_desc, snapshot) + energy_sensor = SpanPanelEnergySensor(coordinator, panel_energy_desc, snapshot) + bess_sensor = SpanBessMetadataSensor( + coordinator, + BESS_METADATA_SENSORS[0], + snapshot, + {"identifiers": {("span_panel", "bess")}}, + ) + pv_sensor = SpanPVMetadataSensor(coordinator, PV_METADATA_SENSORS[0], snapshot) + + assert panel_data_sensor._generate_friendly_name(snapshot, panel_data_desc) == "Sensor" + assert status_sensor._generate_friendly_name(snapshot, status_desc) == "Status" + assert battery_sensor._generate_friendly_name(snapshot, BATTERY_SENSOR) == "Battery" + assert power_sensor._generate_friendly_name(snapshot, panel_power_desc) == "Power" + assert energy_sensor._generate_friendly_name(snapshot, panel_energy_desc) == "Energy" + assert bess_sensor._generate_friendly_name(snapshot, BESS_METADATA_SENSORS[0]) == "BESS Sensor" + assert pv_sensor._generate_friendly_name(snapshot, PV_METADATA_SENSORS[0]) == "PV Sensor" + + +def test_panel_metadata_sensors_return_expected_data_sources() -> None: + """BESS and PV metadata sensors should read their expected snapshots.""" + battery = SpanBatterySnapshotFactory.create(vendor_name="Tesla") + pv_snapshot = SpanPVSnapshot(vendor_name="SolarEdge") + snapshot = SpanPanelSnapshotFactory.create(battery=battery, pv=pv_snapshot) + coordinator = _make_coordinator(snapshot) + + bess_sensor = SpanBessMetadataSensor( + coordinator, + BESS_METADATA_SENSORS[0], + snapshot, + {"identifiers": {("span_panel", "bess")}}, + ) + pv_sensor = SpanPVMetadataSensor(coordinator, PV_METADATA_SENSORS[0], snapshot) + + assert bess_sensor.get_data_source(snapshot) is battery + assert pv_sensor.get_data_source(snapshot) is snapshot + + +def test_panel_energy_sensor_extra_attributes_include_voltage_and_grace() -> None: + """Panel energy sensors should merge grace-period and voltage attributes.""" + snapshot = SpanPanelSnapshotFactory.create(main_meter_energy_consumed_wh=1250.0) + coordinator = _make_coordinator(snapshot) + description = next( + desc for desc in PANEL_ENERGY_SENSORS if desc.key == "mainMeterEnergyConsumedWh" + ) + + sensor = SpanPanelEnergySensor(coordinator, description, snapshot) + + sensor._last_valid_state = 1250.0 + sensor._last_valid_changed = datetime.now(tz=UTC) - timedelta(minutes=1) + + attrs = sensor.extra_state_attributes + assert attrs is not None + assert attrs["voltage"] == 240 + assert attrs["last_valid_state"] == "1250.0" + assert "grace_period_remaining" in attrs + + +def test_panel_battery_sensor_uses_battery_snapshot() -> None: + """Battery sensors should read from the nested battery snapshot.""" + battery = SpanBatterySnapshotFactory.create(soe_percentage=77.0) + snapshot = SpanPanelSnapshotFactory.create(battery=battery) + coordinator = _make_coordinator(snapshot) + + sensor = SpanPanelBattery(coordinator, BATTERY_SENSOR, snapshot) + + assert sensor.get_data_source(snapshot) is battery + + +def test_panel_status_attributes_return_none_without_coordinator_data() -> None: + """Panel status attributes should disappear when coordinator data is missing.""" + snapshot = SpanPanelSnapshotFactory.create() + coordinator = _make_coordinator(snapshot) + + sensor = SpanPanelStatus( + coordinator, + next(desc for desc in STATUS_SENSORS if desc.key == "software_version"), + snapshot, + ) + + coordinator.data = None + + assert sensor.extra_state_attributes is None + + +def test_bess_metadata_sensor_uses_device_override() -> None: + """BESS metadata sensors should preserve the provided sub-device info.""" + snapshot = SpanPanelSnapshotFactory.create( + battery=SpanBatterySnapshotFactory.create(vendor_name="Tesla") + ) + coordinator = _make_coordinator(snapshot) + device_info = {"identifiers": {("span_panel", "sp3-242424-001_bess")}} + + sensor = SpanBessMetadataSensor(coordinator, BESS_METADATA_SENSORS[0], snapshot, device_info) + + assert sensor.device_info == device_info + + +def test_circuit_power_sensor_extra_attributes_include_circuit_metadata() -> None: + """Circuit power/current sensors should expose circuit attributes.""" + circuit = SpanCircuitSnapshotFactory.create( + circuit_id="c1", + name="Kitchen", + tabs=[5, 6], + always_on=True, + relay_state="CLOSED", + relay_requester="USER", + priority="SOC_THRESHOLD", + is_sheddable=True, + current_a=12.5, + ) + snapshot = SpanPanelSnapshotFactory.create(circuits={"c1": circuit}) + coordinator = _make_coordinator(snapshot) + + sensor = SpanCircuitPowerSensor(coordinator, CIRCUIT_CURRENT_SENSOR, snapshot, "c1") + + assert sensor.extra_state_attributes == { + "tabs": "tabs [5:6]", + "voltage": 240, + "always_on": True, + "relay_state": "CLOSED", + "relay_requester": "USER", + "shed_priority": "SOC_THRESHOLD", + "is_sheddable": True, + } + + +def test_circuit_power_sensor_returns_none_name_for_unnamed_friendly_mode() -> None: + """Unnamed circuits in friendly-name mode should let HA provide the default name.""" + circuit = SpanCircuitSnapshotFactory.create(circuit_id="c1", name=None, tabs=[7]) + snapshot = SpanPanelSnapshotFactory.create(circuits={"c1": circuit}) + coordinator = _make_coordinator(snapshot, options={"use_circuit_numbers": False}) + + sensor = SpanCircuitPowerSensor(coordinator, CIRCUIT_BREAKER_RATING_SENSOR, snapshot, "c1") + + assert sensor.name is None + + +def test_unnamed_circuit_fallback_uses_solar_and_evse_labels() -> None: + """Unnamed PV and EVSE circuits should use special fallback labels.""" + solar_circuit = SpanCircuitSnapshotFactory.create(circuit_id="pv1", name="", device_type="pv") + evse_circuit = SpanCircuitSnapshotFactory.create(circuit_id="ev1", name="", device_type="evse") + + assert _unnamed_circuit_fallback(solar_circuit, "pv1") == "Solar" + assert _unnamed_circuit_fallback(evse_circuit, "ev1") == "EV Charger" + + +def test_resolve_circuit_identifier_for_sync_falls_back_when_name_missing() -> None: + """Panel-name sync should use unnamed fallback labels when needed.""" + evse_circuit = SpanCircuitSnapshotFactory.create(circuit_id="ev1", name="", device_type="evse") + + assert _resolve_circuit_identifier_for_sync(evse_circuit, "ev1") == "EV Charger" + + +def test_circuit_power_sensor_subdevice_uses_short_name() -> None: + """EVSE sub-device circuit sensors should omit circuit prefix in the name.""" + circuit = SpanCircuitSnapshotFactory.create(circuit_id="c1", name="Garage") + snapshot = SpanPanelSnapshotFactory.create(circuits={"c1": circuit}) + coordinator = _make_coordinator(snapshot) + + sensor = SpanCircuitPowerSensor( + coordinator, + CIRCUIT_CURRENT_SENSOR, + snapshot, + "c1", + device_info_override={"identifiers": {("span_panel", "evse")}}, + ) + + assert sensor._generate_friendly_name(snapshot, sensor.entity_description) == "Current" + assert sensor._generate_panel_name(snapshot, sensor.entity_description) == "Current" + + +def test_circuit_power_sensor_missing_circuit_uses_unmapped_fallback_name() -> None: + """Missing circuits should use the unmapped friendly name fallback.""" + snapshot = SpanPanelSnapshotFactory.create(circuits={}) + coordinator = _make_coordinator(snapshot) + + sensor = SpanCircuitPowerSensor( + coordinator, CIRCUIT_CURRENT_SENSOR, snapshot, "missing_circuit" + ) + + assert ( + sensor._generate_friendly_name(snapshot, sensor.entity_description) + == "Unmapped Tab missing_circuit Current" + ) + assert ( + sensor._generate_panel_name(snapshot, sensor.entity_description) + == "Unmapped Tab missing_circuit Current" + ) + + +def test_circuit_power_sensor_get_data_source_raises_for_missing_circuit() -> None: + """Missing circuit data should raise a clear KeyError.""" + snapshot = SpanPanelSnapshotFactory.create(circuits={}) + coordinator = _make_coordinator(snapshot) + + sensor = SpanCircuitPowerSensor(coordinator, CIRCUIT_CURRENT_SENSOR, snapshot, "c1") + + with pytest.raises(KeyError, match="Circuit c1 not found"): + sensor.get_data_source(snapshot) + + +def test_circuit_energy_sensor_registers_consumed_sensor_on_add() -> None: + """Consumed/produced energy sensors should register with the coordinator.""" + circuit = SpanCircuitSnapshotFactory.create(circuit_id="c1", name="Kitchen") + snapshot = SpanPanelSnapshotFactory.create(circuits={"c1": circuit}) + coordinator = _make_coordinator(snapshot) + description = next(desc for desc in CIRCUIT_SENSORS if desc.key == "circuit_energy_consumed") + + sensor = SpanCircuitEnergySensor(coordinator, description, snapshot, "c1") + + sensor.async_get_last_extra_data = AsyncMock(return_value=None) + sensor.async_get_last_state = AsyncMock(return_value=None) + sensor.hass = MagicMock() + sensor.entity_id = "sensor.kitchen_consumed_energy" + + with patch( + "homeassistant.helpers.restore_state.async_get", + return_value=MagicMock(async_restore_entity_added=MagicMock(return_value=None)), + ): + asyncio.run(sensor.async_added_to_hass()) + + coordinator.register_circuit_energy_sensor.assert_called_once_with("c1", "consumed", sensor) + + +def test_circuit_net_energy_sensor_applies_dip_offset_adjustment() -> None: + """Net energy sensors should add coordinator-provided dip compensation offsets.""" + circuit = SpanCircuitSnapshotFactory.create( + circuit_id="c1", + name="Kitchen", + consumed_energy_wh=10.0, + produced_energy_wh=2.0, + ) + snapshot = SpanPanelSnapshotFactory.create(circuits={"c1": circuit}) + coordinator = _make_coordinator(snapshot) + coordinator.get_circuit_dip_offset.side_effect = [5.0, 2.0] + description = next(desc for desc in CIRCUIT_SENSORS if desc.key == "circuit_energy_net") + + sensor = SpanCircuitEnergySensor(coordinator, description, snapshot, "c1") + + sensor._process_raw_value(20.0) + + assert sensor.native_value == 23.0 + + +def test_circuit_energy_sensor_missing_circuit_uses_fallback_names() -> None: + """Circuit energy sensors should format fallback names when the circuit is gone.""" + snapshot = SpanPanelSnapshotFactory.create(circuits={}) + coordinator = _make_coordinator(snapshot) + description = next(desc for desc in CIRCUIT_SENSORS if desc.key == "circuit_energy_consumed") + + sensor = SpanCircuitEnergySensor(coordinator, description, snapshot, "c9") + + assert ( + sensor._generate_friendly_name(snapshot, sensor.entity_description) + == "Circuit c9 Consumed Energy" + ) + assert ( + sensor._generate_panel_name(snapshot, sensor.entity_description) + == "Circuit c9 Consumed Energy" + ) + + +def test_circuit_energy_sensor_subdevice_uses_description_only() -> None: + """EVSE sub-device energy sensors should use the description name directly.""" + circuit = SpanCircuitSnapshotFactory.create(circuit_id="c1", name="Garage") + snapshot = SpanPanelSnapshotFactory.create(circuits={"c1": circuit}) + coordinator = _make_coordinator(snapshot) + description = next(desc for desc in CIRCUIT_SENSORS if desc.key == "circuit_energy_consumed") + + sensor = SpanCircuitEnergySensor( + coordinator, + description, + snapshot, + "c1", + device_info_override={"identifiers": {("span_panel", "evse")}}, + ) + + assert sensor._generate_friendly_name(snapshot, sensor.entity_description) == "Consumed Energy" + assert sensor._generate_panel_name(snapshot, sensor.entity_description) == "Consumed Energy" + + +def test_circuit_energy_sensor_extra_attributes_only_include_base_when_circuit_missing() -> None: + """Missing circuit data should still return grace-period attributes without tabs.""" + snapshot = SpanPanelSnapshotFactory.create(circuits={}) + coordinator = _make_coordinator(snapshot) + description = next(desc for desc in CIRCUIT_SENSORS if desc.key == "circuit_energy_consumed") + + sensor = SpanCircuitEnergySensor(coordinator, description, snapshot, "c1") + + sensor._last_valid_state = 12.0 + sensor._last_valid_changed = datetime.now(tz=UTC) - timedelta(minutes=1) + + attrs = sensor.extra_state_attributes + assert attrs is not None + assert attrs["last_valid_state"] == "12.0" + assert "tabs" not in attrs + assert "voltage" not in attrs + + +def test_unmapped_circuit_sensor_generates_unmapped_friendly_name() -> None: + """Unmapped circuit sensors should use tab-based fallback names.""" + snapshot = SpanPanelSnapshotFactory.create( + circuits={"unmapped_tab_7": SpanCircuitSnapshotFactory.create(circuit_id="unmapped_tab_7")} + ) + coordinator = _make_coordinator(snapshot) + + sensor = SpanUnmappedCircuitSensor(coordinator, UNMAPPED_SENSORS[0], snapshot, "unmapped_tab_7") + + assert ( + sensor._generate_friendly_name(snapshot, sensor.entity_description) + == "Unmapped Tab 7 Power" + ) + + +def test_parse_numeric_state_ignores_unknown_state() -> None: + """Non-numeric restore states should not seed grace tracking.""" + restored = State("sensor.span_energy", STATE_UNKNOWN) + + assert _parse_numeric_state(restored) == (None, None) + + +def test_parse_numeric_state_returns_numeric_value_and_timestamp() -> None: + """Numeric restore states should be parsed to float and naive datetime.""" + restored = State("sensor.span_energy", "42.5") + + value, changed = _parse_numeric_state(restored) + + assert value == 42.5 + assert changed is not None + assert changed.tzinfo is UTC + + +def test_parse_numeric_state_ignores_non_numeric_value() -> None: + """Non-numeric restore states should not seed grace tracking.""" + restored = State("sensor.span_energy", "not-a-number") + + assert _parse_numeric_state(restored) == (None, None) + + +def test_panel_power_sensor_stays_available_while_panel_offline() -> None: + """Base sensor availability should stay true during panel-offline handling.""" + snapshot = SpanPanelSnapshotFactory.create(instant_grid_power_w=250.0) + coordinator = _make_coordinator(snapshot) + coordinator.panel_offline = True + description = next(desc for desc in PANEL_POWER_SENSORS if desc.key == "instantGridPowerW") + + sensor = SpanPanelPowerSensor(coordinator, description, snapshot) + + sensor._update_native_value() + + assert sensor.available is True + assert sensor.native_value == 0.0 + + +def test_panel_status_sensor_reports_unknown_when_offline() -> None: + """Enum-like sensors should report unknown when offline.""" + snapshot = SpanPanelSnapshotFactory.create(main_relay_state="CLOSED") + coordinator = _make_coordinator(snapshot) + coordinator.panel_offline = True + description = next(desc for desc in PANEL_DATA_STATUS_SENSORS if desc.key == "main_relay_state") + + sensor = SpanPanelPowerSensor(coordinator, description, snapshot) + + sensor._update_native_value() + + assert sensor.native_value == STATE_UNKNOWN + + +def test_panel_data_sensor_missing_value_function_reports_unknown() -> None: + """Sensors without a value function should fall back to unknown.""" + snapshot = SpanPanelSnapshotFactory.create() + coordinator = _make_coordinator(snapshot) + description = SpanPanelDataSensorEntityDescription( + key="missing_value", + value_fn=None, + device_class=SensorDeviceClass.ENUM, + translation_key="missing_value", + options=["unknown"], + entity_category=None, + ) + + sensor = SpanPanelPowerSensor(coordinator, description, snapshot) + + sensor._handle_online_state() + + assert sensor.native_value == STATE_UNKNOWN + + +def test_panel_data_sensor_adds_enum_option_for_new_value() -> None: + """Enum sensors should normalize and append unseen options.""" + snapshot = SpanPanelSnapshotFactory.create() + coordinator = _make_coordinator(snapshot) + description = SpanPanelDataSensorEntityDescription( + key="dynamic_enum", + value_fn=lambda _: "NEW_STATE", + device_class=SensorDeviceClass.ENUM, + translation_key="dynamic_enum", + options=["unknown"], + entity_category=None, + ) + + sensor = SpanPanelPowerSensor(coordinator, description, snapshot) + + sensor._handle_online_state() + + assert sensor.native_value == "new_state" + assert "new_state" in sensor.options + + +def test_panel_data_sensor_initializes_missing_enum_options() -> None: + """Enum sensors should initialize the options list when it is missing.""" + snapshot = SpanPanelSnapshotFactory.create() + coordinator = _make_coordinator(snapshot) + description = SpanPanelDataSensorEntityDescription( + key="dynamic_enum_missing_options", + value_fn=lambda _: "BRAND_NEW", + device_class=SensorDeviceClass.ENUM, + translation_key="dynamic_enum_missing_options", + options=None, + entity_category=None, + ) + + sensor = SpanPanelPowerSensor(coordinator, description, snapshot) + + sensor._handle_online_state() + + assert sensor.native_value == "brand_new" + assert sensor.options == ["brand_new"] + + +def test_energy_extra_stored_data_invalid_input_returns_none() -> None: + """Malformed extra restore data should be ignored.""" + assert SpanEnergyExtraStoredData.from_dict("invalid") is None + + +def test_energy_sensor_coerces_invalid_grace_period_value() -> None: + """Grace period options should be normalized to a safe integer.""" + snapshot = SpanPanelSnapshotFactory.create(main_meter_energy_consumed_wh=1250.0) + coordinator = _make_coordinator(snapshot) + coordinator.config_entry = MockConfigEntry( + domain="span_panel", + data={CONF_HOST: "192.168.1.50"}, + options={"energy_reporting_grace_period": "abc"}, + title="SPAN Panel", + unique_id=snapshot.serial_number, + ) + description = next( + desc for desc in PANEL_ENERGY_SENSORS if desc.key == "mainMeterEnergyConsumedWh" + ) + + sensor = SpanPanelEnergySensor(coordinator, description, snapshot) + + sensor._last_valid_state = 1250.0 + sensor._last_valid_changed = datetime.now(tz=UTC) - timedelta(minutes=1) + sensor._grace_period_minutes = "abc" + + attrs = sensor.extra_state_attributes + assert attrs is not None + assert attrs["grace_period_remaining"] == "13" + + +def test_energy_sensor_offline_without_last_valid_state_reports_none() -> None: + """Energy sensors without restored state should become unknown when offline.""" + snapshot = SpanPanelSnapshotFactory.create(main_meter_energy_consumed_wh=1250.0) + coordinator = _make_coordinator(snapshot) + coordinator.panel_offline = True + description = next( + desc for desc in PANEL_ENERGY_SENSORS if desc.key == "mainMeterEnergyConsumedWh" + ) + + sensor = SpanPanelEnergySensor(coordinator, description, snapshot) + + sensor._update_native_value() + + assert sensor.native_value is None + assert sensor.native_value != STATE_UNAVAILABLE + + +def test_circuit_power_sensor_logs_debug_info_for_instant_power( + caplog: pytest.LogCaptureFixture, +) -> None: + """Circuit sensors should emit debug info when power data is available.""" + circuit = SpanCircuitSnapshotFactory.create( + circuit_id="c1", + name="Kitchen", + instant_power_w=150.0, + ) + snapshot = SpanPanelSnapshotFactory.create(circuits={"c1": circuit}) + coordinator = _make_coordinator(snapshot) + + sensor = SpanCircuitPowerSensor(coordinator, CIRCUIT_CURRENT_SENSOR, snapshot, "c1") + + sensor.id = "c1" + caplog.set_level("DEBUG") + + sensor._handle_online_state() + + assert "CIRCUIT_POWER_DEBUG: Circuit c1" in caplog.text + + +def test_evse_sensor_uses_evse_subdevice_info_and_name() -> None: + """EVSE sensors should attach to the EVSE sub-device and keep static names.""" + evse = SpanEvseSnapshotFactory.create(node_id="evse-0", feed_circuit_id="c1") + snapshot = SpanPanelSnapshotFactory.create( + circuits={"c1": SpanCircuitSnapshotFactory.create(circuit_id="c1", name="Garage")}, + evse={"evse-0": evse}, + ) + coordinator = _make_coordinator(snapshot, options={"use_circuit_numbers": False}) + coordinator.config_entry = MockConfigEntry( + domain="span_panel", + data={CONF_HOST: "192.168.1.50", "device_name": "Main House"}, + options={"use_circuit_numbers": False}, + title="SPAN Panel", + unique_id=snapshot.serial_number, + ) + description = next(desc for desc in EVSE_SENSORS if desc.key == "evse_status") + + sensor = SpanEvseSensor(coordinator, description, snapshot, "evse-0") + + assert sensor.entity_description.translation_key == "evse_status" + assert sensor.unique_id.endswith("_evse_evse-0_evse_status") + assert sensor.device_info["name"] == "Main House SPAN Drive (Garage)" + assert sensor.get_data_source(snapshot) is evse + + +def test_evse_sensor_returns_empty_snapshot_when_missing_mid_session() -> None: + """EVSE sensors should tolerate missing EVSE data after creation.""" + snapshot = SpanPanelSnapshotFactory.create(evse={"evse-0": SpanEvseSnapshotFactory.create()}) + coordinator = _make_coordinator(snapshot) + description = next(desc for desc in EVSE_SENSORS if desc.key == "evse_status") + + sensor = SpanEvseSensor(coordinator, description, snapshot, "evse-0") + + missing_snapshot = SpanPanelSnapshotFactory.create(evse={}) + data_source = sensor.get_data_source(missing_snapshot) + + assert data_source.node_id == "" + assert data_source.feed_circuit_id == "" + + +def test_existing_circuit_entity_uses_panel_name_on_init() -> None: + """Existing registry entities should initialize with panel-sync naming.""" + circuit = SpanCircuitSnapshotFactory.create(circuit_id="c1", name="Kitchen") + snapshot = SpanPanelSnapshotFactory.create(circuits={"c1": circuit}) + coordinator = _make_coordinator(snapshot) + + with patch("custom_components.span_panel.sensor_base.er.async_get") as mock_async_get: + registry = MagicMock() + registry.async_get_entity_id.return_value = "sensor.kitchen_current" + mock_async_get.return_value = registry + + sensor = SpanCircuitPowerSensor(coordinator, CIRCUIT_CURRENT_SENSOR, snapshot, "c1") + + assert sensor._attr_name == "Kitchen Current" + assert sensor._previous_circuit_name == "Kitchen" + + +def test_circuit_sensor_first_update_requests_reload_for_name_sync() -> None: + """First coordinator update should request a reload for dynamic name sync.""" + circuit = SpanCircuitSnapshotFactory.create(circuit_id="c1", name="Kitchen") + snapshot = SpanPanelSnapshotFactory.create(circuits={"c1": circuit}) + coordinator = _make_coordinator(snapshot) + + with patch("custom_components.span_panel.sensor_base.er.async_get") as mock_async_get: + registry = MagicMock() + registry.async_get_entity_id.return_value = None + registry.async_get.return_value = None + mock_async_get.return_value = registry + + sensor = SpanCircuitPowerSensor(coordinator, CIRCUIT_CURRENT_SENSOR, snapshot, "c1") + + sensor.hass = MagicMock() + sensor.entity_id = "sensor.kitchen_current" + sensor.async_write_ha_state = MagicMock() + + with patch("custom_components.span_panel.sensor_base.er.async_get") as mock_async_get: + runtime_registry = MagicMock() + runtime_registry.async_get.return_value = None + mock_async_get.return_value = runtime_registry + sensor._handle_coordinator_update() + + coordinator.request_reload.assert_called_once() + assert sensor._previous_circuit_name == "Kitchen" + + +def test_circuit_sensor_user_override_skips_reload_on_name_change() -> None: + """Customized entity names should suppress automatic sync reloads.""" + circuit = SpanCircuitSnapshotFactory.create(circuit_id="c1", name="Kitchen") + snapshot = SpanPanelSnapshotFactory.create(circuits={"c1": circuit}) + coordinator = _make_coordinator(snapshot) + + with patch("custom_components.span_panel.sensor_base.er.async_get") as mock_async_get: + init_registry = MagicMock() + init_registry.async_get_entity_id.return_value = "sensor.kitchen_current" + mock_async_get.return_value = init_registry + + sensor = SpanCircuitPowerSensor(coordinator, CIRCUIT_CURRENT_SENSOR, snapshot, "c1") + + updated_circuit = SpanCircuitSnapshotFactory.create(circuit_id="c1", name="Renamed Kitchen") + coordinator.data = SpanPanelSnapshotFactory.create(circuits={"c1": updated_circuit}) + sensor.hass = MagicMock() + sensor.entity_id = "sensor.kitchen_current" + sensor.async_write_ha_state = MagicMock() + + with patch("custom_components.span_panel.sensor_base.er.async_get") as mock_async_get: + runtime_registry = MagicMock() + runtime_registry.async_get.return_value = MagicMock(name="Custom Kitchen Current") + mock_async_get.return_value = runtime_registry + sensor._handle_coordinator_update() + + coordinator.request_reload.assert_not_called() + assert sensor._previous_circuit_name == "Renamed Kitchen" + + +def test_sensor_online_state_handles_lookup_error_as_unknown() -> None: + """Lookup errors from the value function path should fall back to unknown.""" + snapshot = SpanPanelSnapshotFactory.create() + coordinator = _make_coordinator(snapshot) + description = SpanPanelDataSensorEntityDescription( + key="lookup_error", + value_fn=lambda data: data.missing_attribute, + device_class=SensorDeviceClass.ENUM, + translation_key="lookup_error", + options=["unknown"], + entity_category=None, + ) + + sensor = SpanPanelPowerSensor(coordinator, description, snapshot) + + sensor._handle_online_state() + + assert sensor.native_value == STATE_UNKNOWN + + +def test_sensor_online_state_handles_unexpected_exception_as_unknown( + caplog: pytest.LogCaptureFixture, +) -> None: + """Unexpected value function errors should log a warning and fall back.""" + snapshot = SpanPanelSnapshotFactory.create() + coordinator = _make_coordinator(snapshot) + description = SpanPanelDataSensorEntityDescription( + key="boom", + value_fn=lambda _: (_ for _ in ()).throw(RuntimeError("boom")), + device_class=SensorDeviceClass.ENUM, + translation_key="boom", + options=["unknown"], + entity_category=None, + ) + + sensor = SpanPanelPowerSensor(coordinator, description, snapshot) + + caplog.set_level("WARNING") + sensor._handle_online_state() + + assert sensor.native_value == STATE_UNKNOWN + assert "Value function failed" in caplog.text + + +def test_energy_sensor_restores_extra_data_on_add() -> None: + """Energy sensors should restore grace-period and dip state from extra data.""" + snapshot = SpanPanelSnapshotFactory.create(main_meter_energy_consumed_wh=1250.0) + coordinator = _make_coordinator( + snapshot, + options={ + ENABLE_ENERGY_DIP_COMPENSATION: True, + ENERGY_REPORTING_GRACE_PERIOD: 15, + }, + ) + description = next( + desc for desc in PANEL_ENERGY_SENSORS if desc.key == "mainMeterEnergyConsumedWh" + ) + + sensor = SpanPanelEnergySensor(coordinator, description, snapshot) + + sensor.hass = MagicMock() + sensor.entity_id = "sensor.main_meter_energy_consumed" + sensor.async_get_last_extra_data = AsyncMock( + return_value=MagicMock( + as_dict=MagicMock( + return_value={ + "last_valid_state": 33.0, + "last_valid_changed": "2024-01-01T12:00:00", + "energy_offset": 5.0, + "last_panel_reading": 120.0, + "last_dip_delta": 2.5, + } + ) + ) + ) + sensor.async_get_last_state = AsyncMock(return_value=None) + + with patch( + "homeassistant.helpers.restore_state.async_get", + return_value=MagicMock(async_restore_entity_added=MagicMock(return_value=None)), + ): + asyncio.run(sensor.async_added_to_hass()) + + assert sensor._last_valid_state == 33.0 + assert sensor._restored_from_storage is True + assert sensor.energy_offset == 5.0 + assert sensor._last_panel_reading == 120.0 + assert sensor._last_dip_delta == 2.5 + + +def test_dip_offset_applied_before_coordinator_listener_fires() -> None: + """Dip offset must be set before the coordinator listener is registered. + + If an MQTT push arrives right when the listener is registered inside + super().async_added_to_hass(), async_update_listeners() calls the callback + synchronously. The offset must already be in place at that moment or + _process_raw_value() will apply offset=0 and report the bare panel counter. + + This test captures the value of _energy_offset at the exact instant + async_add_listener is called and asserts it equals the stored offset, not 0. + With the original ordering (restore AFTER super) the captured value was 0.0. + """ + stored_offset = 139355.1 + + snapshot = SpanPanelSnapshotFactory.create(main_meter_energy_consumed_wh=753930.6) + coordinator = _make_coordinator( + snapshot, + options={ + ENABLE_ENERGY_DIP_COMPENSATION: True, + ENERGY_REPORTING_GRACE_PERIOD: 15, + }, + ) + description = next( + desc for desc in PANEL_ENERGY_SENSORS if desc.key == "mainMeterEnergyConsumedWh" + ) + sensor = SpanPanelEnergySensor(coordinator, description, snapshot) + + sensor.hass = MagicMock() + sensor.entity_id = "sensor.main_meter_energy_consumed" + sensor.async_get_last_extra_data = AsyncMock( + return_value=MagicMock( + as_dict=MagicMock( + return_value={ + "last_valid_state": 893285.7, + "last_valid_changed": "2026-05-11T17:31:46+00:00", + "energy_offset": stored_offset, + "last_panel_reading": 753930.6, + "last_dip_delta": 65711.9, + } + ) + ) + ) + sensor.async_get_last_state = AsyncMock(return_value=None) + + # Capture the entity's _energy_offset at the moment the listener is + # registered (inside super().async_added_to_hass()). With the fix, this + # must already equal the stored offset; before the fix it was 0.0. + offset_at_registration: list[float] = [] + + def capture_offset_on_register(update_callback, context=None): + offset_at_registration.append(sensor._energy_offset) + return MagicMock() + + coordinator.async_add_listener = MagicMock(side_effect=capture_offset_on_register) + + with patch( + "homeassistant.helpers.restore_state.async_get", + return_value=MagicMock(async_restore_entity_added=MagicMock(return_value=None)), + ): + asyncio.run(sensor.async_added_to_hass()) + + assert offset_at_registration, "async_add_listener was never called" + assert offset_at_registration[0] == stored_offset, ( + f"_energy_offset at listener registration was {offset_at_registration[0]}, " + f"expected {stored_offset} — dip offset not restored before super() call" + ) + + +def test_energy_sensor_restores_invalid_timestamp_logs_warning( + caplog: pytest.LogCaptureFixture, +) -> None: + """Invalid restored timestamps should be ignored with a warning.""" + snapshot = SpanPanelSnapshotFactory.create(main_meter_energy_consumed_wh=1250.0) + coordinator = _make_coordinator(snapshot) + description = next( + desc for desc in PANEL_ENERGY_SENSORS if desc.key == "mainMeterEnergyConsumedWh" + ) + + sensor = SpanPanelEnergySensor(coordinator, description, snapshot) + + sensor.hass = MagicMock() + sensor.entity_id = "sensor.main_meter_energy_consumed" + sensor.async_get_last_extra_data = AsyncMock( + return_value=MagicMock( + as_dict=MagicMock( + return_value={ + "last_valid_state": 33.0, + "last_valid_changed": "not-a-date", + } + ) + ) + ) + sensor.async_get_last_state = AsyncMock(return_value=None) + + caplog.set_level("WARNING") + + with patch( + "homeassistant.helpers.restore_state.async_get", + return_value=MagicMock(async_restore_entity_added=MagicMock(return_value=None)), + ): + asyncio.run(sensor.async_added_to_hass()) + + assert sensor._last_valid_state == 33.0 + assert sensor._last_valid_changed is None + assert "Failed to parse restored last_valid_changed" in caplog.text + + +def test_energy_sensor_initializes_grace_period_from_last_state() -> None: + """Energy sensors should seed grace tracking from the last HA state.""" + snapshot = SpanPanelSnapshotFactory.create(main_meter_energy_consumed_wh=1250.0) + coordinator = _make_coordinator(snapshot) + description = next( + desc for desc in PANEL_ENERGY_SENSORS if desc.key == "mainMeterEnergyConsumedWh" + ) + + sensor = SpanPanelEnergySensor(coordinator, description, snapshot) + + restored_changed = datetime(2024, 1, 2, 3, 4, 5) + sensor.hass = MagicMock() + sensor.entity_id = "sensor.main_meter_energy_consumed" + sensor.async_get_last_extra_data = AsyncMock(return_value=None) + sensor.async_get_last_state = AsyncMock( + return_value=State( + "sensor.main_meter_energy_consumed", "18.5", last_changed=restored_changed + ) + ) + + with patch( + "homeassistant.helpers.restore_state.async_get", + return_value=MagicMock(async_restore_entity_added=MagicMock(return_value=None)), + ): + asyncio.run(sensor.async_added_to_hass()) + + assert sensor._last_valid_state == 18.5 + assert sensor._last_valid_changed == restored_changed.replace(tzinfo=UTC) + assert sensor._restored_from_storage is True + + +def test_energy_sensor_extra_restore_state_data_includes_offsets() -> None: + """Restore payload should include tracked grace and dip data.""" + snapshot = SpanPanelSnapshotFactory.create(main_meter_energy_consumed_wh=1250.0) + coordinator = _make_coordinator(snapshot, options={ENABLE_ENERGY_DIP_COMPENSATION: True}) + description = next( + desc for desc in PANEL_ENERGY_SENSORS if desc.key == "mainMeterEnergyConsumedWh" + ) + + sensor = SpanPanelEnergySensor(coordinator, description, snapshot) + + sensor._attr_native_value = 44.0 + sensor._last_valid_state = 40.0 + sensor._last_valid_changed = datetime(2024, 1, 1, 12, 0, 0) + sensor._energy_offset = 3.0 + sensor._last_panel_reading = 44.0 + sensor._last_dip_delta = 1.5 + + data = sensor.extra_restore_state_data.as_dict() + + assert data["native_value"] == 44.0 + assert data["last_valid_state"] == 40.0 + assert data["energy_offset"] == 3.0 + assert data["last_panel_reading"] == 44.0 + assert data["last_dip_delta"] == 1.5 + + +def test_energy_sensor_offline_seeds_last_valid_from_current_native_value() -> None: + """Offline grace handling should seed tracking from a restored native value.""" + snapshot = SpanPanelSnapshotFactory.create(main_meter_energy_consumed_wh=1250.0) + coordinator = _make_coordinator(snapshot) + coordinator.panel_offline = True + description = next( + desc for desc in PANEL_ENERGY_SENSORS if desc.key == "mainMeterEnergyConsumedWh" + ) + + sensor = SpanPanelEnergySensor(coordinator, description, snapshot) + + sensor._attr_native_value = 22.0 + + sensor._handle_offline_grace_period() + + assert sensor.native_value == 22.0 + assert sensor._last_valid_state == 22.0 + assert sensor._last_valid_changed is not None + + +def test_energy_sensor_negative_grace_period_is_coerced_to_zero() -> None: + """Negative grace periods should be normalized to zero.""" + snapshot = SpanPanelSnapshotFactory.create(main_meter_energy_consumed_wh=1250.0) + coordinator = _make_coordinator(snapshot) + description = next( + desc for desc in PANEL_ENERGY_SENSORS if desc.key == "mainMeterEnergyConsumedWh" + ) + + sensor = SpanPanelEnergySensor(coordinator, description, snapshot) + + sensor._last_valid_state = 22.0 + sensor._last_valid_changed = datetime.now(tz=UTC) - timedelta(minutes=1) + sensor._grace_period_minutes = -5 + + attrs = sensor.extra_state_attributes + + assert attrs is not None + assert "grace_period_remaining" not in attrs + assert sensor._grace_period_minutes == 0 + + +def test_energy_sensor_extra_attributes_mark_using_grace_period_when_offline() -> None: + """Grace-period attributes should expose when offline state is being used.""" + snapshot = SpanPanelSnapshotFactory.create(main_meter_energy_consumed_wh=1250.0) + coordinator = _make_coordinator(snapshot) + coordinator.panel_offline = True + description = next( + desc for desc in PANEL_ENERGY_SENSORS if desc.key == "mainMeterEnergyConsumedWh" + ) + + sensor = SpanPanelEnergySensor(coordinator, description, snapshot) + + sensor._last_valid_state = 22.0 + sensor._last_valid_changed = datetime.now(tz=UTC) - timedelta(minutes=1) + + attrs = sensor.extra_state_attributes + + assert attrs is not None + assert attrs["using_grace_period"] == "True" + + +def test_energy_sensor_first_update_requests_reload_for_name_sync() -> None: + """Energy sensors should request reload on the first synced panel name.""" + circuit = SpanCircuitSnapshotFactory.create(circuit_id="c1", name="Kitchen") + snapshot = SpanPanelSnapshotFactory.create(circuits={"c1": circuit}) + coordinator = _make_coordinator(snapshot) + description = next(desc for desc in CIRCUIT_SENSORS if desc.key == "circuit_energy_consumed") + + with patch("custom_components.span_panel.sensor_base.er.async_get") as mock_async_get: + registry = MagicMock() + registry.async_get_entity_id.return_value = None + registry.async_get.return_value = None + mock_async_get.return_value = registry + sensor = SpanCircuitEnergySensor(coordinator, description, snapshot, "c1") + + sensor.hass = MagicMock() + sensor.entity_id = "sensor.kitchen_consumed_energy" + sensor.async_write_ha_state = MagicMock() + + with patch("custom_components.span_panel.sensor_base.er.async_get") as mock_async_get: + runtime_registry = MagicMock() + runtime_registry.async_get.return_value = None + mock_async_get.return_value = runtime_registry + sensor._handle_coordinator_update() + + coordinator.request_reload.assert_called_once() + assert sensor._previous_circuit_name == "Kitchen" + + +def test_energy_sensor_name_change_requests_reload() -> None: + """Energy sensors should request reload when the panel circuit name changes.""" + circuit = SpanCircuitSnapshotFactory.create(circuit_id="c1", name="Kitchen") + snapshot = SpanPanelSnapshotFactory.create(circuits={"c1": circuit}) + coordinator = _make_coordinator(snapshot) + description = next(desc for desc in CIRCUIT_SENSORS if desc.key == "circuit_energy_consumed") + + with patch("custom_components.span_panel.sensor_base.er.async_get") as mock_async_get: + init_registry = MagicMock() + init_registry.async_get_entity_id.return_value = "sensor.kitchen_consumed_energy" + mock_async_get.return_value = init_registry + sensor = SpanCircuitEnergySensor(coordinator, description, snapshot, "c1") + + updated_circuit = SpanCircuitSnapshotFactory.create(circuit_id="c1", name="Renamed Kitchen") + coordinator.data = SpanPanelSnapshotFactory.create(circuits={"c1": updated_circuit}) + sensor.hass = MagicMock() + sensor.entity_id = "sensor.kitchen_consumed_energy" + sensor.async_write_ha_state = MagicMock() + + with patch("custom_components.span_panel.sensor_base.er.async_get") as mock_async_get: + runtime_registry = MagicMock() + runtime_registry.async_get.return_value = None + mock_async_get.return_value = runtime_registry + sensor._handle_coordinator_update() + + coordinator.request_reload.assert_called_once() + assert sensor._previous_circuit_name == "Renamed Kitchen" + + +def test_circuit_sensor_entity_id_stable_in_circuit_numbers_mode() -> None: + """Entity name should be circuit-based in circuit-numbers mode for entity_id stability.""" + circuit = SpanCircuitSnapshotFactory.create(circuit_id="c1", name="Kitchen", tabs=[5]) + snapshot = SpanPanelSnapshotFactory.create(circuits={"c1": circuit}) + coordinator = _make_coordinator(snapshot, options={USE_CIRCUIT_NUMBERS: True}) + + with patch("custom_components.span_panel.sensor_base.er.async_get") as mock_async_get: + registry = MagicMock() + registry.async_get_entity_id.return_value = "sensor.circuit_5_current" + entity_entry = MagicMock() + entity_entry.name = None + registry.async_get.return_value = entity_entry + mock_async_get.return_value = registry + + sensor = SpanCircuitPowerSensor(coordinator, CIRCUIT_CURRENT_SENSOR, snapshot, "c1") + + # In circuit-numbers mode, _attr_name should be circuit-based (contains "Circuit") + assert sensor._attr_name is not None + assert "Circuit" in sensor._attr_name + assert sensor._previous_circuit_name == "Kitchen" + + +def test_circuit_sensor_name_change_updates_registry_in_circuit_numbers_mode() -> None: + """In circuit-numbers mode, name changes update registry display name without reload.""" + circuit = SpanCircuitSnapshotFactory.create(circuit_id="c1", name="Kitchen", tabs=[5]) + snapshot = SpanPanelSnapshotFactory.create(circuits={"c1": circuit}) + coordinator = _make_coordinator(snapshot, options={USE_CIRCUIT_NUMBERS: True}) + + with patch("custom_components.span_panel.sensor_base.er.async_get") as mock_async_get: + init_registry = MagicMock() + init_registry.async_get_entity_id.return_value = "sensor.circuit_5_current" + entity_entry = MagicMock() + entity_entry.name = None + init_registry.async_get.return_value = entity_entry + mock_async_get.return_value = init_registry + + sensor = SpanCircuitPowerSensor(coordinator, CIRCUIT_CURRENT_SENSOR, snapshot, "c1") + + # Simulate coordinator update with a name change + updated_circuit = SpanCircuitSnapshotFactory.create( + circuit_id="c1", name="Renamed Kitchen", tabs=[5] + ) + coordinator.data = SpanPanelSnapshotFactory.create(circuits={"c1": updated_circuit}) + sensor.hass = MagicMock() + sensor.entity_id = "sensor.circuit_5_current" + sensor.async_write_ha_state = MagicMock() + + with patch("custom_components.span_panel.sensor_base.er.async_get") as mock_async_get: + runtime_registry = MagicMock() + runtime_entry = MagicMock() + runtime_entry.name = "Kitchen Current" + runtime_registry.async_get.return_value = runtime_entry + mock_async_get.return_value = runtime_registry + sensor._handle_coordinator_update() + + # Registry should be updated with the new display name + runtime_registry.async_update_entity.assert_called_once_with( + "sensor.circuit_5_current", name="Renamed Kitchen Current" + ) + # No reload should be requested in circuit-numbers mode + coordinator.request_reload.assert_not_called() + assert sensor._previous_circuit_name == "Renamed Kitchen" diff --git a/tests/test_sensor_phase_mapping.py b/tests/test_sensor_phase_mapping.py index e0e4a492..16f59eb9 100644 --- a/tests/test_sensor_phase_mapping.py +++ b/tests/test_sensor_phase_mapping.py @@ -5,7 +5,7 @@ from span_panel_api.phase_validation import are_tabs_opposite_phase, get_tab_phase -def test_tab_phase_determination(): +def test_tab_phase_determination() -> None: """Test that tab phases are determined correctly.""" # Test some known phase assignments assert get_tab_phase(1) == "L1" # Left side, position 0 @@ -16,13 +16,13 @@ def test_tab_phase_determination(): assert get_tab_phase(6) == "L1" # Right side, position 2 -def test_opposite_phase_validation(): +def test_opposite_phase_validation() -> None: """Test that opposite phase validation works correctly.""" # Test opposite phase combinations (should be valid) - assert are_tabs_opposite_phase(1, 3) is True # L1 + L2 - assert are_tabs_opposite_phase(2, 4) is True # L1 + L2 - assert are_tabs_opposite_phase(1, 4) is True # L1 + L2 - assert are_tabs_opposite_phase(3, 6) is True # L2 + L1 + assert are_tabs_opposite_phase(1, 3) is True # L1 + L2 + assert are_tabs_opposite_phase(2, 4) is True # L1 + L2 + assert are_tabs_opposite_phase(1, 4) is True # L1 + L2 + assert are_tabs_opposite_phase(3, 6) is True # L2 + L1 # Test same phase combinations (should be invalid) assert are_tabs_opposite_phase(1, 2) is False # L1 + L1 @@ -31,41 +31,5 @@ def test_opposite_phase_validation(): assert are_tabs_opposite_phase(2, 6) is False # L1 + L1 -def test_filtered_tab_options(): - """Test the filtered tab options function.""" - from custom_components.span_panel.config_flow_utils.validation import get_filtered_tab_options - - available_tabs = [1, 2, 3, 4, 5, 6, 7, 8] - - # Test with no selection (should show all tabs) - all_options = get_filtered_tab_options(0, available_tabs) - assert 0 in all_options # None option - assert all(tab in all_options for tab in available_tabs) - - # Test with tab 1 selected (L1) - should show only L2 tabs - leg1_options = get_filtered_tab_options(1, available_tabs) - assert 0 in leg1_options # None option always included - assert 1 not in leg1_options # Selected tab not in options - assert 2 not in leg1_options # Same phase (L1) - assert 3 in leg1_options # Opposite phase (L2) - assert 4 in leg1_options # Opposite phase (L2) - assert 5 not in leg1_options # Same phase (L1) - assert 6 not in leg1_options # Same phase (L1) - assert 7 in leg1_options # Opposite phase (L2) - assert 8 in leg1_options # Opposite phase (L2) - - # Test with tab 3 selected (L2) - should show only L1 tabs - leg2_options = get_filtered_tab_options(3, available_tabs) - assert 0 in leg2_options # None option always included - assert 3 not in leg2_options # Selected tab not in options - assert 1 in leg2_options # Opposite phase (L1) - assert 2 in leg2_options # Opposite phase (L1) - assert 4 not in leg2_options # Same phase (L2) - assert 5 in leg2_options # Opposite phase (L1) - assert 6 in leg2_options # Opposite phase (L1) - assert 7 not in leg2_options # Same phase (L2) - assert 8 not in leg2_options # Same phase (L2) - - if __name__ == "__main__": pytest.main([__file__]) diff --git a/tests/test_sensor_platform.py b/tests/test_sensor_platform.py new file mode 100644 index 00000000..1bb17f56 --- /dev/null +++ b/tests/test_sensor_platform.py @@ -0,0 +1,383 @@ +"""Tests for Span Panel sensor platform orchestration.""" + +from __future__ import annotations + +import logging +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from custom_components.span_panel.const import ( + DOMAIN, + ENABLE_CIRCUIT_NET_ENERGY_SENSORS, + ENABLE_PANEL_NET_ENERGY_SENSORS, + ENABLE_UNMAPPED_CIRCUIT_SENSORS, + USE_CIRCUIT_NUMBERS, +) +from custom_components.span_panel.sensor import ( + _build_evse_device_info_map, + async_setup_entry, + create_battery_sensors, + create_circuit_sensors, + create_evse_sensors, + create_native_sensors, + create_panel_sensors, + create_power_flow_sensors, + create_unmapped_circuit_sensors, +) +from homeassistant.core import HomeAssistant + +from .factories import ( + SpanBatterySnapshotFactory, + SpanCircuitSnapshotFactory, + SpanEvseSnapshotFactory, + SpanPanelSnapshotFactory, + SpanPVSnapshot, +) + +from pytest_homeassistant_custom_component.common import MockConfigEntry + + +async def test_sensor_async_setup_entry_adds_entities_and_refreshes( + hass: HomeAssistant, +) -> None: + """Sensor platform setup should add entities and refresh.""" + snapshot = SpanPanelSnapshotFactory.create() + coordinator = MagicMock() + coordinator.data = snapshot + coordinator.async_request_refresh = AsyncMock() + entry = MockConfigEntry(domain=DOMAIN, data={}, title="SPAN Panel") + entry.runtime_data = MagicMock(coordinator=coordinator) + entities = [MagicMock(), MagicMock()] + async_add_entities = MagicMock() + + with patch( + "custom_components.span_panel.sensor.create_native_sensors", + return_value=entities, + ) as mock_create: + await async_setup_entry(hass, entry, async_add_entities) + + mock_create.assert_called_once_with(coordinator, snapshot, entry) + async_add_entities.assert_called_once_with(entities) + coordinator.async_request_refresh.assert_awaited_once() + + +async def test_sensor_async_setup_entry_logs_and_reraises_errors( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Setup errors should be logged and re-raised.""" + coordinator = MagicMock() + coordinator.data = SpanPanelSnapshotFactory.create() + entry = MockConfigEntry(domain=DOMAIN, data={}, title="SPAN Panel") + entry.runtime_data = MagicMock(coordinator=coordinator) + + caplog.set_level(logging.ERROR) + + with ( + patch( + "custom_components.span_panel.sensor.create_native_sensors", + side_effect=RuntimeError("broken sensor setup"), + ), + pytest.raises(RuntimeError, match="broken sensor setup"), + ): + await async_setup_entry(hass, entry, MagicMock()) + + assert "Error in async_setup_entry" in caplog.text + assert "broken sensor setup" in caplog.text + + +def test_create_native_sensors_concatenates_sensor_groups() -> None: + """Native sensor creation should preserve group ordering.""" + coordinator = MagicMock() + snapshot = SpanPanelSnapshotFactory.create() + entry = MockConfigEntry( + domain=DOMAIN, + data={}, + title="SPAN Panel", + options={ENABLE_UNMAPPED_CIRCUIT_SENSORS: True}, + ) + + panel = [MagicMock(name="panel")] + circuit = [MagicMock(name="circuit")] + unmapped = [MagicMock(name="unmapped")] + battery = [MagicMock(name="battery")] + power_flow = [MagicMock(name="power_flow")] + evse = [MagicMock(name="evse")] + + with ( + patch( + "custom_components.span_panel.sensor.create_panel_sensors", + return_value=panel, + ), + patch( + "custom_components.span_panel.sensor.create_circuit_sensors", + return_value=circuit, + ), + patch( + "custom_components.span_panel.sensor.create_unmapped_circuit_sensors", + return_value=unmapped, + ), + patch( + "custom_components.span_panel.sensor.create_battery_sensors", + return_value=battery, + ), + patch( + "custom_components.span_panel.sensor.create_power_flow_sensors", + return_value=power_flow, + ), + patch( + "custom_components.span_panel.sensor.create_evse_sensors", + return_value=evse, + ), + ): + result = create_native_sensors(coordinator, snapshot, entry) + + assert result == panel + circuit + unmapped + battery + power_flow + evse + + +def test_create_native_sensors_excludes_unmapped_when_disabled() -> None: + """Unmapped sensors should be excluded when the option is disabled.""" + coordinator = MagicMock() + snapshot = SpanPanelSnapshotFactory.create() + entry = MockConfigEntry(domain=DOMAIN, data={}, title="SPAN Panel") + + panel = [MagicMock(name="panel")] + circuit = [MagicMock(name="circuit")] + battery = [MagicMock(name="battery")] + power_flow = [MagicMock(name="power_flow")] + evse = [MagicMock(name="evse")] + + with ( + patch( + "custom_components.span_panel.sensor.create_panel_sensors", + return_value=panel, + ), + patch( + "custom_components.span_panel.sensor.create_circuit_sensors", + return_value=circuit, + ), + patch( + "custom_components.span_panel.sensor.create_unmapped_circuit_sensors", + ) as mock_unmapped, + patch( + "custom_components.span_panel.sensor.create_battery_sensors", + return_value=battery, + ), + patch( + "custom_components.span_panel.sensor.create_power_flow_sensors", + return_value=power_flow, + ), + patch( + "custom_components.span_panel.sensor.create_evse_sensors", + return_value=evse, + ), + ): + result = create_native_sensors(coordinator, snapshot, entry) + + mock_unmapped.assert_not_called() + assert result == panel + circuit + battery + power_flow + evse + + +def test_create_panel_sensors_filters_net_energy_and_adds_diagnostics() -> None: + """Panel sensors should honor net-energy options and optional diagnostics.""" + snapshot = SpanPanelSnapshotFactory.create( + l1_voltage=120.0, + l2_voltage=121.0, + upstream_l1_current_a=10.0, + upstream_l2_current_a=11.0, + downstream_l1_current_a=12.0, + downstream_l2_current_a=13.0, + main_breaker_rating_a=200, + ) + coordinator = MagicMock() + coordinator.data = snapshot + coordinator.hass = MagicMock() + entry = MockConfigEntry( + domain=DOMAIN, + data={}, + title="SPAN Panel", + options={ENABLE_PANEL_NET_ENERGY_SENSORS: False}, + unique_id=snapshot.serial_number, + ) + coordinator.config_entry = entry + + entities = create_panel_sensors(coordinator, snapshot, entry) + keys = [entity.entity_description.key for entity in entities] + + assert "mainMeterNetEnergyWh" not in keys + assert "feedthroughNetEnergyWh" not in keys + assert "l1_voltage" in keys + assert "l2_voltage" in keys + assert "upstream_l1_current" in keys + assert "upstream_l2_current" in keys + assert "downstream_l1_current" in keys + assert "downstream_l2_current" in keys + assert "main_breaker_rating" in keys + + +def test_build_evse_device_info_map_uses_feed_circuit_and_display_suffix() -> None: + """EVSE feed circuits should map to EVSE sub-device info.""" + snapshot = SpanPanelSnapshotFactory.create( + circuits={ + "c1": SpanCircuitSnapshotFactory.create( + circuit_id="c1", name="Garage Charger" + ) + }, + evse={ + "evse-0": SpanEvseSnapshotFactory.create( + node_id="evse-0", feed_circuit_id="c1" + ) + }, + ) + coordinator = MagicMock() + coordinator.config_entry = MockConfigEntry( + domain=DOMAIN, + data={"device_name": "Main House"}, + title="SPAN Panel", + options={USE_CIRCUIT_NUMBERS: False}, + ) + + mapping = _build_evse_device_info_map(coordinator, snapshot) + + assert list(mapping) == ["c1"] + assert mapping["c1"]["name"] == "Main House SPAN Drive (Garage Charger)" + + +def test_create_circuit_sensors_skips_unmapped_and_optional_net_sensors() -> None: + """Circuit sensors should ignore unmapped tabs and honor net-energy options.""" + snapshot = SpanPanelSnapshotFactory.create( + circuits={ + "c1": SpanCircuitSnapshotFactory.create( + circuit_id="c1", + name="Kitchen", + current_a=10.0, + breaker_rating_a=20.0, + ), + "unmapped_tab_7": SpanCircuitSnapshotFactory.create( + circuit_id="unmapped_tab_7" + ), + }, + evse={ + "evse-0": SpanEvseSnapshotFactory.create( + node_id="evse-0", feed_circuit_id="c1" + ) + }, + ) + coordinator = MagicMock() + coordinator.data = snapshot + coordinator.hass = MagicMock() + entry = MockConfigEntry( + domain=DOMAIN, + data={"device_name": "Main House"}, + title="SPAN Panel", + options={ENABLE_CIRCUIT_NET_ENERGY_SENSORS: False}, + unique_id=snapshot.serial_number, + ) + coordinator.config_entry = entry + + entities = create_circuit_sensors(coordinator, snapshot, entry) + keys = [entity.original_key for entity in entities] + circuit_ids = [entity.circuit_id for entity in entities] + + assert "c1" in circuit_ids + assert "unmapped_tab_7" not in circuit_ids + assert "circuit_energy_net" not in keys + assert "circuit_current" in keys + assert "circuit_breaker_rating" in keys + assert any( + entity.device_info["name"] == "Main House SPAN Drive (Kitchen)" + for entity in entities + ) + + +def test_create_unmapped_circuit_sensors_only_creates_unmapped_entities() -> None: + """Unmapped helper sensors should only be created for unmapped circuits.""" + snapshot = SpanPanelSnapshotFactory.create( + circuits={ + "c1": SpanCircuitSnapshotFactory.create(circuit_id="c1"), + "unmapped_tab_7": SpanCircuitSnapshotFactory.create( + circuit_id="unmapped_tab_7" + ), + } + ) + coordinator = MagicMock() + coordinator.data = snapshot + coordinator.hass = MagicMock() + coordinator.config_entry = MockConfigEntry( + domain=DOMAIN, data={}, title="SPAN Panel" + ) + + entities = create_unmapped_circuit_sensors(coordinator, snapshot) + + assert len(entities) == 3 + assert all(entity.circuit_id == "unmapped_tab_7" for entity in entities) + + +def test_create_battery_sensors_returns_expected_entities_when_bess_present() -> None: + """Battery helpers should create battery power, SoE, and metadata sensors.""" + snapshot = SpanPanelSnapshotFactory.create( + battery=SpanBatterySnapshotFactory.create( + soe_percentage=75.0, vendor_name="Tesla", product_name="Powerwall" + ) + ) + coordinator = MagicMock() + coordinator.data = snapshot + coordinator.hass = MagicMock() + coordinator.config_entry = MockConfigEntry( + domain=DOMAIN, + data={"device_name": "Main House"}, + title="SPAN Panel", + ) + + entities = create_battery_sensors(coordinator, snapshot) + keys = [entity.entity_description.key for entity in entities] + + assert len(entities) == 8 + assert "batteryPowerW" in keys + assert "storage_battery_percentage" in keys + assert "vendor" in keys + + +def test_create_power_flow_sensors_gate_pv_and_site_flow() -> None: + """Power-flow helper should add PV and site/grid entities only when available.""" + snapshot = SpanPanelSnapshotFactory.create( + power_flow_pv=-3500.0, + power_flow_grid=-1200.0, + power_flow_site=2400.0, + pv=SpanPVSnapshot(vendor_name="SolarEdge"), + ) + coordinator = MagicMock() + coordinator.data = snapshot + coordinator.hass = MagicMock() + coordinator.config_entry = MockConfigEntry( + domain=DOMAIN, data={}, title="SPAN Panel" + ) + + entities = create_power_flow_sensors(coordinator, snapshot) + keys = [entity.entity_description.key for entity in entities] + + assert "pvPowerW" in keys + assert "gridPowerFlowW" in keys + assert "sitePowerW" in keys + assert "pv_vendor" in keys + + +def test_create_evse_sensors_creates_all_descriptions_for_each_charger() -> None: + """EVSE helpers should create one entity per EVSE description.""" + snapshot = SpanPanelSnapshotFactory.create( + evse={ + "evse-0": SpanEvseSnapshotFactory.create(node_id="evse-0"), + "evse-1": SpanEvseSnapshotFactory.create(node_id="evse-1"), + } + ) + coordinator = MagicMock() + coordinator.data = snapshot + coordinator.hass = MagicMock() + coordinator.config_entry = MockConfigEntry( + domain=DOMAIN, data={}, title="SPAN Panel" + ) + + entities = create_evse_sensors(coordinator, snapshot) + + assert len(entities) == 6 + assert {entity._evse_id for entity in entities} == {"evse-0", "evse-1"} diff --git a/tests/test_setup_entry.py b/tests/test_setup_entry.py new file mode 100644 index 00000000..2152db52 --- /dev/null +++ b/tests/test_setup_entry.py @@ -0,0 +1,286 @@ +"""Tests for Span Panel async_setup_entry.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from span_panel_api.exceptions import SpanPanelAuthError + +from custom_components.span_panel import SpanPanelRuntimeData, async_setup_entry +from custom_components.span_panel.const import ( + CONF_API_VERSION, + CONF_EBUS_BROKER_HOST, + CONF_EBUS_BROKER_PASSWORD, + CONF_EBUS_BROKER_PORT, + CONF_EBUS_BROKER_USERNAME, + CONF_HTTP_PORT, + DOMAIN, +) +from homeassistant.config_entries import ( + ConfigEntryAuthFailed, + ConfigEntryError, + ConfigEntryNotReady, +) +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant + +from .factories import SpanPanelSnapshotFactory + +from pytest_homeassistant_custom_component.common import MockConfigEntry + + +def _create_v2_entry(**data_overrides) -> MockConfigEntry: + """Create a standard v2 config entry for setup-entry tests.""" + data = { + CONF_API_VERSION: "v2", + CONF_HOST: "192.168.1.50", + CONF_EBUS_BROKER_HOST: "span-panel.local", + CONF_EBUS_BROKER_USERNAME: "mqtt-user", + CONF_EBUS_BROKER_PASSWORD: "mqtt-pass", + CONF_EBUS_BROKER_PORT: 8883, + CONF_HTTP_PORT: 80, + } + data.update(data_overrides) + return MockConfigEntry( + domain=DOMAIN, + data=data, + entry_id="entry-setup", + title="sp3-setup-001", + unique_id="sp3-setup-001", + ) + + +async def test_async_setup_entry_v2_success_sets_runtime_data_and_title( + hass: HomeAssistant, +) -> None: + """Successful v2 setup should register runtime data and normalize the title.""" + entry = _create_v2_entry() + entry.add_to_hass(hass) + snapshot = SpanPanelSnapshotFactory.create(serial_number="sp3-setup-001") + client = MagicMock() + client.connect = AsyncMock() + coordinator = MagicMock() + coordinator.async_config_entry_first_refresh = AsyncMock() + coordinator.async_setup_streaming = AsyncMock() + coordinator.data = snapshot + + with ( + patch("custom_components.span_panel.async_register_commands") as mock_ws, + patch( + "custom_components.span_panel.SpanMqttClient", return_value=client + ) as mock_client_cls, + patch( + "custom_components.span_panel.SpanPanelCoordinator", + return_value=coordinator, + ) as mock_coordinator_cls, + patch( + "custom_components.span_panel.ensure_device_registered", + AsyncMock(), + ) as mock_ensure_device, + patch.object( + hass.config_entries, "async_forward_entry_setups", AsyncMock() + ) as mock_forward, + patch.object(hass.config_entries, "async_update_entry") as mock_update_entry, + ): + assert await async_setup_entry(hass, entry) is True + + assert entry.runtime_data == SpanPanelRuntimeData(coordinator=coordinator) + assert hass.data[DOMAIN]["websocket_registered"] is True + mock_ws.assert_called_once_with(hass) + mock_client_cls.assert_called_once() + client.connect.assert_awaited_once() + mock_coordinator_cls.assert_called_once_with(hass, client, entry) + coordinator.async_config_entry_first_refresh.assert_awaited_once() + coordinator.async_setup_streaming.assert_awaited_once() + mock_ensure_device.assert_awaited_once_with(hass, entry, snapshot, "SPAN Panel") + mock_forward.assert_awaited_once() + mock_update_entry.assert_called_once_with(entry, title="SPAN Panel") + + +async def test_async_setup_entry_v2_missing_mqtt_credentials_raises_auth_failed( + hass: HomeAssistant, +) -> None: + """Missing v2 MQTT credentials should trigger reauthentication.""" + entry = _create_v2_entry(**{CONF_EBUS_BROKER_PASSWORD: None}) + entry.add_to_hass(hass) + + with ( + patch("custom_components.span_panel.async_register_commands"), + patch("custom_components.span_panel.SpanMqttClient") as mock_client_cls, + pytest.raises(ConfigEntryAuthFailed, match="missing MQTT credentials"), + ): + await async_setup_entry(hass, entry) + + mock_client_cls.assert_not_called() + + +async def test_async_setup_entry_v2_missing_unique_id_raises_not_ready( + hass: HomeAssistant, +) -> None: + """A v2 entry without a serial-number unique ID should not set up.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_API_VERSION: "v2", + CONF_HOST: "192.168.1.50", + CONF_EBUS_BROKER_HOST: "span-panel.local", + CONF_EBUS_BROKER_USERNAME: "mqtt-user", + CONF_EBUS_BROKER_PASSWORD: "mqtt-pass", + CONF_EBUS_BROKER_PORT: 8883, + CONF_HTTP_PORT: 80, + }, + entry_id="entry-no-uid", + title="SPAN Panel", + unique_id=None, + ) + entry.add_to_hass(hass) + + with ( + patch("custom_components.span_panel.async_register_commands"), + pytest.raises(ConfigEntryNotReady, match="no unique_id"), + ): + await async_setup_entry(hass, entry) + + +async def test_async_setup_entry_v2_auth_error_closes_client( + hass: HomeAssistant, +) -> None: + """MQTT auth errors should close the client before raising.""" + entry = _create_v2_entry() + entry.add_to_hass(hass) + client = MagicMock() + client.connect = AsyncMock(side_effect=SpanPanelAuthError("bad auth")) + client.close = AsyncMock() + + with ( + patch("custom_components.span_panel.async_register_commands"), + patch( + "custom_components.span_panel.SpanMqttClient", return_value=client + ), + pytest.raises(ConfigEntryAuthFailed, match="MQTT authentication failed"), + ): + await async_setup_entry(hass, entry) + + client.close.assert_awaited_once() + + +async def test_async_setup_entry_v1_requires_reauth( + hass: HomeAssistant, +) -> None: + """Legacy v1 entries should fail with a reauth request.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_API_VERSION: "v1", CONF_HOST: "192.168.1.50"}, + entry_id="entry-v1", + title="SPAN Panel", + unique_id="sp3-v1-001", + ) + entry.add_to_hass(hass) + + with pytest.raises(ConfigEntryAuthFailed, match="requires reauthentication"): + await async_setup_entry(hass, entry) + + +async def test_async_setup_entry_unknown_api_version_raises_config_error( + hass: HomeAssistant, +) -> None: + """Unknown API versions should fail clearly.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_API_VERSION: "v3", CONF_HOST: "192.168.1.50"}, + entry_id="entry-bad-api", + title="SPAN Panel", + unique_id="sp3-bad-api-001", + ) + entry.add_to_hass(hass) + + with ( + patch("custom_components.span_panel.async_register_commands"), + pytest.raises(ConfigEntryError, match="Unknown api_version: v3"), + ): + await async_setup_entry(hass, entry) + + +async def test_async_setup_entry_renames_to_unique_panel_title( + hass: HomeAssistant, +) -> None: + """Serial-number titles should be normalized without colliding with existing entries.""" + existing = MockConfigEntry( + domain=DOMAIN, + data={CONF_API_VERSION: "v2"}, + entry_id="existing-entry", + title="SPAN Panel", + unique_id="sp3-existing-001", + ) + existing.add_to_hass(hass) + + entry = _create_v2_entry() + entry.add_to_hass(hass) + snapshot = SpanPanelSnapshotFactory.create(serial_number="sp3-setup-001") + client = MagicMock() + client.connect = AsyncMock() + coordinator = MagicMock() + coordinator.async_config_entry_first_refresh = AsyncMock() + coordinator.async_setup_streaming = AsyncMock() + coordinator.data = snapshot + + with ( + patch("custom_components.span_panel.async_register_commands"), + patch( + "custom_components.span_panel.SpanMqttClient", return_value=client + ), + patch( + "custom_components.span_panel.SpanPanelCoordinator", + return_value=coordinator, + ), + patch( + "custom_components.span_panel.ensure_device_registered", + AsyncMock(), + ), + patch.object(hass.config_entries, "async_forward_entry_setups", AsyncMock()), + patch.object(hass.config_entries, "async_update_entry") as mock_update_entry, + ): + assert await async_setup_entry(hass, entry) is True + + assert mock_update_entry.call_args_list[-1].kwargs["title"] == "SPAN Panel 2" + + +async def test_async_setup_entry_shutdowns_coordinator_on_forward_failure( + hass: HomeAssistant, +) -> None: + """Late setup failures should shut down the coordinator.""" + entry = _create_v2_entry() + entry.add_to_hass(hass) + snapshot = SpanPanelSnapshotFactory.create(serial_number="sp3-setup-001") + client = MagicMock() + client.connect = AsyncMock() + coordinator = MagicMock() + coordinator.async_config_entry_first_refresh = AsyncMock() + coordinator.async_setup_streaming = AsyncMock() + coordinator.async_shutdown = AsyncMock() + coordinator.data = snapshot + + with ( + patch("custom_components.span_panel.async_register_commands"), + patch( + "custom_components.span_panel.SpanMqttClient", return_value=client + ), + patch( + "custom_components.span_panel.SpanPanelCoordinator", + return_value=coordinator, + ), + patch( + "custom_components.span_panel.ensure_device_registered", + AsyncMock(), + ), + patch.object( + hass.config_entries, + "async_forward_entry_setups", + AsyncMock(side_effect=RuntimeError("forward failed")), + ), + pytest.raises(RuntimeError, match="forward failed"), + ): + await async_setup_entry(hass, entry) + + coordinator.async_shutdown.assert_awaited_once() diff --git a/tests/test_snapshot_interval_option.py b/tests/test_snapshot_interval_option.py new file mode 100644 index 00000000..24f6c1db --- /dev/null +++ b/tests/test_snapshot_interval_option.py @@ -0,0 +1,90 @@ +"""Test snapshot update interval configuration option.""" + +from unittest.mock import MagicMock + +import pytest +import voluptuous as vol + +from custom_components.span_panel.config_flow_options import GENERAL_OPTIONS_SCHEMA +from custom_components.span_panel.const import DEFAULT_SNAPSHOT_INTERVAL +from custom_components.span_panel.options import SNAPSHOT_UPDATE_INTERVAL + + +class TestSnapshotIntervalOption: + """Test snapshot update interval configuration option.""" + + def test_snapshot_interval_constant(self) -> None: + """Test that the option constant is defined correctly.""" + assert SNAPSHOT_UPDATE_INTERVAL == "snapshot_update_interval" + + def test_default_snapshot_interval(self) -> None: + """Test that the default interval is 5.0 seconds.""" + assert DEFAULT_SNAPSHOT_INTERVAL == 5.0 + + def test_snapshot_interval_in_options_schema(self) -> None: + """Test that snapshot interval is in the GENERAL_OPTIONS_SCHEMA.""" + assert SNAPSHOT_UPDATE_INTERVAL in GENERAL_OPTIONS_SCHEMA.schema + + def test_snapshot_interval_valid_values(self) -> None: + """Test that valid values pass validation.""" + validator = GENERAL_OPTIONS_SCHEMA.schema[SNAPSHOT_UPDATE_INTERVAL] + + assert validator(0) == 0.0 + assert validator(0.5) == 0.5 + assert validator(1) == 1.0 + assert validator(1.0) == 1.0 + assert validator(5) == 5.0 + assert validator(15) == 15.0 + + def test_snapshot_interval_coerces_int_to_float(self) -> None: + """Test that integer input is coerced to float.""" + validator = GENERAL_OPTIONS_SCHEMA.schema[SNAPSHOT_UPDATE_INTERVAL] + result = validator(3) + assert isinstance(result, float) + assert result == 3.0 + + def test_snapshot_interval_rejects_negative(self) -> None: + """Test that negative values are rejected.""" + validator = GENERAL_OPTIONS_SCHEMA.schema[SNAPSHOT_UPDATE_INTERVAL] + with pytest.raises(vol.Invalid): + validator(-1) + + def test_snapshot_interval_rejects_above_max(self) -> None: + """Test that values above 15 are rejected.""" + validator = GENERAL_OPTIONS_SCHEMA.schema[SNAPSHOT_UPDATE_INTERVAL] + with pytest.raises(vol.Invalid): + validator(16) + + with pytest.raises(vol.Invalid): + validator(100) + + def test_snapshot_interval_rejects_non_numeric(self) -> None: + """Test that non-numeric values are rejected.""" + validator = GENERAL_OPTIONS_SCHEMA.schema[SNAPSHOT_UPDATE_INTERVAL] + with pytest.raises(vol.Invalid): + validator("invalid") + + def test_snapshot_interval_option_persistence(self) -> None: + """Test that the option is accessible from config entry options.""" + mock_coordinator = MagicMock() + mock_coordinator.config_entry = MagicMock() + mock_coordinator.config_entry.options = { + SNAPSHOT_UPDATE_INTERVAL: 5.0, + } + + interval = mock_coordinator.config_entry.options.get( + SNAPSHOT_UPDATE_INTERVAL, DEFAULT_SNAPSHOT_INTERVAL + ) + assert interval == 5.0 + + def test_snapshot_interval_default_when_missing(self) -> None: + """Test that the default is used when option is not set.""" + mock_coordinator = MagicMock() + mock_coordinator.config_entry = MagicMock() + mock_coordinator.config_entry.options = {} + + interval = mock_coordinator.config_entry.options.get( + SNAPSHOT_UPDATE_INTERVAL, DEFAULT_SNAPSHOT_INTERVAL + ) + assert interval == DEFAULT_SNAPSHOT_INTERVAL + assert interval == 5.0 diff --git a/tests/test_solar_configuration_simulator.py b/tests/test_solar_configuration_simulator.py deleted file mode 100644 index a28435d0..00000000 --- a/tests/test_solar_configuration_simulator.py +++ /dev/null @@ -1,106 +0,0 @@ -"""Test solar configuration using simulator mode (without mocking).""" - -from typing import Any - -from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST -from homeassistant.core import HomeAssistant - -from custom_components.span_panel.const import DOMAIN -from custom_components.span_panel.options import INVERTER_ENABLE, INVERTER_LEG1, INVERTER_LEG2 - -# Import MockConfigEntry from pytest-homeassistant-custom-component -try: - from pytest_homeassistant_custom_component.common import MockConfigEntry -except ImportError: - from homeassistant.config_entries import ConfigEntry as MockConfigEntry - - -async def test_solar_configuration_with_simulator_mode(hass: HomeAssistant, enable_custom_integrations: Any) -> None: - """Test solar configuration using simulator mode - demonstrates the new approach.""" - - # This test demonstrates how tests WOULD work with simulator mode - # Currently fails due to conftest.py mocking span_panel_api - # In real usage (without test mocking), this would work perfectly - - # Create a config entry with simulator mode - config_entry = MockConfigEntry( - domain=DOMAIN, - title="SPAN Panel (Simulator)", - data={ - CONF_HOST: "localhost", - CONF_ACCESS_TOKEN: "simulator_token", - "simulation_mode": True, - }, - options={ - "use_device_prefix": True, - "use_circuit_numbers": False, - INVERTER_ENABLE: False, - INVERTER_LEG1: 0, - INVERTER_LEG2: 0, - }, - ) - - # Add the config entry to hass - config_entry.add_to_hass(hass) - - # This would work in real usage but fails in tests due to mocking - # The integration would: - # 1. Create SpanPanelClient with simulation_mode=True - # 2. Get realistic data from the package's simulation - # 3. Create actual Home Assistant sensors - # 4. Allow testing against real HA state - - # Example of what the test would look like: - # await hass.config_entries.async_setup(config_entry.entry_id) - # await hass.async_block_till_done() - # - # # Test actual HA entities created by simulation - # solar_current_power = hass.states.get("sensor.span_sp3_simulation_001_solar_current_power") - # assert solar_current_power is not None - # assert float(solar_current_power.state) > 0 - # - # # Test configuration changes - # await hass.config_entries.async_update_entry( - # config_entry, - # options={**config_entry.options, INVERTER_ENABLE: True, INVERTER_LEG1: 30, INVERTER_LEG2: 32} - # ) - # await hass.async_block_till_done() - # - # # Verify solar sensors were created - # solar_sensors = [ - # entity_id for entity_id in hass.states.async_entity_ids("sensor") - # if "solar" in entity_id - # ] - # assert len(solar_sensors) >= 4 # power, energy_consumed, energy_produced, etc. - - # For now, just verify the config entry was created correctly - assert config_entry.domain == DOMAIN - assert config_entry.data["simulation_mode"] is True - assert config_entry.data[CONF_HOST] == "localhost" - assert config_entry.data[CONF_ACCESS_TOKEN] == "simulator_token" - - -def test_simulator_approach_benefits(): - """Document the benefits of the simulator approach.""" - - benefits = [ - "No complex mocking infrastructure needed", - "Tests run against real Home Assistant entities", - "Realistic data from span-panel-api simulation", - "Tests the complete integration flow", - "Easy to create different test scenarios", - "Validates actual sensor creation and state", - "Tests configuration changes and entity updates", - "Simpler test setup and maintenance", - ] - - # This test just documents the benefits - assert len(benefits) == 8 - - # The simulator approach would replace: - # - SpanPanelApiResponseFactory - # - Complex mocking in conftest.py - # - patch_span_panel_dependencies - # - YAML fixture comparisons - # - # With simple config entries that use simulation_mode=True diff --git a/tests/test_solar_configuration_with_simulator.py b/tests/test_solar_configuration_with_simulator.py deleted file mode 100644 index 0bc75801..00000000 --- a/tests/test_solar_configuration_with_simulator.py +++ /dev/null @@ -1,340 +0,0 @@ -"""Test solar configuration using simulator mode (real integration test). - -This module automatically runs in simulation mode without requiring the -SPAN_USE_REAL_SIMULATION environment variable to be set externally. -""" - -# Critical: Set simulation mode BEFORE any imports -import os - -import pytest - -# Skip this test if SPAN_USE_REAL_SIMULATION is not set externally -if os.environ.get('SPAN_USE_REAL_SIMULATION', '').lower() not in ('1', 'true', 'yes'): - pytest.skip("Simulation tests require SPAN_USE_REAL_SIMULATION=1", allow_module_level=True) - -os.environ['SPAN_USE_REAL_SIMULATION'] = '1' - -# Configure logging to reduce noise BEFORE other imports -import logging - -logging.getLogger("homeassistant.core").setLevel(logging.WARNING) -logging.getLogger("homeassistant.loader").setLevel(logging.WARNING) -logging.getLogger("homeassistant.setup").setLevel(logging.WARNING) -logging.getLogger("homeassistant.components").setLevel(logging.WARNING) -logging.getLogger("aiohttp").setLevel(logging.WARNING) -logging.getLogger("urllib3").setLevel(logging.WARNING) -logging.getLogger("yaml").setLevel(logging.WARNING) -logging.getLogger("homeassistant.helpers").setLevel(logging.WARNING) -logging.getLogger("homeassistant.config_entries").setLevel(logging.WARNING) - -# Keep our own logs visible for debugging -logging.getLogger("custom_components.span_panel").setLevel(logging.INFO) -logging.getLogger("ha_synthetic_sensors").setLevel(logging.INFO) - -# Now we can safely import everything else -from typing import Any - -from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST -from homeassistant.core import HomeAssistant - -# Import MockConfigEntry from pytest-homeassistant-custom-component -from pytest_homeassistant_custom_component.common import MockConfigEntry -import yaml - -from custom_components.span_panel.const import COORDINATOR, DOMAIN, SENSOR_SET -from custom_components.span_panel.options import INVERTER_ENABLE, INVERTER_LEG1, INVERTER_LEG2 -from custom_components.span_panel.synthetic_solar import handle_solar_options_change - -# Import simulation factory for consistent serial number validation - - -async def test_solar_configuration_with_simulator_friendly_names(hass: HomeAssistant, enable_custom_integrations: Any) -> None: - """Test solar configuration with friendly names using simulator mode.""" - - # Get the consistent simulation serial number from the fixture - with open("tests/fixtures/friendly_names.yaml") as f: - fixture_data = yaml.safe_load(f) - simulation_serial = fixture_data["global_settings"]["device_identifier"] - - # Create a config entry with simulator mode and the correct serial number as host - config_entry = MockConfigEntry( - domain=DOMAIN, - title=f"SPAN Panel (Simulator: {simulation_serial})", - data={ - CONF_HOST: simulation_serial, # Use serial number as host for simulation - CONF_ACCESS_TOKEN: "simulator_token", - "simulation_mode": True, - }, - options={ - "use_device_prefix": True, - "use_circuit_numbers": False, - "enable_solar_circuit": True, # Enable solar from the start - "leg1": 30, - "leg2": 32, - }, - ) - - # Add the config entry to hass - config_entry.add_to_hass(hass) - - # Setup the integration - this will use real simulation data - result = await hass.config_entries.async_setup(config_entry.entry_id) - assert result is True, "Integration should setup successfully" - - # Verify the integration is loaded - assert config_entry.state == ConfigEntryState.LOADED - - # Verify integration data exists - assert DOMAIN in hass.data - assert config_entry.entry_id in hass.data[DOMAIN] - - # Get the coordinator with real simulation data - coordinator = hass.data[DOMAIN][config_entry.entry_id]["coordinator"] - assert coordinator is not None - - # Verify span panel has simulation mode enabled - span_panel = coordinator.data - assert span_panel is not None - assert span_panel.api.simulation_mode is True - - # Validate that the simulator provides the expected serial number - expected_serial = simulation_serial # Use the same serial we extracted earlier - actual_serial = span_panel.status.serial_number - print(f"Expected serial number from YAML: {expected_serial}") - print(f"Actual serial number from simulator: {actual_serial}") - assert actual_serial == expected_serial, f"Serial number mismatch: expected {expected_serial}, got {actual_serial}" - - # Wait for initial data load - await hass.async_block_till_done() - - # Check that native sensors are created first - all_entities = hass.states.async_entity_ids() - print(f"\nAll {len(all_entities)} entities after initial setup:") - for entity_id in sorted(all_entities): - print(f" {entity_id}") - - # Solar sensors should not be created yet during initial setup - solar_entities = [e for e in all_entities if "solar" in e] - print(f"\nFound {len(solar_entities)} solar entities after initial setup: {solar_entities}") - - # Now trigger the options change to create solar sensors (this simulates what happens - # when a user changes options in the UI, which creates solar sensors after native sensors exist) - print("\nTriggering options change to create solar sensors...") - hass.config_entries.async_update_entry( - config_entry, - options={ - "use_device_prefix": True, - "use_circuit_numbers": False, - "enable_solar_circuit": True, - "leg1": 30, # Use circuit 30 (available in simulation) - "leg2": 32, # Use circuit 32 (available in simulation) - }, - ) - - # In the test environment, we need to manually trigger the solar options change - # since the update_listener may not be called automatically - from custom_components.span_panel.synthetic_solar import handle_solar_options_change - - coordinator_data = hass.data.get(DOMAIN, {}).get(config_entry.entry_id, {}) - coordinator = coordinator_data.get(COORDINATOR) - sensor_set = coordinator_data.get(SENSOR_SET) - - print(f"Coordinator available: {coordinator is not None}") - print(f"Sensor set available: {sensor_set is not None}") - - if coordinator and sensor_set: - solar_enabled = config_entry.options.get("enable_solar_circuit", False) - leg1 = config_entry.options.get("leg1", 0) - leg2 = config_entry.options.get("leg2", 0) - - print(f"Calling handle_solar_options_change with: enable={solar_enabled}, leg1={leg1}, leg2={leg2}") - result = await handle_solar_options_change(hass, config_entry, coordinator, sensor_set, solar_enabled, leg1, leg2, "Test Device") - print(f"Solar options change result: {result}") - - # Check what's in synthetic storage after the solar options change - # Use the cached sensor_set directly - if sensor_set: - all_sensors = sensor_set.list_sensors() - solar_sensors_in_storage = [s for s in all_sensors if 'solar' in s.unique_id.lower() or 'inverter' in s.unique_id.lower()] - print(f"Total sensors in storage: {len(all_sensors)}") - print(f"Solar sensors in storage: {len(solar_sensors_in_storage)}") - for sensor in solar_sensors_in_storage[:3]: # Show first 3 - print(f" - {sensor.unique_id} -> {sensor.entity_id}") - else: - print("No sensor set found in coordinator data") - - if result: - # Trigger a reload to activate the new sensors - await hass.config_entries.async_reload(config_entry.entry_id) - - # Wait for the options change to be processed and integration to reload - await hass.async_block_till_done() - - # Wait for solar sensors to be created by polling - import asyncio - - async def wait_for_solar_sensors(timeout=10.0): - """Wait for solar sensors to be created with polling.""" - start_time = asyncio.get_event_loop().time() - - while (asyncio.get_event_loop().time() - start_time) < timeout: - await hass.async_block_till_done() - all_entities = hass.states.async_entity_ids() - solar_entities = [e for e in all_entities if "solar" in e] - - if len(solar_entities) > 0: - return solar_entities - - # Wait a bit before checking again - await asyncio.sleep(0.1) - - return [] - - # Wait for solar sensors to be created - solar_entities = await wait_for_solar_sensors() - - print(f"\nFound {len(solar_entities)} solar entities after options change and reload:") - for entity_id in sorted(solar_entities): - state = hass.states.get(entity_id) - print(f" {entity_id}: {state.state if state else 'None'}") - - # Export the complete YAML configuration - print("\n" + "="*80) - print("EXPORTING COMPLETE YAML CONFIGURATION") - print("="*80) - - # Get the storage manager from the integration data - coordinator_data = hass.data.get(DOMAIN, {}).get(config_entry.entry_id, {}) - storage_manager = coordinator_data.get("storage_manager") - - if storage_manager: - # Export YAML for the main sensor set - try: - yaml_content = storage_manager.export_yaml("span_panel_sensors") - print("\nCOMPLETE YAML CONFIGURATION:") - print("-" * 40) - print(yaml_content) - print("-" * 40) - except Exception as e: - print(f"Error exporting YAML: {e}") - - # Also show sensor set metadata - try: - metadata = storage_manager.get_sensor_set_metadata("span_panel_sensors") - if metadata: - print("\nSensor Set Metadata:") - print(f" ID: {metadata.sensor_set_id}") - print(f" Name: {metadata.name}") - print(f" Device ID: {metadata.device_identifier}") - print(f" Sensor Count: {metadata.sensor_count}") - print(f" Global Settings: {metadata.global_settings}") - except Exception as e: - print(f"Error getting metadata: {e}") - else: - print("Storage manager not found in integration data") - - # Verify solar sensors were created - assert len(solar_entities) > 0, "Solar sensors should be created after options change and reload" - - -async def test_solar_configuration_with_simulator_circuit_numbers(hass: HomeAssistant, enable_custom_integrations: Any) -> None: - """Test solar configuration with circuit numbers using simulator mode.""" - - # Create a config entry with simulator mode and circuit numbers (no solar initially) - config_entry = MockConfigEntry( - domain=DOMAIN, - title="SPAN Panel (Simulator)", - data={ - CONF_HOST: "localhost", - CONF_ACCESS_TOKEN: "simulator_token", - "simulation_mode": True, - }, - options={ - "use_device_prefix": True, - "use_circuit_numbers": True, # Use circuit numbers instead of friendly names - }, - ) - - # Add the config entry to hass - config_entry.add_to_hass(hass) - - # Setup the integration - result = await hass.config_entries.async_setup(config_entry.entry_id) - assert result is True, "Integration should setup successfully" - - # Wait for setup to complete - await hass.async_block_till_done() - - # Verify no solar sensors initially - initial_solar_entities = [e for e in hass.states.async_entity_ids() if "solar" in e] - print(f"\nInitial solar entities (should be 0): {len(initial_solar_entities)}") - - # Now add solar configuration via options change - print("\nConfiguring solar inverter via options change...") - hass.config_entries.async_update_entry( - config_entry, - options={ - "use_device_prefix": True, - "use_circuit_numbers": True, # Use circuit numbers instead of friendly names - INVERTER_ENABLE: True, # Enable solar - INVERTER_LEG1: 30, - INVERTER_LEG2: 32, - }, - ) - - # Trigger options update by calling the handler directly - coordinator_data = hass.data.get(DOMAIN, {}).get(config_entry.entry_id, {}) - coordinator = coordinator_data.get(COORDINATOR) - sensor_set = coordinator_data.get(SENSOR_SET) - - print(f"Coordinator available: {coordinator is not None}") - print(f"Sensor set available: {sensor_set is not None}") - - if coordinator and sensor_set: - solar_enabled = config_entry.options.get(INVERTER_ENABLE, False) - leg1 = config_entry.options.get(INVERTER_LEG1, 0) - leg2 = config_entry.options.get(INVERTER_LEG2, 0) - - print(f"Calling handle_solar_options_change with: enable={solar_enabled}, leg1={leg1}, leg2={leg2}") - result = await handle_solar_options_change(hass, config_entry, coordinator, sensor_set, solar_enabled, leg1, leg2, "Test Device") - print(f"Solar options change result: {result}") - else: - print("Warning: Could not find coordinator or storage manager for solar options change") - - # Reload the integration to apply changes - print("Reloading integration to apply solar configuration...") - await hass.config_entries.async_reload(config_entry.entry_id) - await hass.async_block_till_done() - - # Check sensor keys in the SensorSet instead of entity IDs (sensor keys are stable) - # Get the sensor_set directly from the cached data - coordinator_data = hass.data.get(DOMAIN, {}).get(config_entry.entry_id, {}) - sensor_set = coordinator_data.get(SENSOR_SET) - - if sensor_set: - all_sensors = sensor_set.list_sensors() - # Look for solar sensor keys (these are stable and reliable) - solar_sensors_in_storage = [s for s in all_sensors if 'solar' in s.unique_id.lower()] - - print(f"\nFound {len(solar_sensors_in_storage)} solar sensors in storage:") - for sensor in solar_sensors_in_storage: - print(f" - {sensor.unique_id} -> {sensor.entity_id}") - - # Verify solar sensors were created in storage - assert len(solar_sensors_in_storage) > 0, "Solar sensors should be created in SensorSet after options change" - - # Look for specific solar sensor keys (should include solar_current_power, etc.) - expected_solar_types = ['solar_current_power', 'solar_energy_produced', 'solar_energy_consumed'] - found_solar_types = [] - for sensor in solar_sensors_in_storage: - for solar_type in expected_solar_types: - if solar_type in sensor.unique_id.lower(): - found_solar_types.append(solar_type) - break - - print(f"Found solar sensor types: {found_solar_types}") - assert len(found_solar_types) > 0, f"Should have at least one of {expected_solar_types} solar sensors" - else: - assert False, "No sensor set found in coordinator data" diff --git a/tests/test_span_panel.py b/tests/test_span_panel.py deleted file mode 100644 index dbe1d360..00000000 --- a/tests/test_span_panel.py +++ /dev/null @@ -1,319 +0,0 @@ -"""Tests for the SpanPanel class.""" - -from unittest.mock import AsyncMock, MagicMock - -import pytest - -from custom_components.span_panel.exceptions import SpanPanelReturnedEmptyData -from custom_components.span_panel.options import Options -from custom_components.span_panel.span_panel import SpanPanel -from custom_components.span_panel.span_panel_api import SpanPanelApi -from custom_components.span_panel.span_panel_circuit import SpanPanelCircuit -from custom_components.span_panel.span_panel_data import SpanPanelData -from custom_components.span_panel.span_panel_hardware_status import ( - SpanPanelHardwareStatus, -) -from custom_components.span_panel.span_panel_storage_battery import ( - SpanPanelStorageBattery, -) - - -@pytest.fixture -def mock_options(): - """Create mock options.""" - options = MagicMock(spec=Options) - options.enable_battery_percentage = True - options.enable_solar_sensors = False - options.inverter_leg1 = 0 - options.inverter_leg2 = 0 - options.api_retries = 3 - options.api_retry_timeout = 5.0 - options.api_retry_backoff_multiplier = 2.0 - return options - - -@pytest.fixture -def mock_status(): - """Create mock hardware status.""" - return MagicMock(spec=SpanPanelHardwareStatus) - - -@pytest.fixture -def mock_panel_data(): - """Create mock panel data.""" - return MagicMock(spec=SpanPanelData) - - -@pytest.fixture -def mock_circuits(): - """Create mock circuits.""" - return {"circuit_1": MagicMock(spec=SpanPanelCircuit)} - - -@pytest.fixture -def mock_battery(): - """Create mock storage battery.""" - return MagicMock(spec=SpanPanelStorageBattery) - - -class TestSpanPanelInit: - """Test SpanPanel initialization.""" - - def test_init_with_defaults(self): - """Test initialization with default parameters.""" - panel = SpanPanel("192.168.1.100") - - assert panel.host == "192.168.1.100" - assert panel.options is None - assert isinstance(panel.api, SpanPanelApi) - assert panel._status is None - assert panel._panel is None - assert panel._circuits == {} - assert panel._storage_battery is None - - def test_init_with_access_token(self): - """Test initialization with access token.""" - panel = SpanPanel("192.168.1.100", access_token="test_token") - - assert panel.host == "192.168.1.100" - assert panel.api.access_token == "test_token" - - def test_init_with_options(self, mock_options): - """Test initialization with options.""" - panel = SpanPanel("192.168.1.100", options=mock_options) - - assert panel.host == "192.168.1.100" - assert panel.options is mock_options - assert panel._options is mock_options - - def test_init_with_ssl(self): - """Test initialization with SSL enabled.""" - panel = SpanPanel("192.168.1.100", use_ssl=True) - - assert panel.host == "192.168.1.100" - # SSL parameter is passed to the API - - -class TestSpanPanelProperties: - """Test SpanPanel properties.""" - - def test_host_property(self): - """Test host property returns API host.""" - panel = SpanPanel("192.168.1.100") - assert panel.host == "192.168.1.100" - - def test_options_property(self, mock_options): - """Test options property returns options atomically.""" - panel = SpanPanel("192.168.1.100", options=mock_options) - assert panel.options is mock_options - - def test_status_property_with_data(self, mock_status): - """Test status property when data is available.""" - panel = SpanPanel("192.168.1.100") - panel._status = mock_status - - assert panel.status is mock_status - - def test_status_property_without_data(self): - """Test status property when data is not available.""" - panel = SpanPanel("192.168.1.100") - - with pytest.raises(RuntimeError, match="Hardware status not available"): - _ = panel.status - - def test_panel_property_with_data(self, mock_panel_data): - """Test panel property when data is available.""" - panel = SpanPanel("192.168.1.100") - panel._panel = mock_panel_data - - assert panel.panel is mock_panel_data - - def test_panel_property_without_data(self): - """Test panel property when data is not available.""" - panel = SpanPanel("192.168.1.100") - - with pytest.raises(RuntimeError, match="Panel data not available"): - _ = panel.panel - - def test_circuits_property(self, mock_circuits): - """Test circuits property returns circuits atomically.""" - panel = SpanPanel("192.168.1.100") - panel._circuits = mock_circuits - - assert panel.circuits is mock_circuits - - def test_storage_battery_property_with_data(self, mock_battery): - """Test storage_battery property when data is available.""" - panel = SpanPanel("192.168.1.100") - panel._storage_battery = mock_battery - - assert panel.storage_battery is mock_battery - - def test_storage_battery_property_without_data(self): - """Test storage_battery property when data is not available.""" - panel = SpanPanel("192.168.1.100") - - with pytest.raises(RuntimeError, match="Storage battery not available"): - _ = panel.storage_battery - - -class TestSpanPanelUpdate: - """Test SpanPanel update functionality.""" - - @pytest.mark.asyncio - async def test_update_success_without_battery( - self, mock_status, mock_panel_data, mock_circuits - ): - """Test successful update without battery data.""" - panel = SpanPanel("192.168.1.100") - - # Mock API methods - panel.api.get_all_data = AsyncMock(return_value={ - "status": mock_status, - "panel": mock_panel_data, - "circuits": mock_circuits, - "battery": None - }) - - await panel.update() - - # Verify API calls - panel.api.get_all_data.assert_called_once_with(include_battery=False) - - # Verify data is updated - assert panel._status is mock_status - assert panel._panel is mock_panel_data - assert panel._circuits is mock_circuits - assert panel._storage_battery is None - - @pytest.mark.asyncio - async def test_update_success_with_battery( - self, mock_status, mock_panel_data, mock_circuits, mock_battery, mock_options - ): - """Test successful update with battery data.""" - panel = SpanPanel("192.168.1.100", options=mock_options) - - # Mock API methods - panel.api.get_all_data = AsyncMock(return_value={ - "status": mock_status, - "panel": mock_panel_data, - "circuits": mock_circuits, - "battery": mock_battery - }) - - await panel.update() - - # Verify API calls - panel.api.get_all_data.assert_called_once_with(include_battery=True) - - # Verify data is updated - assert panel._status is mock_status - assert panel._panel is mock_panel_data - assert panel._circuits is mock_circuits - assert panel._storage_battery is mock_battery - - @pytest.mark.asyncio - async def test_update_with_battery_disabled(self, mock_status, mock_panel_data, mock_circuits): - """Test update when battery is disabled in options.""" - options = MagicMock(spec=Options) - options.enable_battery_percentage = False - options.enable_solar_sensors = False - options.inverter_leg1 = 0 - options.inverter_leg2 = 0 - options.api_retries = 3 - options.api_retry_timeout = 5.0 - options.api_retry_backoff_multiplier = 2.0 - panel = SpanPanel("192.168.1.100", options=options) - - # Mock API methods - panel.api.get_all_data = AsyncMock(return_value={ - "status": mock_status, - "panel": mock_panel_data, - "circuits": mock_circuits, - "battery": None - }) - - await panel.update() - - # Verify API calls - panel.api.get_all_data.assert_called_once_with(include_battery=False) - assert panel._storage_battery is None - - @pytest.mark.asyncio - async def test_update_handles_empty_data_exception( - self, mock_status, mock_panel_data, mock_circuits - ): - """Test update handles SpanPanelReturnedEmptyData exception.""" - panel = SpanPanel("192.168.1.100") - - # Mock API methods - raises empty data exception - panel.api.get_all_data = AsyncMock(side_effect=SpanPanelReturnedEmptyData("Empty data")) - - # Should not raise exception, just log warning - await panel.update() - - # Data should not be updated due to exception - assert panel._status is None - assert panel._panel is None - assert panel._circuits == {} - - @pytest.mark.asyncio - async def test_update_propagates_other_exceptions(self): - """Test update propagates non-empty-data exceptions.""" - panel = SpanPanel("192.168.1.100") - - # Mock API method to raise generic exception - panel.api.get_all_data = AsyncMock(side_effect=Exception("API error")) - - with pytest.raises(Exception, match="API error"): - await panel.update() - - -class TestSpanPanelAtomicUpdates: - """Test SpanPanel atomic update methods.""" - - def test_update_status(self, mock_status): - """Test atomic status update.""" - panel = SpanPanel("192.168.1.100") - - panel._update_status(mock_status) - - assert panel._status is mock_status - - def test_update_panel(self, mock_panel_data): - """Test atomic panel update.""" - panel = SpanPanel("192.168.1.100") - - panel._update_panel(mock_panel_data) - - assert panel._panel is mock_panel_data - - def test_update_circuits(self, mock_circuits): - """Test atomic circuits update.""" - panel = SpanPanel("192.168.1.100") - - panel._update_circuits(mock_circuits) - - assert panel._circuits is mock_circuits - - def test_update_storage_battery(self, mock_battery): - """Test atomic storage battery update.""" - panel = SpanPanel("192.168.1.100") - - panel._update_storage_battery(mock_battery) - - assert panel._storage_battery is mock_battery - - -class TestSpanPanelClose: - """Test SpanPanel close functionality.""" - - @pytest.mark.asyncio - async def test_close(self): - """Test close method calls API close.""" - panel = SpanPanel("192.168.1.100") - panel.api.close = AsyncMock() - - await panel.close() - - panel.api.close.assert_called_once() diff --git a/tests/test_span_panel_api.py b/tests/test_span_panel_api.py deleted file mode 100644 index 028ae50e..00000000 --- a/tests/test_span_panel_api.py +++ /dev/null @@ -1,896 +0,0 @@ -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest -from span_panel_api.exceptions import ( - SpanPanelAPIError, - SpanPanelAuthError, - SpanPanelRetriableError, - SpanPanelServerError, -) - -from custom_components.span_panel.exceptions import SpanPanelReturnedEmptyData -from custom_components.span_panel.span_panel_api import SpanPanelApi - - -@pytest.mark.asyncio -async def test_setup_with_invalid_token(monkeypatch): - api = SpanPanelApi("host", access_token="badtoken") - monkeypatch.setattr(api, "get_panel_data", AsyncMock(side_effect=Exception("fail"))) - with pytest.raises(Exception): - await api.setup() - assert not api._authenticated - - -@pytest.mark.asyncio -async def test_ping_success(monkeypatch): - api = SpanPanelApi("host") - monkeypatch.setattr(api, "get_status_data", AsyncMock(return_value=True)) - assert await api.ping() is True - - -@pytest.mark.asyncio -async def test_ping_failure(monkeypatch): - api = SpanPanelApi("host") - monkeypatch.setattr(api, "get_status_data", AsyncMock(side_effect=SpanPanelAPIError("fail"))) - assert await api.ping() is False - - -@pytest.mark.asyncio -async def test_get_access_token_success(monkeypatch): - api = SpanPanelApi("host") - - # Mock the entire get_access_token method to avoid network calls - async def mock_get_access_token(): - return "token" - - monkeypatch.setattr(api, "get_access_token", mock_get_access_token) - assert await api.get_access_token() == "token" - - -@pytest.mark.asyncio -async def test_get_access_token_auth_error(monkeypatch): - api = SpanPanelApi("host") - - # Mock the entire get_access_token method to raise the expected error - async def mock_get_access_token(): - raise SpanPanelAuthError("fail") - - monkeypatch.setattr(api, "get_access_token", mock_get_access_token) - with pytest.raises(SpanPanelAuthError): - await api.get_access_token() - - -@pytest.mark.asyncio -async def test_close_sets_client_none(): - api = SpanPanelApi("host") - await api.close() - assert api._client is None - - -@pytest.mark.asyncio -async def test_setup_with_valid_token(): - api = SpanPanelApi("host", access_token="valid_token") - # Mock get_panel_data to succeed - api.get_panel_data = AsyncMock() - await api.setup() - assert api._authenticated is True - - -@pytest.mark.asyncio -async def test_setup_without_token(): - api = SpanPanelApi("host") - await api.setup() - # Should not be authenticated without token - assert api._authenticated is False - - -@pytest.mark.asyncio -async def test_ping_with_auth_success(): - api = SpanPanelApi("host") - api.get_panel_data = AsyncMock() - result = await api.ping_with_auth() - assert result is True - - -@pytest.mark.asyncio -async def test_ping_with_auth_failure(): - api = SpanPanelApi("host") - api.get_panel_data = AsyncMock(side_effect=SpanPanelAuthError("fail")) - result = await api.ping_with_auth() - assert result is False - - -def test_ensure_client_open_client_none(): - api = SpanPanelApi("host") - api._client = None - api._client_created = True # Mark as previously created and closed - with pytest.raises(SpanPanelAPIError, match="API client has been closed"): - api._ensure_client_open() - - -def test_debug_check_client_none(): - api = SpanPanelApi("host") - api._client = None - # Should not raise, just log - api._debug_check_client("test_method") - - -@pytest.mark.asyncio -async def test_get_status_data(): - api = SpanPanelApi("host") - mock_client = MagicMock() - mock_status = MagicMock() - mock_client.get_status = AsyncMock(return_value=mock_status) - api._client = mock_client - api._ensure_client_open = MagicMock() - - result = await api.get_status_data() - assert result is not None - - -@pytest.mark.asyncio -async def test_get_panel_data(): - api = SpanPanelApi("host") - mock_client = MagicMock() - mock_panel = MagicMock() - mock_client.get_panel_state = AsyncMock(return_value=mock_panel) - api._client = mock_client - api._ensure_client_open = MagicMock() - api._ensure_authenticated = AsyncMock() - - result = await api.get_panel_data() - assert result is not None - - -@pytest.mark.asyncio -async def test_get_circuits_data(): - api = SpanPanelApi("host") - mock_client = MagicMock() - mock_circuits_response = MagicMock() - mock_circuits_response.circuits.additional_properties = { - "1": MagicMock(), - "2": MagicMock(), - } - # Mock the to_dict method for each circuit with correct key names - for circuit in mock_circuits_response.circuits.additional_properties.values(): - circuit.to_dict.return_value = { - "id": "1", - "name": "Test Circuit", - "relayState": "CLOSED", - "instantPowerW": 100.0, - "instantPowerUpdateTimeS": 1672531200, - "producedEnergyWh": 0.0, - "consumedEnergyWh": 50.0, - "energyAccumUpdateTimeS": 1672531200, - "tabs": [1], - "priority": "MUST_HAVE", - "isUserControllable": True, - "isSheddable": False, - "isNeverBackup": False, - } - - mock_client.get_circuits = AsyncMock(return_value=mock_circuits_response) - api._client = mock_client - api._ensure_client_open = MagicMock() - api._ensure_authenticated = AsyncMock() - - result = await api.get_circuits_data() - assert len(result) == 2 - - -@pytest.mark.asyncio -async def test_get_storage_battery_data(): - api = SpanPanelApi("host") - mock_client = MagicMock() - mock_storage_response = MagicMock() - mock_storage_response.soe.to_dict.return_value = {"battery_percentage": 85.5} - mock_client.get_storage_soe = AsyncMock(return_value=mock_storage_response) - api._client = mock_client - api._ensure_client_open = MagicMock() - api._ensure_authenticated = AsyncMock() - - result = await api.get_storage_battery_data() - assert result is not None - - -@pytest.mark.asyncio -async def test_set_relay(): - from custom_components.span_panel.const import CircuitRelayState - from custom_components.span_panel.span_panel_circuit import SpanPanelCircuit - - api = SpanPanelApi("host") - mock_client = MagicMock() - mock_client.set_circuit_relay = AsyncMock() - api._client = mock_client - api._ensure_client_open = MagicMock() - api._ensure_authenticated = AsyncMock() - - # Create a mock circuit - circuit = SpanPanelCircuit( - circuit_id="1", - name="Test Circuit", - relay_state=CircuitRelayState.CLOSED, - instant_power=100.0, - instant_power_update_time="2023-01-01T00:00:00Z", - produced_energy=0.0, - consumed_energy=50.0, - energy_accum_update_time="2023-01-01T00:00:00Z", - tabs=["A"], - priority="MUST_HAVE", - is_user_controllable=True, - is_sheddable=False, - is_never_backup=False, - ) - - await api.set_relay(circuit, CircuitRelayState.OPEN) - mock_client.set_circuit_relay.assert_called_once_with("1", "OPEN") - - -@pytest.mark.asyncio -async def test_set_priority(): - from custom_components.span_panel.const import CircuitPriority, CircuitRelayState - from custom_components.span_panel.span_panel_circuit import SpanPanelCircuit - - api = SpanPanelApi("host") - mock_client = MagicMock() - mock_client.set_circuit_priority = AsyncMock() - api._client = mock_client - api._ensure_client_open = MagicMock() - api._ensure_authenticated = AsyncMock() - - # Create a mock circuit - circuit = SpanPanelCircuit( - circuit_id="1", - name="Test Circuit", - relay_state=CircuitRelayState.CLOSED, - instant_power=100.0, - instant_power_update_time="2023-01-01T00:00:00Z", - produced_energy=0.0, - consumed_energy=50.0, - energy_accum_update_time="2023-01-01T00:00:00Z", - tabs=["A"], - priority="MUST_HAVE", - is_user_controllable=True, - is_sheddable=False, - is_never_backup=False, - ) - - await api.set_priority(circuit, CircuitPriority.NICE_TO_HAVE) - mock_client.set_circuit_priority.assert_called_once_with("1", "NICE_TO_HAVE") - - -@pytest.mark.asyncio -async def test_api_error_handling(): - api = SpanPanelApi("host") - api._client = None - api._client_created = True # Mark as previously created and closed - - with pytest.raises(SpanPanelAPIError, match="API client has been closed"): - await api.get_status_data() - - with pytest.raises(SpanPanelAPIError, match="API client has been closed"): - await api.get_panel_data() - - with pytest.raises(SpanPanelAPIError, match="API client has been closed"): - await api.get_circuits_data() - - -@pytest.mark.asyncio -async def test_ensure_client_open_with_closed_client(): - api = SpanPanelApi("host") - # Mock a client with closed underlying httpx client - mock_client = MagicMock() - mock_httpx_client = MagicMock() - mock_httpx_client.is_closed = True - mock_client._client = mock_httpx_client - api._client = mock_client - - # The method should not create a new client when underlying httpx is closed - # It just logs a message and lets SpanPanelClient handle it internally - api._ensure_client_open() - - # Should not create new client, just log the message - assert api._client == mock_client - - -@pytest.mark.asyncio -async def test_ensure_client_open_with_options(): - from custom_components.span_panel.options import Options - - # Create mock config entry - mock_entry = MagicMock() - mock_entry.options = { - "api_retries": 5, - "api_retry_timeout": 10, - "api_retry_backoff_multiplier": 2.5, - } - options = Options(mock_entry) - - api = SpanPanelApi("host", options=options) - # Mock a client with closed underlying httpx client - mock_client = MagicMock() - mock_httpx_client = MagicMock() - mock_httpx_client.is_closed = True - mock_client._client = mock_httpx_client - api._client = mock_client - - # The method should not create a new client when underlying httpx is closed - # It just logs a message and lets SpanPanelClient handle it internally - api._ensure_client_open() - - # Should not create new client, just log the message - assert api._client == mock_client - - -@pytest.mark.asyncio -async def test_ensure_client_open_with_access_token(): - api = SpanPanelApi("host", access_token="test_token") - # Mock a client with closed underlying httpx client - mock_client = MagicMock() - mock_httpx_client = MagicMock() - mock_httpx_client.is_closed = True - mock_client._client = mock_httpx_client - api._client = mock_client - - # The method should not create a new client when underlying httpx is closed - # It just logs a message and lets SpanPanelClient handle it internally - api._ensure_client_open() - - # Should not create new client, just log the message - assert api._client == mock_client - - -@pytest.mark.asyncio -async def test_ensure_authenticated_success(): - api = SpanPanelApi("host") - api._authenticated = False - mock_client = MagicMock() - mock_auth_response = MagicMock() - mock_auth_response.access_token = "new_token" - mock_client.authenticate = AsyncMock(return_value=mock_auth_response) - api._client = mock_client - - await api._ensure_authenticated() - - assert api._authenticated is True - assert api.access_token == "new_token" - mock_client.authenticate.assert_called_once() - - -@pytest.mark.asyncio -async def test_ensure_authenticated_failure(): - api = SpanPanelApi("host") - api._authenticated = False - mock_client = MagicMock() - mock_client.authenticate = AsyncMock(side_effect=Exception("Auth failed")) - api._client = mock_client - - with pytest.raises(SpanPanelAuthError, match="Re-authentication failed"): - await api._ensure_authenticated() - - -@pytest.mark.asyncio -async def test_ensure_authenticated_client_none(): - api = SpanPanelApi("host") - api._authenticated = False - api._client = None - - with pytest.raises(SpanPanelAPIError, match="API client has been closed"): - await api._ensure_authenticated() - - -@pytest.mark.asyncio -async def test_setup_with_auth_error(): - api = SpanPanelApi("host", access_token="invalid_token") - api.get_panel_data = AsyncMock(side_effect=SpanPanelAuthError("Invalid token")) - api.close = AsyncMock() - - await api.setup() - - assert api._authenticated is False - - -@pytest.mark.asyncio -async def test_setup_with_general_exception(): - api = SpanPanelApi("host", access_token="token") - api.get_panel_data = AsyncMock(side_effect=Exception("Network error")) - api.close = AsyncMock() - - with pytest.raises(Exception, match="Network error"): - await api.setup() - - api.close.assert_called_once() - - -@pytest.mark.asyncio -async def test_debug_check_client_with_closed_client(): - api = SpanPanelApi("host") - mock_client = MagicMock() - mock_httpx_client = MagicMock() - mock_httpx_client.is_closed = True - mock_client._client = mock_httpx_client - mock_client._in_context = False - api._client = mock_client - - # Should not raise, just log - api._debug_check_client("test_method") - - -@pytest.mark.asyncio -async def test_get_status_data_with_retriable_error(): - api = SpanPanelApi("host") - mock_client = MagicMock() - mock_client.get_status = AsyncMock(side_effect=SpanPanelRetriableError("Retry me")) - api._client = mock_client - api._ensure_client_open = MagicMock() - - with pytest.raises(SpanPanelRetriableError): - await api.get_status_data() - - -@pytest.mark.asyncio -async def test_get_status_data_with_server_error(): - api = SpanPanelApi("host") - mock_client = MagicMock() - mock_client.get_status = AsyncMock(side_effect=SpanPanelServerError("Server error")) - api._client = mock_client - api._ensure_client_open = MagicMock() - - with pytest.raises(SpanPanelServerError): - await api.get_status_data() - - -@pytest.mark.asyncio -async def test_get_panel_data_with_empty_data(): - from custom_components.span_panel.const import PANEL_MAIN_RELAY_STATE_UNKNOWN_VALUE - - api = SpanPanelApi("host") - mock_client = MagicMock() - mock_panel_response = MagicMock() - mock_panel_response.to_dict.return_value = { - "main_relay_state": PANEL_MAIN_RELAY_STATE_UNKNOWN_VALUE - } - mock_client.get_panel_state = AsyncMock(return_value=mock_panel_response) - api._client = mock_client - api._ensure_client_open = MagicMock() - api._ensure_authenticated = AsyncMock() - - with patch("custom_components.span_panel.span_panel_api.SpanPanelData") as mock_data_class: - mock_data = MagicMock() - mock_data.main_relay_state = PANEL_MAIN_RELAY_STATE_UNKNOWN_VALUE - mock_data_class.from_dict.return_value = mock_data - - with pytest.raises(SpanPanelReturnedEmptyData): - await api.get_panel_data() - - -@pytest.mark.asyncio -async def test_get_panel_data_with_auth_error(): - api = SpanPanelApi("host") - mock_client = MagicMock() - mock_client.get_panel_state = AsyncMock(side_effect=SpanPanelAuthError("Auth failed")) - api._client = mock_client - api._ensure_client_open = MagicMock() - api._ensure_authenticated = AsyncMock() - api._authenticated = True - - with pytest.raises(SpanPanelAuthError): - await api.get_panel_data() - - # Should reset auth flag - assert api._authenticated is False - - -@pytest.mark.asyncio -async def test_get_circuits_data_empty_response(): - api = SpanPanelApi("host") - mock_client = MagicMock() - mock_circuits_response = MagicMock() - mock_circuits_response.circuits.additional_properties = {} - mock_client.get_circuits = AsyncMock(return_value=mock_circuits_response) - api._client = mock_client - api._ensure_client_open = MagicMock() - api._ensure_authenticated = AsyncMock() - - with pytest.raises(SpanPanelReturnedEmptyData): - await api.get_circuits_data() - - -@pytest.mark.asyncio -async def test_get_storage_battery_data_empty_response(): - api = SpanPanelApi("host") - mock_client = MagicMock() - mock_storage_response = MagicMock() - mock_storage_response.soe.to_dict.return_value = {} # Empty response - mock_client.get_storage_soe = AsyncMock(return_value=mock_storage_response) - api._client = mock_client - api._ensure_client_open = MagicMock() - api._ensure_authenticated = AsyncMock() - - with pytest.raises(SpanPanelReturnedEmptyData): - await api.get_storage_battery_data() - - -@pytest.mark.asyncio -async def test_get_panel_data_auth_reset_on_auth_error(): - """Test that authentication flag is reset when auth error occurs in get_panel_data.""" - api = SpanPanelApi("host") - api._authenticated = True # Start as authenticated - mock_client = MagicMock() - mock_client.get_panel_state = AsyncMock(side_effect=SpanPanelAuthError("Auth failed")) - api._client = mock_client - api._ensure_client_open = MagicMock() - api._ensure_authenticated = AsyncMock() - - with pytest.raises(SpanPanelAuthError): - await api.get_panel_data() - - # Verify auth flag was reset - assert api._authenticated is False - - -@pytest.mark.asyncio -async def test_get_circuits_data_auth_reset_on_auth_error(): - """Test that authentication flag is reset when auth error occurs in get_circuits_data.""" - api = SpanPanelApi("host") - api._authenticated = True # Start as authenticated - mock_client = MagicMock() - mock_client.get_circuits = AsyncMock(side_effect=SpanPanelAuthError("Auth failed")) - api._client = mock_client - api._ensure_client_open = MagicMock() - api._ensure_authenticated = AsyncMock() - - with pytest.raises(SpanPanelAuthError): - await api.get_circuits_data() - - # Verify auth flag was reset - assert api._authenticated is False - - -@pytest.mark.asyncio -async def test_get_storage_battery_data_auth_reset_on_auth_error(): - """Test that authentication flag is reset when auth error occurs in get_storage_battery_data.""" - api = SpanPanelApi("host") - api._authenticated = True # Start as authenticated - mock_client = MagicMock() - mock_client.get_storage_soe = AsyncMock(side_effect=SpanPanelAuthError("Auth failed")) - api._client = mock_client - api._ensure_client_open = MagicMock() - api._ensure_authenticated = AsyncMock() - - with pytest.raises(SpanPanelAuthError): - await api.get_storage_battery_data() - - # Verify auth flag was reset - assert api._authenticated is False - - -@pytest.mark.asyncio -async def test_set_relay_retriable_error(): - """Test set_relay handles retriable errors correctly.""" - from custom_components.span_panel.const import CircuitRelayState - from custom_components.span_panel.span_panel_circuit import SpanPanelCircuit - - api = SpanPanelApi("host") - mock_client = MagicMock() - mock_client.set_circuit_relay = AsyncMock( - side_effect=SpanPanelRetriableError("Retriable error") - ) - api._client = mock_client - api._ensure_client_open = MagicMock() - api._ensure_authenticated = AsyncMock() - - circuit = SpanPanelCircuit( - circuit_id="1", - name="Test Circuit", - relay_state=CircuitRelayState.CLOSED, - instant_power=100.0, - instant_power_update_time="2023-01-01T00:00:00Z", - produced_energy=0.0, - consumed_energy=50.0, - energy_accum_update_time="2023-01-01T00:00:00Z", - tabs=["A"], - priority="MUST_HAVE", - is_user_controllable=True, - is_sheddable=False, - is_never_backup=False, - ) - - with pytest.raises(SpanPanelRetriableError): - await api.set_relay(circuit, CircuitRelayState.OPEN) - - -@pytest.mark.asyncio -async def test_set_relay_server_error(): - """Test set_relay handles server errors correctly.""" - from custom_components.span_panel.const import CircuitRelayState - from custom_components.span_panel.span_panel_circuit import SpanPanelCircuit - - api = SpanPanelApi("host") - mock_client = MagicMock() - mock_client.set_circuit_relay = AsyncMock(side_effect=SpanPanelServerError("Server error")) - api._client = mock_client - api._ensure_client_open = MagicMock() - api._ensure_authenticated = AsyncMock() - - circuit = SpanPanelCircuit( - circuit_id="1", - name="Test Circuit", - relay_state=CircuitRelayState.CLOSED, - instant_power=100.0, - instant_power_update_time="2023-01-01T00:00:00Z", - produced_energy=0.0, - consumed_energy=50.0, - energy_accum_update_time="2023-01-01T00:00:00Z", - tabs=["A"], - priority="MUST_HAVE", - is_user_controllable=True, - is_sheddable=False, - is_never_backup=False, - ) - - with pytest.raises(SpanPanelServerError): - await api.set_relay(circuit, CircuitRelayState.OPEN) - - -@pytest.mark.asyncio -async def test_set_relay_auth_reset_on_auth_error(): - """Test that authentication flag is reset when auth error occurs in set_relay.""" - from custom_components.span_panel.const import CircuitRelayState - from custom_components.span_panel.span_panel_circuit import SpanPanelCircuit - - api = SpanPanelApi("host") - api._authenticated = True # Start as authenticated - mock_client = MagicMock() - mock_client.set_circuit_relay = AsyncMock(side_effect=SpanPanelAuthError("Auth failed")) - api._client = mock_client - api._ensure_client_open = MagicMock() - api._ensure_authenticated = AsyncMock() - - circuit = SpanPanelCircuit( - circuit_id="1", - name="Test Circuit", - relay_state=CircuitRelayState.CLOSED, - instant_power=100.0, - instant_power_update_time="2023-01-01T00:00:00Z", - produced_energy=0.0, - consumed_energy=50.0, - energy_accum_update_time="2023-01-01T00:00:00Z", - tabs=["A"], - priority="MUST_HAVE", - is_user_controllable=True, - is_sheddable=False, - is_never_backup=False, - ) - - with pytest.raises(SpanPanelAuthError): - await api.set_relay(circuit, CircuitRelayState.OPEN) - - # Verify auth flag was reset - assert api._authenticated is False - - -@pytest.mark.asyncio -async def test_set_priority_retriable_error(): - """Test set_priority handles retriable errors correctly.""" - from custom_components.span_panel.const import CircuitPriority, CircuitRelayState - from custom_components.span_panel.span_panel_circuit import SpanPanelCircuit - - api = SpanPanelApi("host") - mock_client = MagicMock() - mock_client.set_circuit_priority = AsyncMock( - side_effect=SpanPanelRetriableError("Retriable error") - ) - api._client = mock_client - api._ensure_client_open = MagicMock() - api._ensure_authenticated = AsyncMock() - - circuit = SpanPanelCircuit( - circuit_id="1", - name="Test Circuit", - relay_state=CircuitRelayState.CLOSED, - instant_power=100.0, - instant_power_update_time="2023-01-01T00:00:00Z", - produced_energy=0.0, - consumed_energy=50.0, - energy_accum_update_time="2023-01-01T00:00:00Z", - tabs=["A"], - priority="MUST_HAVE", - is_user_controllable=True, - is_sheddable=False, - is_never_backup=False, - ) - - with pytest.raises(SpanPanelRetriableError): - await api.set_priority(circuit, CircuitPriority.MUST_HAVE) - - -@pytest.mark.asyncio -async def test_set_priority_server_error(): - """Test set_priority handles server errors correctly.""" - from custom_components.span_panel.const import CircuitPriority, CircuitRelayState - from custom_components.span_panel.span_panel_circuit import SpanPanelCircuit - - api = SpanPanelApi("host") - mock_client = MagicMock() - mock_client.set_circuit_priority = AsyncMock(side_effect=SpanPanelServerError("Server error")) - api._client = mock_client - api._ensure_client_open = MagicMock() - api._ensure_authenticated = AsyncMock() - - circuit = SpanPanelCircuit( - circuit_id="1", - name="Test Circuit", - relay_state=CircuitRelayState.CLOSED, - instant_power=100.0, - instant_power_update_time="2023-01-01T00:00:00Z", - produced_energy=0.0, - consumed_energy=50.0, - energy_accum_update_time="2023-01-01T00:00:00Z", - tabs=["A"], - priority="MUST_HAVE", - is_user_controllable=True, - is_sheddable=False, - is_never_backup=False, - ) - - with pytest.raises(SpanPanelServerError): - await api.set_priority(circuit, CircuitPriority.MUST_HAVE) - - -@pytest.mark.asyncio -async def test_set_priority_auth_reset_on_auth_error(): - """Test that authentication flag is reset when auth error occurs in set_priority.""" - from custom_components.span_panel.const import CircuitPriority, CircuitRelayState - from custom_components.span_panel.span_panel_circuit import SpanPanelCircuit - - api = SpanPanelApi("host") - api._authenticated = True # Start as authenticated - mock_client = MagicMock() - mock_client.set_circuit_priority = AsyncMock(side_effect=SpanPanelAuthError("Auth failed")) - api._client = mock_client - api._ensure_client_open = MagicMock() - api._ensure_authenticated = AsyncMock() - - circuit = SpanPanelCircuit( - circuit_id="1", - name="Test Circuit", - relay_state=CircuitRelayState.CLOSED, - instant_power=100.0, - instant_power_update_time="2023-01-01T00:00:00Z", - produced_energy=0.0, - consumed_energy=50.0, - energy_accum_update_time="2023-01-01T00:00:00Z", - tabs=["A"], - priority="MUST_HAVE", - is_user_controllable=True, - is_sheddable=False, - is_never_backup=False, - ) - - with pytest.raises(SpanPanelAuthError): - await api.set_priority(circuit, CircuitPriority.MUST_HAVE) - - # Verify auth flag was reset - assert api._authenticated is False - - -@pytest.mark.asyncio -async def test_close_with_exception(): - """Test close method handles exceptions during client close.""" - api = SpanPanelApi("host") - mock_client = MagicMock() - mock_client.close = AsyncMock(side_effect=Exception("Close error")) - api._client = mock_client - - # Should not raise exception, just log warning - await api.close() - - # Client should still be set to None - assert api._client is None - - -@pytest.mark.asyncio -async def test_ping_with_auth_connection_error(): - """Test ping_with_auth handles connection errors correctly.""" - api = SpanPanelApi("host") - api.get_panel_data = AsyncMock(side_effect=SpanPanelReturnedEmptyData("Empty data")) - result = await api.ping_with_auth() - assert result is False - - -@pytest.mark.asyncio -async def test_get_access_token_connection_error(): - """Test get_access_token handles connection errors correctly.""" - api = SpanPanelApi("host") - mock_client = MagicMock() - mock_client.authenticate = AsyncMock(side_effect=SpanPanelAPIError("Connection failed")) - api._client = mock_client - api._ensure_client_open = MagicMock() - - with pytest.raises(SpanPanelReturnedEmptyData): - await api.get_access_token() - - -@pytest.mark.asyncio -async def test_get_storage_battery_data_retriable_error(): - """Test get_storage_battery_data handles retriable errors correctly.""" - api = SpanPanelApi("host") - mock_client = MagicMock() - mock_client.get_storage_soe = AsyncMock(side_effect=SpanPanelRetriableError("Retriable error")) - api._client = mock_client - api._ensure_client_open = MagicMock() - api._ensure_authenticated = AsyncMock() - - with pytest.raises(SpanPanelRetriableError): - await api.get_storage_battery_data() - - -@pytest.mark.asyncio -async def test_get_storage_battery_data_server_error(): - """Test get_storage_battery_data handles server errors correctly.""" - api = SpanPanelApi("host") - mock_client = MagicMock() - mock_client.get_storage_soe = AsyncMock(side_effect=SpanPanelServerError("Server error")) - api._client = mock_client - api._ensure_client_open = MagicMock() - api._ensure_authenticated = AsyncMock() - - with pytest.raises(SpanPanelServerError): - await api.get_storage_battery_data() - - -@pytest.mark.asyncio -async def test_get_circuits_data_retriable_error(): - """Test get_circuits_data handles retriable errors correctly.""" - api = SpanPanelApi("host") - mock_client = MagicMock() - mock_client.get_circuits = AsyncMock(side_effect=SpanPanelRetriableError("Retriable error")) - api._client = mock_client - api._ensure_client_open = MagicMock() - api._ensure_authenticated = AsyncMock() - - with pytest.raises(SpanPanelRetriableError): - await api.get_circuits_data() - - -@pytest.mark.asyncio -async def test_get_circuits_data_server_error(): - """Test get_circuits_data handles server errors correctly.""" - api = SpanPanelApi("host") - mock_client = MagicMock() - mock_client.get_circuits = AsyncMock(side_effect=SpanPanelServerError("Server error")) - api._client = mock_client - api._ensure_client_open = MagicMock() - api._ensure_authenticated = AsyncMock() - - with pytest.raises(SpanPanelServerError): - await api.get_circuits_data() - - -@pytest.mark.asyncio -async def test_get_panel_data_retriable_error(): - """Test get_panel_data handles retriable errors correctly.""" - api = SpanPanelApi("host") - mock_client = MagicMock() - mock_client.get_panel_state = AsyncMock(side_effect=SpanPanelRetriableError("Retriable error")) - api._client = mock_client - api._ensure_client_open = MagicMock() - api._ensure_authenticated = AsyncMock() - - with pytest.raises(SpanPanelRetriableError): - await api.get_panel_data() - - -@pytest.mark.asyncio -async def test_get_panel_data_server_error(): - """Test get_panel_data handles server errors correctly.""" - api = SpanPanelApi("host") - mock_client = MagicMock() - mock_client.get_panel_state = AsyncMock(side_effect=SpanPanelServerError("Server error")) - api._client = mock_client - api._ensure_client_open = MagicMock() - api._ensure_authenticated = AsyncMock() - - with pytest.raises(SpanPanelServerError): - await api.get_panel_data() diff --git a/tests/test_tabs_attribute.py b/tests/test_tabs_attribute.py index c8ca16c3..f1358ac6 100644 --- a/tests/test_tabs_attribute.py +++ b/tests/test_tabs_attribute.py @@ -1,464 +1,81 @@ -#!/usr/bin/env python3 """Test script for tabs attribute functionality.""" -import os -import sys - -# Add the custom_components directory to the path -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "custom_components")) +from span_panel_api import SpanCircuitSnapshot from custom_components.span_panel.helpers import ( construct_tabs_attribute, construct_voltage_attribute, - get_circuit_voltage_type, - parse_tabs_attribute, ) -from custom_components.span_panel.span_panel_circuit import SpanPanelCircuit - - -def test_tabs_attribute_construction(): - """Test tabs attribute construction from circuit data.""" - print("Testing tabs attribute construction...") - - # Test single tab (120V) - circuit_120v = SpanPanelCircuit( - circuit_id="test_120v", - name="Test 120V Circuit", - relay_state="CLOSED", - instant_power=100.0, - instant_power_update_time=1234567890, - produced_energy=1000.0, - consumed_energy=500.0, - energy_accum_update_time=1234567890, - priority="MUST_HAVE", - is_user_controllable=True, - is_sheddable=True, - is_never_backup=False, - tabs=[28], - ) - - tabs_attr_120v = construct_tabs_attribute(circuit_120v) - print(f"120V circuit tabs attribute: {tabs_attr_120v}") - assert tabs_attr_120v == "tabs [28]" - - # Test two tabs (240V) - circuit_240v = SpanPanelCircuit( - circuit_id="test_240v", - name="Test 240V Circuit", - relay_state="CLOSED", - instant_power=200.0, - instant_power_update_time=1234567890, - produced_energy=2000.0, - consumed_energy=1000.0, - energy_accum_update_time=1234567890, - priority="MUST_HAVE", - is_user_controllable=True, - is_sheddable=True, - is_never_backup=False, - tabs=[30, 32], - ) - - tabs_attr_240v = construct_tabs_attribute(circuit_240v) - print(f"240V circuit tabs attribute: {tabs_attr_240v}") - assert tabs_attr_240v == "tabs [30:32]" - - # Test circuit with no tabs - circuit_no_tabs = SpanPanelCircuit( - circuit_id="test_no_tabs", - name="Test No Tabs Circuit", - relay_state="CLOSED", - instant_power=50.0, - instant_power_update_time=1234567890, - produced_energy=500.0, - consumed_energy=250.0, - energy_accum_update_time=1234567890, - priority="MUST_HAVE", - is_user_controllable=True, - is_sheddable=True, - is_never_backup=False, - tabs=[], - ) - - tabs_attr_no_tabs = construct_tabs_attribute(circuit_no_tabs) - print(f"No tabs circuit tabs attribute: {tabs_attr_no_tabs}") - assert tabs_attr_no_tabs is None - - # Test circuit with more than 2 tabs (invalid for US electrical system) - circuit_invalid = SpanPanelCircuit( - circuit_id="test_invalid", - name="Test Invalid Circuit", - relay_state="CLOSED", - instant_power=300.0, - instant_power_update_time=1234567890, - produced_energy=3000.0, - consumed_energy=1500.0, - energy_accum_update_time=1234567890, - priority="MUST_HAVE", - is_user_controllable=True, - is_sheddable=True, - is_never_backup=False, - tabs=[1, 3, 5], # More than 2 tabs - invalid for US electrical system - ) - - tabs_attr_invalid = construct_tabs_attribute(circuit_invalid) - print(f"Invalid circuit tabs attribute: {tabs_attr_invalid}") - assert tabs_attr_invalid is None - print("✓ Tabs attribute construction tests passed!") - -def test_tabs_attribute_parsing(): - """Test tabs attribute parsing back to tab numbers.""" - print("\nTesting tabs attribute parsing...") - - # Test parsing single tab (120V) - tabs_120v = parse_tabs_attribute("tabs [28]") - print(f"Parsed 120V tabs: {tabs_120v}") - assert tabs_120v == [28] - - # Test parsing two tabs (240V) - tabs_240v = parse_tabs_attribute("tabs [30:32]") - print(f"Parsed 240V tabs: {tabs_240v}") - assert tabs_240v == [30, 32] - - # Test parsing invalid formats - invalid_tabs = parse_tabs_attribute("invalid format") - print(f"Parsed invalid format: {invalid_tabs}") - assert invalid_tabs is None - - invalid_tabs2 = parse_tabs_attribute("tabs [invalid]") - print(f"Parsed invalid content: {invalid_tabs2}") - assert invalid_tabs2 is None - - # Test parsing comma-separated format (not valid for US electrical system) - tabs_multi = parse_tabs_attribute("tabs [1,3,5]") - print(f"Parsed multi tabs (should be None for US system): {tabs_multi}") - assert tabs_multi is None - - print("✓ Tabs attribute parsing tests passed!") - - -def test_voltage_type_detection(): - """Test voltage type detection from circuit data.""" - print("\nTesting voltage type detection...") - - # Test 120V circuit - circuit_120v = SpanPanelCircuit( - circuit_id="test_120v", - name="Test 120V Circuit", - relay_state="CLOSED", - instant_power=100.0, - instant_power_update_time=1234567890, - produced_energy=1000.0, - consumed_energy=500.0, - energy_accum_update_time=1234567890, - priority="MUST_HAVE", - is_user_controllable=True, - is_sheddable=True, - is_never_backup=False, - tabs=[28], - ) - - voltage_type_120v = get_circuit_voltage_type(circuit_120v) - print(f"120V circuit voltage type: {voltage_type_120v}") - assert voltage_type_120v == "120V" - - # Test 240V circuit - circuit_240v = SpanPanelCircuit( - circuit_id="test_240v", - name="Test 240V Circuit", +def _make_circuit( + tabs: list[int], instant_power_w: float = 100.0 +) -> SpanCircuitSnapshot: + """Create a minimal SpanCircuitSnapshot for tab/voltage tests.""" + return SpanCircuitSnapshot( + circuit_id="test", + name="Test Circuit", relay_state="CLOSED", - instant_power=200.0, - instant_power_update_time=1234567890, - produced_energy=2000.0, - consumed_energy=1000.0, - energy_accum_update_time=1234567890, - priority="MUST_HAVE", + instant_power_w=instant_power_w, + produced_energy_wh=0.0, + consumed_energy_wh=0.0, + tabs=tabs, + priority="NEVER", is_user_controllable=True, is_sheddable=True, is_never_backup=False, - tabs=[30, 32], ) - voltage_type_240v = get_circuit_voltage_type(circuit_240v) - print(f"240V circuit voltage type: {voltage_type_240v}") - assert voltage_type_240v == "240V" - - # Test circuit with no tabs - circuit_no_tabs = SpanPanelCircuit( - circuit_id="test_no_tabs", - name="Test No Tabs Circuit", - relay_state="CLOSED", - instant_power=50.0, - instant_power_update_time=1234567890, - produced_energy=500.0, - consumed_energy=250.0, - energy_accum_update_time=1234567890, - priority="MUST_HAVE", - is_user_controllable=True, - is_sheddable=True, - is_never_backup=False, - tabs=[], - ) - voltage_type_no_tabs = get_circuit_voltage_type(circuit_no_tabs) - print(f"No tabs circuit voltage type: {voltage_type_no_tabs}") - assert voltage_type_no_tabs == "unknown" +def test_tabs_attribute_construction() -> None: + """Test tabs attribute construction from circuit data.""" + # Single tab (120V) + assert construct_tabs_attribute(_make_circuit([28])) == "tabs [28]" - # Test circuit with more than 2 tabs (invalid for US electrical system) - circuit_invalid = SpanPanelCircuit( - circuit_id="test_invalid", - name="Test Invalid Circuit", - relay_state="CLOSED", - instant_power=300.0, - instant_power_update_time=1234567890, - produced_energy=3000.0, - consumed_energy=1500.0, - energy_accum_update_time=1234567890, - priority="MUST_HAVE", - is_user_controllable=True, - is_sheddable=True, - is_never_backup=False, - tabs=[1, 3, 5], # More than 2 tabs - invalid for US electrical system - ) + # Two tabs (240V) + assert construct_tabs_attribute(_make_circuit([30, 32])) == "tabs [30:32]" - voltage_type_invalid = get_circuit_voltage_type(circuit_invalid) - print(f"Invalid circuit voltage type: {voltage_type_invalid}") - assert voltage_type_invalid == "unknown" + # No tabs + assert construct_tabs_attribute(_make_circuit([])) is None - print("✓ Voltage type detection tests passed!") + # More than 2 tabs (invalid for US electrical system) + assert construct_tabs_attribute(_make_circuit([1, 3, 5])) is None -def test_voltage_attribute_construction(): +def test_voltage_attribute_construction() -> None: """Test voltage attribute construction from circuit data.""" - print("\nTesting voltage attribute construction...") - - # Test single tab (120V) - circuit_120v = SpanPanelCircuit( - circuit_id="test_120v", - name="Test 120V Circuit", - relay_state="CLOSED", - instant_power=100.0, - instant_power_update_time=1234567890, - produced_energy=1000.0, - consumed_energy=500.0, - energy_accum_update_time=1234567890, - priority="MUST_HAVE", - is_user_controllable=True, - is_sheddable=True, - is_never_backup=False, - tabs=[28], - ) - - voltage_attr_120v = construct_voltage_attribute(circuit_120v) - print(f"120V circuit voltage attribute: {voltage_attr_120v}") - assert voltage_attr_120v == 120 - - # Test two tabs (240V) - circuit_240v = SpanPanelCircuit( - circuit_id="test_240v", - name="Test 240V Circuit", - relay_state="CLOSED", - instant_power=200.0, - instant_power_update_time=1234567890, - produced_energy=2000.0, - consumed_energy=1000.0, - energy_accum_update_time=1234567890, - priority="MUST_HAVE", - is_user_controllable=True, - is_sheddable=True, - is_never_backup=False, - tabs=[30, 32], - ) - - voltage_attr_240v = construct_voltage_attribute(circuit_240v) - print(f"240V circuit voltage attribute: {voltage_attr_240v}") - assert voltage_attr_240v == 240 - - # Test circuit with no tabs - circuit_no_tabs = SpanPanelCircuit( - circuit_id="test_no_tabs", - name="Test No Tabs Circuit", - relay_state="CLOSED", - instant_power=50.0, - instant_power_update_time=1234567890, - produced_energy=500.0, - consumed_energy=250.0, - energy_accum_update_time=1234567890, - priority="MUST_HAVE", - is_user_controllable=True, - is_sheddable=True, - is_never_backup=False, - tabs=[], - ) - - voltage_attr_no_tabs = construct_voltage_attribute(circuit_no_tabs) - print(f"No tabs circuit voltage attribute: {voltage_attr_no_tabs}") - assert voltage_attr_no_tabs is None - - # Test circuit with more than 2 tabs (invalid for US electrical system) - circuit_invalid = SpanPanelCircuit( - circuit_id="test_invalid", - name="Test Invalid Circuit", - relay_state="CLOSED", - instant_power=300.0, - instant_power_update_time=1234567890, - produced_energy=3000.0, - consumed_energy=1500.0, - energy_accum_update_time=1234567890, - priority="MUST_HAVE", - is_user_controllable=True, - is_sheddable=True, - is_never_backup=False, - tabs=[1, 3, 5], # More than 2 tabs - invalid for US electrical system - ) + assert construct_voltage_attribute(_make_circuit([28])) == 120 + assert construct_voltage_attribute(_make_circuit([30, 32])) == 240 + assert construct_voltage_attribute(_make_circuit([])) is None + assert construct_voltage_attribute(_make_circuit([1, 3, 5])) is None - voltage_attr_invalid = construct_voltage_attribute(circuit_invalid) - print(f"Invalid circuit voltage attribute: {voltage_attr_invalid}") - assert voltage_attr_invalid is None - print("✓ Voltage attribute construction tests passed!") - - -def test_end_to_end_tabs_workflow(): - """Test the complete workflow from circuit data to tabs attribute and back.""" - print("\nTesting end-to-end tabs workflow...") - - # Create a 240V circuit - circuit = SpanPanelCircuit( - circuit_id="test_240v_workflow", - name="Test 240V Workflow Circuit", - relay_state="CLOSED", - instant_power=200.0, - instant_power_update_time=1234567890, - produced_energy=2000.0, - consumed_energy=1000.0, - energy_accum_update_time=1234567890, - priority="MUST_HAVE", - is_user_controllable=True, - is_sheddable=True, - is_never_backup=False, - tabs=[30, 32], - ) +def test_end_to_end_tabs_workflow() -> None: + """Test the complete workflow from circuit data to tabs attribute and voltage.""" + circuit = _make_circuit([30, 32]) - # Step 1: Generate tabs attribute tabs_attr = construct_tabs_attribute(circuit) - print(f"Generated tabs attribute: {tabs_attr}") assert tabs_attr == "tabs [30:32]" - # Step 2: Parse tabs attribute back to numbers - parsed_tabs = parse_tabs_attribute(tabs_attr) - print(f"Parsed tabs: {parsed_tabs}") - assert parsed_tabs == [30, 32] + assert construct_voltage_attribute(circuit) == 240 - # Step 3: Verify voltage type - voltage_type = get_circuit_voltage_type(circuit) - print(f"Voltage type: {voltage_type}") - assert voltage_type == "240V" - # Step 4: Verify voltage attribute - voltage_attr = construct_voltage_attribute(circuit) - print(f"Voltage attribute: {voltage_attr}") - assert voltage_attr == 240 - - print("✓ End-to-end tabs workflow test passed!") - - -def test_amperage_calculation(): +def test_amperage_calculation() -> None: """Test amperage calculation using voltage and power.""" - print("\nTesting amperage calculation...") - - # Test 120V circuit with 1200W power (should be 10A) - circuit_120v = SpanPanelCircuit( - circuit_id="test_120v_10a", - name="Test 120V 10A Circuit", - relay_state="CLOSED", - instant_power=1200.0, # 1200W - instant_power_update_time=1234567890, - produced_energy=1000.0, - consumed_energy=500.0, - energy_accum_update_time=1234567890, - priority="MUST_HAVE", - is_user_controllable=True, - is_sheddable=True, - is_never_backup=False, - tabs=[28], - ) - + # 120V at 1200W -> 10A + circuit_120v = _make_circuit([28], instant_power_w=1200.0) voltage_120v = construct_voltage_attribute(circuit_120v) - expected_amperage_120v = circuit_120v.instant_power / voltage_120v - print( - f"120V circuit: {circuit_120v.instant_power}W / {voltage_120v}V = {expected_amperage_120v}A" - ) - assert expected_amperage_120v == 10.0 # 1200W / 120V = 10A - - # Test 240V circuit with 4800W power (should be 20A) - circuit_240v = SpanPanelCircuit( - circuit_id="test_240v_20a", - name="Test 240V 20A Circuit", - relay_state="CLOSED", - instant_power=4800.0, # 4800W - instant_power_update_time=1234567890, - produced_energy=2000.0, - consumed_energy=1000.0, - energy_accum_update_time=1234567890, - priority="MUST_HAVE", - is_user_controllable=True, - is_sheddable=True, - is_never_backup=False, - tabs=[30, 32], - ) + assert voltage_120v is not None + assert circuit_120v.instant_power_w / voltage_120v == 10.0 + # 240V at 4800W -> 20A + circuit_240v = _make_circuit([30, 32], instant_power_w=4800.0) voltage_240v = construct_voltage_attribute(circuit_240v) - expected_amperage_240v = circuit_240v.instant_power / voltage_240v - print( - f"240V circuit: {circuit_240v.instant_power}W / {voltage_240v}V = {expected_amperage_240v}A" - ) - assert expected_amperage_240v == 20.0 # 4800W / 240V = 20A - - # Test edge case: 0W power (should be 0A) - circuit_zero = SpanPanelCircuit( - circuit_id="test_zero_power", - name="Test Zero Power Circuit", - relay_state="CLOSED", - instant_power=0.0, # 0W - instant_power_update_time=1234567890, - produced_energy=0.0, - consumed_energy=0.0, - energy_accum_update_time=1234567890, - priority="MUST_HAVE", - is_user_controllable=True, - is_sheddable=True, - is_never_backup=False, - tabs=[28], - ) + assert voltage_240v is not None + assert circuit_240v.instant_power_w / voltage_240v == 20.0 + # 0W -> 0A + circuit_zero = _make_circuit([28], instant_power_w=0.0) voltage_zero = construct_voltage_attribute(circuit_zero) - expected_amperage_zero = circuit_zero.instant_power / voltage_zero - print( - f"Zero power circuit: {circuit_zero.instant_power}W / {voltage_zero}V = {expected_amperage_zero}A" - ) - assert expected_amperage_zero == 0.0 # 0W / 120V = 0A - - print("✓ Amperage calculation tests passed!") - - -if __name__ == "__main__": - print("Running tabs attribute functionality tests...\n") - - try: - test_tabs_attribute_construction() - test_tabs_attribute_parsing() - test_voltage_type_detection() - test_voltage_attribute_construction() - test_amperage_calculation() - test_end_to_end_tabs_workflow() - - print("\n🎉 All tests passed! Tabs attribute functionality is working correctly.") - - except Exception as e: - print(f"\n❌ Test failed with error: {e}") - import traceback - - traceback.print_exc() - sys.exit(1) + assert voltage_zero is not None + assert circuit_zero.instant_power_w / voltage_zero == 0.0 diff --git a/tests/test_topology_panel_entities.py b/tests/test_topology_panel_entities.py new file mode 100644 index 00000000..d7bf2e00 --- /dev/null +++ b/tests/test_topology_panel_entities.py @@ -0,0 +1,60 @@ +"""Tests for panel_entities section in topology response.""" + +import pytest +from unittest.mock import MagicMock + +from custom_components.span_panel.helpers import build_panel_unique_id + + +class TestBuildPanelEntityMap: + """Verify _build_panel_entity_map resolves unique_ids to entity_ids.""" + + def test_resolves_known_sensors(self): + """Panel entities section contains resolved entity IDs.""" + from custom_components.span_panel.websocket import _build_panel_entity_map + + serial = "test-serial-123" + mock_registry = MagicMock() + + def mock_get_entity_id(domain, integration, unique_id): + mapping = { + build_panel_unique_id(serial, "instantGridPowerW"): "sensor.my_current_power", + build_panel_unique_id(serial, "sitePowerW"): "sensor.custom_site", + build_panel_unique_id(serial, "dsm_grid_state"): "sensor.grid_state", + } + return mapping.get(unique_id) + + mock_registry.async_get_entity_id = mock_get_entity_id + + result = _build_panel_entity_map(serial, mock_registry) + + assert result["current_power"] == "sensor.my_current_power" + assert result["site_power"] == "sensor.custom_site" + assert result["dsm_state"] == "sensor.grid_state" + assert "pv_power" not in result # Not in mock mapping + + def test_empty_when_no_entities_found(self): + """Returns empty dict when no entities resolve.""" + from custom_components.span_panel.websocket import _build_panel_entity_map + + mock_registry = MagicMock() + mock_registry.async_get_entity_id = MagicMock(return_value=None) + + result = _build_panel_entity_map("unknown-serial", mock_registry) + assert result == {} + + def test_all_panel_sensor_keys_attempted(self): + """All defined panel sensor keys are looked up.""" + from custom_components.span_panel.websocket import ( + _build_panel_entity_map, + _PANEL_SENSOR_KEYS, + ) + + serial = "test-serial" + mock_registry = MagicMock() + mock_registry.async_get_entity_id = MagicMock(return_value=None) + + _build_panel_entity_map(serial, mock_registry) + + # Should have been called once per key + assert mock_registry.async_get_entity_id.call_count == len(_PANEL_SENSOR_KEYS) diff --git a/tests/test_unique_id_generation.py b/tests/test_unique_id_generation.py index 33901e36..57184448 100644 --- a/tests/test_unique_id_generation.py +++ b/tests/test_unique_id_generation.py @@ -1,471 +1,146 @@ -"""Tests for unique ID generation patterns in Span Panel integration.""" +"""Tests for unique ID generation pure functions in Span Panel integration. + +These tests exercise the actual build_*_unique_id helpers and +get_user_friendly_suffix / get_panel_entity_suffix functions that +produce the unique IDs stored in the entity registry. +""" + +from custom_components.span_panel.helpers import ( + build_binary_sensor_unique_id, + build_circuit_unique_id, + build_panel_unique_id, + build_select_unique_id, + build_switch_unique_id, + get_panel_entity_suffix, + get_user_friendly_suffix, +) -from unittest.mock import MagicMock -from homeassistant.const import CONF_HOST -import pytest +class TestBuildCircuitUniqueId: + """Tests for build_circuit_unique_id.""" -from custom_components.span_panel.const import ( - USE_CIRCUIT_NUMBERS, - USE_DEVICE_PREFIX, -) -from custom_components.span_panel.options import ( - INVERTER_ENABLE, - INVERTER_LEG1, - INVERTER_LEG2, -) -from custom_components.span_panel.span_panel import SpanPanel -from tests.common import create_mock_config_entry - - -class TestUniqueIdGeneration: - """Test unique ID generation for circuits and synthetic sensors.""" - - # Predefined test data for consistent testing - TEST_SERIAL_NUMBER = "ABC123DEF456" - - # Regular circuit test data - KITCHEN_CIRCUIT_DATA = { - "circuit_id": "1", - "name": "Kitchen Outlets", - "power": 245.3, - "tab": 1, - } - - LIVING_ROOM_CIRCUIT_DATA = { - "circuit_id": "2", - "name": "Living Room Lights", - "power": 85.2, - "tab": 2, - } - - SOLAR_CIRCUIT_DATA = { - "circuit_id": "15", - "name": "Solar Panels", - "power": -1200.0, # Production - "tab": 15, - } - - # Solar synthetic sensor test data - SOLAR_LEG1 = 15 - SOLAR_LEG2 = 16 - - def _create_mock_span_panel(self, serial_number: str = None) -> SpanPanel: - """Create a mock SpanPanel with predefined data.""" - if serial_number is None: - serial_number = self.TEST_SERIAL_NUMBER - - # Create a minimal mock span panel that has the required structure - # for unique ID generation - mock_span_panel = MagicMock(spec=SpanPanel) - - # Mock the status with serial number - mock_status = MagicMock() - mock_status.serial_number = serial_number - mock_span_panel.status = mock_status - - # Also add direct serial_number property for helper methods - mock_span_panel.serial_number = serial_number - - # Mock circuits - mock_circuits = {} - for circuit_data in [ - self.KITCHEN_CIRCUIT_DATA, - self.LIVING_ROOM_CIRCUIT_DATA, - self.SOLAR_CIRCUIT_DATA, - ]: - mock_circuit = MagicMock() - mock_circuit.name = circuit_data["name"] - mock_circuit.instant_power = circuit_data["power"] - mock_circuits[circuit_data["circuit_id"]] = mock_circuit - - mock_span_panel.circuits = mock_circuits - - return mock_span_panel - - def _create_circuit_sensor( - self, - span_panel: SpanPanel, - circuit_id: str, - sensor_description, # Accept description directly instead of deriving it - config_options: dict = None, - ): - """Create a circuit sensor for testing - now using synthetic sensor approach.""" - if config_options is None: - config_options = {} - - # Create mock coordinator with config entry - coordinator = MagicMock() - mock_config_entry = create_mock_config_entry({CONF_HOST: "192.168.1.100"}, config_options) - coordinator.config_entry = mock_config_entry - - # Return mock sensor with unique_id property for testing - mock_sensor = MagicMock() - # Calculate unique_id using the same pattern as the real implementation - unique_id = f"span_{span_panel.serial_number}_{circuit_id}_{sensor_description.key}" - mock_sensor.unique_id = unique_id - return mock_sensor - - def _create_synthetic_sensor( - self, - span_panel: SpanPanel, - circuit_numbers: list[int], - description, # Accept description directly - ): - """Create a synthetic sensor for testing - now using mock approach.""" - # Create mock coordinator - coordinator = MagicMock() - mock_config_entry = create_mock_config_entry( - {CONF_HOST: "192.168.1.100"}, - { - INVERTER_ENABLE: True, - INVERTER_LEG1: circuit_numbers[0], - INVERTER_LEG2: circuit_numbers[1] if len(circuit_numbers) > 1 else 0, - }, - ) - coordinator.config_entry = mock_config_entry - - # Return mock sensor with unique_id property for testing - mock_sensor = MagicMock() - # Calculate unique_id using the same pattern as the real implementation - circuit_list_str = "_".join(str(c) for c in sorted(circuit_numbers)) - unique_id = ( - f"span_{span_panel.serial_number}_synthetic_{circuit_list_str}_{description.key}" - ) - mock_sensor.unique_id = unique_id - return mock_sensor - - def test_regular_circuit_unique_id_new_installation(self): - """Test unique ID generation for regular circuits in new installations.""" - span_panel = self._create_mock_span_panel() - - # Create a mock power sensor description with hardcoded expected key - power_description = MagicMock() - power_description.key = "power" # Hardcoded expected key, not derived from integration - - # Test power sensor for kitchen circuit - sensor = self._create_circuit_sensor( - span_panel, - self.KITCHEN_CIRCUIT_DATA["circuit_id"], - power_description, - {USE_CIRCUIT_NUMBERS: True}, # New installation pattern - ) - - # Hardcoded expected unique ID pattern - not derived from integration constants - expected_unique_id = ( - f"span_{self.TEST_SERIAL_NUMBER}_{self.KITCHEN_CIRCUIT_DATA['circuit_id']}_power" - ) - assert sensor.unique_id == expected_unique_id - - def test_regular_circuit_unique_id_multiple_circuits(self): - """Test unique ID generation for multiple regular circuits.""" - span_panel = self._create_mock_span_panel() - - # Create mock sensor descriptions with hardcoded expected keys - power_description = MagicMock() - power_description.key = "power" - - test_cases = [ - (self.KITCHEN_CIRCUIT_DATA, power_description, "power"), - (self.LIVING_ROOM_CIRCUIT_DATA, power_description, "power"), - (self.SOLAR_CIRCUIT_DATA, power_description, "power"), - ] - - for circuit_data, description, expected_suffix in test_cases: - sensor = self._create_circuit_sensor( - span_panel, circuit_data["circuit_id"], description, {USE_CIRCUIT_NUMBERS: True} - ) - - # Hardcoded expected unique ID pattern - expected_unique_id = ( - f"span_{self.TEST_SERIAL_NUMBER}_{circuit_data['circuit_id']}_{expected_suffix}" - ) - assert sensor.unique_id == expected_unique_id, ( - f"Failed for circuit {circuit_data['circuit_id']}" - ) - - def test_solar_synthetic_sensor_unique_id(self): - """Test unique ID generation for solar synthetic sensors.""" - span_panel = self._create_mock_span_panel() - - # Test the three auto-generated solar sensors with hardcoded descriptions - test_cases = [ - ("solar_inverter_instant_power", "solar_inverter_instant_power"), - ("solar_inverter_energy_produced", "solar_inverter_energy_produced"), - ("solar_inverter_energy_consumed", "solar_inverter_energy_consumed"), - ] - - for description_key, expected_suffix in test_cases: - # Create mock description with hardcoded key - mock_description = MagicMock() - mock_description.key = description_key - - sensor = self._create_synthetic_sensor( - span_panel, [self.SOLAR_LEG1, self.SOLAR_LEG2], mock_description - ) - - # Hardcoded expected unique ID pattern - expected_unique_id = f"span_{self.TEST_SERIAL_NUMBER}_synthetic_{self.SOLAR_LEG1}_{self.SOLAR_LEG2}_{expected_suffix}" - assert sensor.unique_id == expected_unique_id, f"Failed for sensor {description_key}" - - def test_solar_synthetic_sensor_single_circuit(self): - """Test unique ID generation for solar synthetic sensor with single circuit.""" - span_panel = self._create_mock_span_panel() - - # Create mock description with hardcoded key - mock_description = MagicMock() - mock_description.key = "solar_inverter_instant_power" - - sensor = self._create_synthetic_sensor( - span_panel, - [self.SOLAR_LEG1], # Single circuit - mock_description, - ) - - # Hardcoded expected unique ID pattern - expected_unique_id = f"span_{self.TEST_SERIAL_NUMBER}_synthetic_{self.SOLAR_LEG1}_solar_inverter_instant_power" - assert sensor.unique_id == expected_unique_id - - def test_unique_id_with_different_serial_numbers(self): - """Test that unique IDs change with different serial numbers.""" - test_serials = ["ABC123", "XYZ789", "PANEL001"] - - for serial in test_serials: - span_panel = self._create_mock_span_panel(serial) - - # Create mock power description - power_description = MagicMock() - power_description.key = "power" - - # Test regular circuit - circuit_sensor = self._create_circuit_sensor( - span_panel, self.KITCHEN_CIRCUIT_DATA["circuit_id"], power_description - ) - # Hardcoded expected pattern - expected_circuit_id = f"span_{serial}_{self.KITCHEN_CIRCUIT_DATA['circuit_id']}_power" - assert circuit_sensor.unique_id == expected_circuit_id - - # Create mock synthetic description - synthetic_description = MagicMock() - synthetic_description.key = "solar_inverter_instant_power" - - # Test synthetic sensor - synthetic_sensor = self._create_synthetic_sensor( - span_panel, [self.SOLAR_LEG1, self.SOLAR_LEG2], synthetic_description - ) - # Hardcoded expected pattern - expected_synthetic_id = f"span_{serial}_synthetic_{self.SOLAR_LEG1}_{self.SOLAR_LEG2}_solar_inverter_instant_power" - assert synthetic_sensor.unique_id == expected_synthetic_id - - def test_unique_id_format_consistency(self): - """Test that unique ID formats are consistent and follow documented patterns.""" - span_panel = self._create_mock_span_panel() - - # Create mock power description - power_description = MagicMock() - power_description.key = "power" - - # Test regular circuit format: span_{serial}_{circuit_id}_{description_key} - circuit_sensor = self._create_circuit_sensor( - span_panel, self.KITCHEN_CIRCUIT_DATA["circuit_id"], power_description - ) - - parts = circuit_sensor.unique_id.split("_") - assert parts[0] == "span", "Should start with 'span'" - assert parts[1] == self.TEST_SERIAL_NUMBER, "Should include serial number" - assert parts[2] == self.KITCHEN_CIRCUIT_DATA["circuit_id"], "Should include circuit ID" - assert parts[3] == "power", "Should include description key" # Hardcoded expected value - - # Create mock synthetic description - synthetic_description = MagicMock() - synthetic_description.key = "solar_inverter_instant_power" - - # Test synthetic sensor format: span_{serial}_synthetic_{leg1}_{leg2}_{yaml_key} - synthetic_sensor = self._create_synthetic_sensor( - span_panel, [self.SOLAR_LEG1, self.SOLAR_LEG2], synthetic_description - ) - - synthetic_parts = synthetic_sensor.unique_id.split("_") - assert synthetic_parts[0] == "span", "Should start with 'span'" - assert synthetic_parts[1] == self.TEST_SERIAL_NUMBER, "Should include serial number" - assert synthetic_parts[2] == "synthetic", "Should include 'synthetic'" - assert synthetic_parts[3] == str(self.SOLAR_LEG1), "Should include first leg" - assert synthetic_parts[4] == str(self.SOLAR_LEG2), "Should include second leg" - assert "_".join(synthetic_parts[5:]) == "solar_inverter_instant_power", ( - "Should include YAML key" # Hardcoded expected pattern - ) - - def test_unique_id_stability_across_installations(self): - """Test that unique IDs remain stable across different installation types.""" - span_panel = self._create_mock_span_panel() - - # Create mock synthetic description - synthetic_description = MagicMock() - synthetic_description.key = "solar_inverter_instant_power" - - # Test that synthetic sensors always use the v1.0.10+ pattern regardless of config - config_variations = [ - {}, # Legacy - {USE_CIRCUIT_NUMBERS: True}, # New - {USE_DEVICE_PREFIX: True}, # With device prefix - {USE_CIRCUIT_NUMBERS: True, USE_DEVICE_PREFIX: True}, # Both - ] - - for config in config_variations: - synthetic_sensor = self._create_synthetic_sensor( - span_panel, [self.SOLAR_LEG1, self.SOLAR_LEG2], synthetic_description - ) - - # Synthetic sensors should always use the same pattern regardless of config - # Hardcoded expected pattern - expected_unique_id = f"span_{self.TEST_SERIAL_NUMBER}_synthetic_{self.SOLAR_LEG1}_{self.SOLAR_LEG2}_solar_inverter_instant_power" - assert synthetic_sensor.unique_id == expected_unique_id, f"Failed for config: {config}" - - def test_multi_panel_unique_id_differentiation(self): - """Test that unique IDs differentiate between multiple panels.""" - # Create two panels with different serial numbers - panel1 = self._create_mock_span_panel("PANEL001") - panel2 = self._create_mock_span_panel("PANEL002") - - # Create mock power description - power_description = MagicMock() - power_description.key = "power" - - # Same circuit on different panels should have different unique IDs - sensor1 = self._create_circuit_sensor(panel1, "1", power_description) - sensor2 = self._create_circuit_sensor(panel2, "1", power_description) - - assert sensor1.unique_id != sensor2.unique_id - assert "PANEL001" in sensor1.unique_id - assert "PANEL002" in sensor2.unique_id - - # Create mock synthetic description - synthetic_description = MagicMock() - synthetic_description.key = "solar_inverter_instant_power" - - # Same synthetic sensor on different panels should have different unique IDs - synthetic1 = self._create_synthetic_sensor(panel1, [15, 16], synthetic_description) - synthetic2 = self._create_synthetic_sensor(panel2, [15, 16], synthetic_description) - - assert synthetic1.unique_id != synthetic2.unique_id - assert "PANEL001" in synthetic1.unique_id - assert "PANEL002" in synthetic2.unique_id - - def test_solar_synthetic_sensor_v1_0_10_compatibility(self): - """Test that solar synthetic sensors maintain v1.0.10+ compatibility.""" - span_panel = self._create_mock_span_panel() - - # Create mock synthetic description - synthetic_description = MagicMock() - synthetic_description.key = "solar_inverter_instant_power" - - # This tests the specific requirement from the documentation: - # Solar synthetic sensors must maintain compatibility with v1.0.10+ synthetic naming patterns - sensor = self._create_synthetic_sensor( - span_panel, [self.SOLAR_LEG1, self.SOLAR_LEG2], synthetic_description - ) - - # The unique ID should follow the v1.0.10+ pattern: - # span_{serial}_synthetic_{circuits}_{yaml_key} - # Hardcoded expected pattern - expected_pattern = f"span_{self.TEST_SERIAL_NUMBER}_synthetic_{self.SOLAR_LEG1}_{self.SOLAR_LEG2}_solar_inverter_instant_power" - assert sensor.unique_id == expected_pattern - - # Verify the pattern components match v1.0.10+ requirements - assert sensor.unique_id.startswith(f"span_{self.TEST_SERIAL_NUMBER}_synthetic_") - assert f"_{self.SOLAR_LEG1}_{self.SOLAR_LEG2}_" in sensor.unique_id - assert sensor.unique_id.endswith("_solar_inverter_instant_power") - - @pytest.mark.parametrize( - "circuit_combinations", - [ - ([10]), # Single circuit - ([10, 11]), # Two circuits - ([10, 11, 12]), # Three circuits - ([1, 15, 30]), # Non-sequential circuits - ], - ) - def test_synthetic_sensor_circuit_combinations(self, circuit_combinations): - """Test unique ID generation for various circuit combinations.""" - span_panel = self._create_mock_span_panel() - - # Create mock synthetic description with hardcoded expected key - synthetic_description = MagicMock() - synthetic_description.key = "solar_inverter_instant_power" - - sensor = self._create_synthetic_sensor( - span_panel, circuit_combinations, synthetic_description - ) - - # Build expected circuit specification - hardcoded pattern matching documentation - circuit_spec = "_".join(str(num) for num in circuit_combinations) - expected_unique_id = ( - f"span_{self.TEST_SERIAL_NUMBER}_synthetic_{circuit_spec}_solar_inverter_instant_power" - ) - - assert sensor.unique_id == expected_unique_id - - def test_circuit_sensor_uses_correct_documented_pattern(self): - """Test that circuit sensors follow the documented unique ID pattern.""" - span_panel = self._create_mock_span_panel() - - # Use ACTUAL integration sensor description (not mocked) - from custom_components.span_panel.const import CIRCUITS_POWER - from custom_components.span_panel.sensor_definitions import UNMAPPED_SENSORS - - # Get the actual power sensor description used by the integration - power_description = next(d for d in UNMAPPED_SENSORS if d.key == CIRCUITS_POWER) - - # Create sensor using actual integration components - sensor = self._create_circuit_sensor( - span_panel, - self.KITCHEN_CIRCUIT_DATA["circuit_id"], - power_description, - {USE_CIRCUIT_NUMBERS: True}, - ) - - # Verify it matches the DOCUMENTED pattern, not the integration constants - # This is from SPAN_Unique_Key_Compatibility.md: span_{serial_number}_{circuit_id}_{description_key} - expected_unique_id = f"span_{self.TEST_SERIAL_NUMBER}_{self.KITCHEN_CIRCUIT_DATA['circuit_id']}_instantPowerW" - assert sensor.unique_id == expected_unique_id - - # Additional verification: ensure the integration actually uses the expected key - assert power_description.key == "instantPowerW", ( - "Integration changed power sensor key - update documentation!" - ) - - def test_synthetic_sensor_uses_correct_documented_pattern(self): - """Test that synthetic sensors follow the documented unique ID pattern.""" - span_panel = self._create_mock_span_panel() - - # Create a mock power template (since SYNTHETIC_SENSOR_TEMPLATES doesn't exist) - power_template = MagicMock() - power_template.key = "instant_power" - power_template.name = "Instant Power" - power_template.device_class = "power" - power_template.native_unit_of_measurement = "W" - power_template.state_class = "measurement" - - # Create a mock description object - description = MagicMock() - description.key = ( - f"solar_inverter_{power_template.key}" # This is what the integration does - ) - description.name = power_template.name - description.device_class = power_template.device_class - description.native_unit_of_measurement = power_template.native_unit_of_measurement - description.state_class = power_template.state_class - - sensor = self._create_synthetic_sensor( - span_panel, [self.SOLAR_LEG1, self.SOLAR_LEG2], description - ) - - # Verify it matches the DOCUMENTED pattern from SPAN_Unique_Key_Compatibility.md: - # span_{serial_number}_synthetic_{leg1}_{leg2}_{yaml_key} - expected_unique_id = f"span_{self.TEST_SERIAL_NUMBER}_synthetic_{self.SOLAR_LEG1}_{self.SOLAR_LEG2}_solar_inverter_instant_power" - assert sensor.unique_id == expected_unique_id - - # Additional verification: ensure the integration actually uses expected template structure - assert power_template.key == "instant_power", ( - "Integration changed power template key - update documentation!" - ) + def test_basic_format(self) -> None: + """Circuit unique IDs follow span_{serial}_{circuit_id}_{suffix}.""" + uid = build_circuit_unique_id("SP3-001", "uuid-kitchen", "instantPowerW") + assert uid == "span_sp3-001_uuid-kitchen_power" + + def test_serial_lowercased(self) -> None: + """Serial numbers are lowercased for consistency.""" + uid = build_circuit_unique_id("ABC123DEF", "1", "instantPowerW") + assert uid.startswith("span_abc123def_") + + def test_different_serials_produce_different_ids(self) -> None: + """Two panels with the same circuit produce distinct unique IDs.""" + uid1 = build_circuit_unique_id("PANEL001", "1", "instantPowerW") + uid2 = build_circuit_unique_id("PANEL002", "1", "instantPowerW") + assert uid1 != uid2 + + def test_different_circuits_produce_different_ids(self) -> None: + """Same panel, different circuits produce distinct unique IDs.""" + uid1 = build_circuit_unique_id("SP3-001", "1", "instantPowerW") + uid2 = build_circuit_unique_id("SP3-001", "2", "instantPowerW") + assert uid1 != uid2 + + def test_different_description_keys_produce_different_ids(self) -> None: + """Same circuit, different sensor types produce distinct unique IDs.""" + uid_power = build_circuit_unique_id("SP3-001", "1", "instantPowerW") + uid_energy = build_circuit_unique_id("SP3-001", "1", "consumedEnergyWh") + assert uid_power != uid_energy + + def test_energy_consumed_suffix(self) -> None: + """ConsumedEnergyWh maps to the expected suffix.""" + uid = build_circuit_unique_id("SP3-001", "1", "consumedEnergyWh") + assert uid.endswith("_energy_consumed") + + def test_energy_produced_suffix(self) -> None: + """ProducedEnergyWh maps to the expected suffix.""" + uid = build_circuit_unique_id("SP3-001", "1", "producedEnergyWh") + assert uid.endswith("_energy_produced") + + def test_unmapped_key_passes_through_lowercased(self) -> None: + """Keys without a suffix mapping are lowercased as-is.""" + uid = build_circuit_unique_id("SP3-001", "1", "someNewField") + assert uid.endswith("_somenewfield") + + +class TestBuildPanelUniqueId: + """Tests for build_panel_unique_id.""" + + def test_basic_format(self) -> None: + """Panel unique IDs follow span_{serial}_{entity_suffix}.""" + uid = build_panel_unique_id("SP3-001", "instantGridPowerW") + assert uid == "span_sp3-001_current_power" + + def test_serial_lowercased(self) -> None: + """Serial numbers are lowercased.""" + uid = build_panel_unique_id("ABC123", "instantGridPowerW") + assert uid.startswith("span_abc123_") + + def test_feedthrough_power_suffix(self) -> None: + """FeedthroughPowerW uses its panel-specific suffix mapping.""" + uid = build_panel_unique_id("SP3-001", "feedthroughPowerW") + assert uid.endswith("_feed_through_power") + + +class TestBuildSwitchUniqueId: + """Tests for build_switch_unique_id.""" + + def test_format(self) -> None: + """Switch unique IDs follow span_{serial}_relay_{circuit_id}.""" + uid = build_switch_unique_id("SP3-001", "uuid-kitchen") + assert uid == "span_SP3-001_relay_uuid-kitchen" + + +class TestBuildBinarySensorUniqueId: + """Tests for build_binary_sensor_unique_id.""" + + def test_format(self) -> None: + """Binary sensor unique IDs follow span_{serial}_{key}.""" + uid = build_binary_sensor_unique_id("SP3-001", "doorState") + assert uid == "span_SP3-001_doorState" + + +class TestBuildSelectUniqueId: + """Tests for build_select_unique_id.""" + + def test_format(self) -> None: + """Select unique IDs follow span_{serial}_select_{select_id}.""" + uid = build_select_unique_id("SP3-001", "uuid-kitchen") + assert uid == "span_SP3-001_select_uuid-kitchen" + + +class TestGetUserFriendlySuffix: + """Tests for the suffix mapping used by circuit unique IDs.""" + + def test_known_power_key(self) -> None: + """InstantPowerW maps to 'power'.""" + assert get_user_friendly_suffix("instantPowerW") == "power" + + def test_known_energy_keys(self) -> None: + """Energy keys map to human-readable suffixes.""" + assert get_user_friendly_suffix("consumedEnergyWh") == "energy_consumed" + assert get_user_friendly_suffix("producedEnergyWh") == "energy_produced" + + def test_unknown_key_lowercased(self) -> None: + """Unknown keys pass through lowercased.""" + assert get_user_friendly_suffix("novelMetric") == "novelmetric" + + def test_dotted_key_underscored(self) -> None: + """Dots in keys are replaced with underscores.""" + assert get_user_friendly_suffix("some.dotted.key") == "some_dotted_key" + + +class TestGetPanelEntitySuffix: + """Tests for the panel-specific suffix mapping.""" + + def test_panel_specific_mapping(self) -> None: + """Panel keys use their own mapping (not the circuit one).""" + assert get_panel_entity_suffix("instantGridPowerW") == "current_power" + + def test_falls_back_to_general(self) -> None: + """Keys not in the panel mapping fall back to the general suffix map.""" + assert get_panel_entity_suffix("instantPowerW") == "power" + + def test_unknown_key_lowercased(self) -> None: + """Unmapped keys pass through lowercased.""" + assert get_panel_entity_suffix("brandNewField") == "brandnewfield" diff --git a/tests/test_unmapped_sensor_ids.py b/tests/test_unmapped_sensor_ids.py index 5a24449f..6e640682 100644 --- a/tests/test_unmapped_sensor_ids.py +++ b/tests/test_unmapped_sensor_ids.py @@ -2,13 +2,16 @@ from unittest.mock import MagicMock -from homeassistant.const import CONF_HOST +from span_panel_api import SpanPanelSnapshot from custom_components.span_panel.const import USE_DEVICE_PREFIX from custom_components.span_panel.sensor import SpanUnmappedCircuitSensor from custom_components.span_panel.sensor_definitions import UNMAPPED_SENSORS -from custom_components.span_panel.span_panel import SpanPanel -from tests.common import create_mock_config_entry +from homeassistant.const import CONF_HOST + +from .factories import SpanPanelSnapshotFactory + +from pytest_homeassistant_custom_component.common import MockConfigEntry class TestUnmappedSensorIds: @@ -16,38 +19,31 @@ class TestUnmappedSensorIds: TEST_SERIAL_NUMBER = "NJ-2316-005K6" - def _create_mock_span_panel(self, serial_number: str = None) -> SpanPanel: - """Create a mock SpanPanel with predefined data.""" + def _create_snapshot(self, serial_number: str | None = None) -> SpanPanelSnapshot: + """Create a SpanPanelSnapshot with predefined data.""" if serial_number is None: serial_number = self.TEST_SERIAL_NUMBER + return SpanPanelSnapshotFactory.create(serial_number=serial_number) - mock_span_panel = MagicMock(spec=SpanPanel) - mock_status = MagicMock() - mock_status.serial_number = serial_number - mock_span_panel.status = mock_status - - # Add api attribute that some tests might access - mock_api = MagicMock() - mock_span_panel.api = mock_api - - return mock_span_panel - - def _create_mock_coordinator(self, config_options: dict = None): + def _create_mock_coordinator( + self, config_options: dict[str, object] | None = None + ) -> MagicMock: """Create a mock coordinator with config entry.""" if config_options is None: config_options = {} coordinator = MagicMock() - mock_config_entry = create_mock_config_entry( - {CONF_HOST: "192.168.1.100", "device_name": "SPAN Panel"}, - config_options + coordinator.config_entry = MockConfigEntry( + domain="span_panel", + data={CONF_HOST: "192.168.1.100", "device_name": "SPAN Panel"}, + options=config_options, + title="SPAN Panel", ) - coordinator.config_entry = mock_config_entry return coordinator - def test_unmapped_sensor_unique_id_generation(self): + def test_unmapped_sensor_unique_id_generation(self) -> None: """Test that unmapped circuit sensors generate correct unique IDs.""" - span_panel = self._create_mock_span_panel() + snapshot = self._create_snapshot() coordinator = self._create_mock_coordinator() circuit_id = "unmapped_tab_32" @@ -58,10 +54,10 @@ def test_unmapped_sensor_unique_id_generation(self): } for description in UNMAPPED_SENSORS: - # Test the full sensor initialization process (this is what actually happens at runtime) - sensor = SpanUnmappedCircuitSensor(coordinator, description, span_panel, circuit_id) + sensor = SpanUnmappedCircuitSensor( + coordinator, description, snapshot, circuit_id + ) - # Check the unique ID that gets set during initialization actual_unique_id = sensor._attr_unique_id expected_unique_id = expected_patterns[description.key] @@ -70,120 +66,89 @@ def test_unmapped_sensor_unique_id_generation(self): f"expected {expected_unique_id}, got {actual_unique_id}" ) - # Verify no duplication in unique ID (this is the critical test) assert "unmapped_tab_32_unmapped_tab_32" not in actual_unique_id, ( f"Duplicate unmapped_tab in unique_id: {actual_unique_id}" ) - # Also test the method directly for completeness - method_unique_id = sensor._generate_unique_id(span_panel, description) - assert method_unique_id == expected_unique_id, ( - f"Method unique ID mismatch for {description.key}: " - f"expected {expected_unique_id}, got {method_unique_id}" - ) + method_unique_id = sensor._generate_unique_id(snapshot, description) + assert method_unique_id == expected_unique_id - def test_unmapped_sensor_entity_id_generation(self): - """Test that unmapped circuit sensors have proper unique IDs (entity_id is set by HA framework).""" - span_panel = self._create_mock_span_panel() + def test_unmapped_sensor_entity_id_generation(self) -> None: + """Test that unmapped circuit sensors have proper unique IDs.""" + snapshot = self._create_snapshot() coordinator = self._create_mock_coordinator() circuit_id = "unmapped_tab_32" for description in UNMAPPED_SENSORS: - # Test the full sensor initialization process (this is what actually happens at runtime) - sensor = SpanUnmappedCircuitSensor(coordinator, description, span_panel, circuit_id) - - # Test that the sensor has a unique_id (this is what the sensor actually sets) - assert sensor.unique_id is not None, f"Unique ID should be set for {description.key}" + sensor = SpanUnmappedCircuitSensor( + coordinator, description, snapshot, circuit_id + ) - # Test that the unique_id follows the expected pattern - assert "unmapped_tab_32" in sensor.unique_id, f"Unique ID should contain circuit ID: {sensor.unique_id}" - # The unique_id contains a transformed version of the key, not the original key - assert "power" in sensor.unique_id or "energy" in sensor.unique_id, f"Unique ID should contain transformed key: {sensor.unique_id}" + assert sensor.unique_id is not None + assert "unmapped_tab_32" in sensor.unique_id + assert "power" in sensor.unique_id or "energy" in sensor.unique_id - def test_unmapped_sensor_always_uses_device_prefix(self): + def test_unmapped_sensor_always_uses_device_prefix(self) -> None: """Test that unmapped sensors always use device prefix regardless of config.""" - span_panel = self._create_mock_span_panel() + snapshot = self._create_snapshot() circuit_id = "unmapped_tab_27" - # Test with device prefix disabled - coordinator_no_prefix = self._create_mock_coordinator({USE_DEVICE_PREFIX: False}) - - # Test with device prefix enabled - coordinator_with_prefix = self._create_mock_coordinator({USE_DEVICE_PREFIX: True}) + coordinator_no_prefix = self._create_mock_coordinator( + {USE_DEVICE_PREFIX: False} + ) + coordinator_with_prefix = self._create_mock_coordinator( + {USE_DEVICE_PREFIX: True} + ) for description in UNMAPPED_SENSORS: - # Create sensors with both configurations sensor_no_prefix = SpanUnmappedCircuitSensor( - coordinator_no_prefix, description, span_panel, circuit_id + coordinator_no_prefix, description, snapshot, circuit_id ) sensor_with_prefix = SpanUnmappedCircuitSensor( - coordinator_with_prefix, description, span_panel, circuit_id + coordinator_with_prefix, description, snapshot, circuit_id ) - # Both should have the same unique_id (unmapped sensors always use device prefix) - assert sensor_no_prefix.unique_id == sensor_with_prefix.unique_id, ( - f"Unmapped sensors should always use device prefix in unique_id: " - f"no_prefix={sensor_no_prefix.unique_id}, with_prefix={sensor_with_prefix.unique_id}" - ) - - # Both should contain the device name in unique_id (actual device name, not "span_panel") - assert "span_" in sensor_no_prefix.unique_id, ( - f"Unmapped sensor unique_id should contain device prefix: {sensor_no_prefix.unique_id}" - ) + assert sensor_no_prefix.unique_id == sensor_with_prefix.unique_id + assert "span_" in sensor_no_prefix.unique_id - def test_unmapped_sensor_different_circuit_numbers(self): + def test_unmapped_sensor_different_circuit_numbers(self) -> None: """Test unmapped sensors with different circuit numbers.""" - span_panel = self._create_mock_span_panel() + snapshot = self._create_snapshot() coordinator = self._create_mock_coordinator() - test_circuits = ["unmapped_tab_15", "unmapped_tab_30", "unmapped_tab_28"] - - for circuit_id in test_circuits: - circuit_id.split("_")[-1] # Extract number from circuit_id - + for circuit_id in ("unmapped_tab_15", "unmapped_tab_30", "unmapped_tab_28"): for description in UNMAPPED_SENSORS: - sensor = SpanUnmappedCircuitSensor(coordinator, description, span_panel, circuit_id) - - # Test unique ID - unique_id = sensor._generate_unique_id(span_panel, description) - assert circuit_id in unique_id, ( - f"Circuit ID {circuit_id} not found in unique_id: {unique_id}" + sensor = SpanUnmappedCircuitSensor( + coordinator, description, snapshot, circuit_id ) - # Verify no duplication in unique_id + unique_id = sensor._generate_unique_id(snapshot, description) + assert circuit_id in unique_id assert f"{circuit_id}_{circuit_id}" not in unique_id - def test_unmapped_sensor_different_serial_numbers(self): + def test_unmapped_sensor_different_serial_numbers(self) -> None: """Test unmapped sensors with different panel serial numbers.""" coordinator = self._create_mock_coordinator() circuit_id = "unmapped_tab_32" - test_serials = ["ABC123DEF456", "XYZ789GHI012", "TEST-SERIAL-001"] - - for serial_number in test_serials: - span_panel = self._create_mock_span_panel(serial_number) + for serial_number in ("ABC123DEF456", "XYZ789GHI012", "TEST-SERIAL-001"): + snapshot = self._create_snapshot(serial_number) for description in UNMAPPED_SENSORS: - sensor = SpanUnmappedCircuitSensor(coordinator, description, span_panel, circuit_id) - unique_id = sensor._generate_unique_id(span_panel, description) - - # Unique ID should contain the serial number (lowercase) - assert serial_number.lower() in unique_id, ( - f"Serial number {serial_number} not found in unique_id: {unique_id}" + sensor = SpanUnmappedCircuitSensor( + coordinator, description, snapshot, circuit_id ) + unique_id = sensor._generate_unique_id(snapshot, description) - # Should start with "span_" followed by serial - assert unique_id.startswith(f"span_{serial_number.lower()}_"), ( - f"Unique ID should start with span_{serial_number.lower()}_: {unique_id}" - ) + assert serial_number.lower() in unique_id + assert unique_id.startswith(f"span_{serial_number.lower()}_") - def test_unmapped_sensor_key_mapping(self): - """Test that description keys are correctly mapped to user-friendly suffixes in generated IDs.""" - span_panel = self._create_mock_span_panel() + def test_unmapped_sensor_key_mapping(self) -> None: + """Test that description keys are correctly mapped to user-friendly suffixes.""" + snapshot = self._create_snapshot() coordinator = self._create_mock_coordinator() circuit_id = "unmapped_tab_32" - # Test that the correct suffixes appear in the generated IDs key_to_suffix_mapping = { "instantPowerW": "power", "producedEnergyWh": "energy_produced", @@ -191,70 +156,48 @@ def test_unmapped_sensor_key_mapping(self): } for description in UNMAPPED_SENSORS: - sensor = SpanUnmappedCircuitSensor(coordinator, description, span_panel, circuit_id) - - # Test that the expected suffix appears in unique ID - unique_id = sensor._generate_unique_id(span_panel, description) + sensor = SpanUnmappedCircuitSensor( + coordinator, description, snapshot, circuit_id + ) + unique_id = sensor._generate_unique_id(snapshot, description) expected_suffix = key_to_suffix_mapping[description.key] - assert expected_suffix in unique_id, ( - f"Expected suffix '{expected_suffix}' not found in unique_id for {description.key}: {unique_id}" - ) + assert expected_suffix in unique_id - def test_unmapped_sensor_entity_registry_defaults(self): + def test_unmapped_sensor_entity_registry_defaults(self) -> None: """Test that unmapped sensors have correct entity registry defaults.""" - span_panel = self._create_mock_span_panel() + snapshot = self._create_snapshot() coordinator = self._create_mock_coordinator() circuit_id = "unmapped_tab_32" for description in UNMAPPED_SENSORS: - sensor = SpanUnmappedCircuitSensor(coordinator, description, span_panel, circuit_id) - - # Unmapped sensors should be enabled but hidden - assert sensor._attr_entity_registry_enabled_default is True, ( - "Unmapped sensor should be enabled by default" + sensor = SpanUnmappedCircuitSensor( + coordinator, description, snapshot, circuit_id ) - assert sensor._attr_entity_registry_visible_default is False, ( - "Unmapped sensor should be hidden by default" - ) - - def test_unmapped_sensor_initialization_bug_prevention(self): - """Test that prevents the specific bug where description.key gets overridden during initialization. + assert sensor._attr_entity_registry_enabled_default is True + assert sensor._attr_entity_registry_visible_default is False - This test specifically catches the bug where the __init__ method was overriding - description.key with circuit_id, causing duplicate unmapped_tab_32 in IDs. - """ - span_panel = self._create_mock_span_panel() + def test_unmapped_sensor_initialization_bug_prevention(self) -> None: + """Prevent the bug where description.key gets overridden during initialization.""" + snapshot = self._create_snapshot() coordinator = self._create_mock_coordinator() circuit_id = "unmapped_tab_32" for description in UNMAPPED_SENSORS: - # Create sensor (this is where the bug would manifest) - sensor = SpanUnmappedCircuitSensor(coordinator, description, span_panel, circuit_id) - - # Verify the original key is preserved - assert hasattr(sensor, "original_key"), "Sensor should store original_key" - assert sensor.original_key == description.key, ( - f"original_key should match description.key: {sensor.original_key} != {description.key}" + sensor = SpanUnmappedCircuitSensor( + coordinator, description, snapshot, circuit_id ) - # Verify no duplication in the actual attributes set during initialization - unique_id = sensor._attr_unique_id + assert hasattr(sensor, "original_key") + assert sensor.original_key == description.key - # The bug would create IDs like: span_serial_unmapped_tab_32_unmapped_tab_32 - assert "unmapped_tab_32_unmapped_tab_32" not in unique_id, ( - f"Bug detected: duplicate circuit_id in unique_id: {unique_id}" - ) + unique_id = sensor._attr_unique_id + assert "unmapped_tab_32_unmapped_tab_32" not in unique_id - # Verify the IDs contain the expected suffix based on original key expected_suffix = { "instantPowerW": "power", "producedEnergyWh": "energy_produced", "consumedEnergyWh": "energy_consumed", }[description.key] - assert expected_suffix in unique_id, ( - f"Expected suffix '{expected_suffix}' not found in unique_id: {unique_id}" - ) - - # Only test unique_id since entity_id is not set by the sensor + assert expected_suffix in unique_id diff --git a/tests/test_upgrade_scenarios.py b/tests/test_upgrade_scenarios.py index c88b2940..04cde7b7 100644 --- a/tests/test_upgrade_scenarios.py +++ b/tests/test_upgrade_scenarios.py @@ -5,16 +5,21 @@ import pytest from custom_components.span_panel.config_flow import OptionsFlowHandler +from custom_components.span_panel.config_flow_options import ( + get_current_naming_pattern, +) from custom_components.span_panel.const import ( USE_CIRCUIT_NUMBERS, USE_DEVICE_PREFIX, EntityNamingPattern, ) from custom_components.span_panel.helpers import ( - construct_entity_id, construct_multi_circuit_entity_id, + construct_single_circuit_entity_id, ) +from .factories import SpanCircuitSnapshotFactory, SpanPanelSnapshotFactory + @pytest.fixture def mock_hass(): @@ -33,7 +38,7 @@ def mock_config_entry(): """Create a mock config entry.""" class MockConfigEntry: - def __init__(self): + def __init__(self) -> None: self.entry_id = "test_entry_id" self.options = {} @@ -60,256 +65,148 @@ def mock_span_panel(): class TestUpgradeScenarios: """Test upgrade scenarios for different installation types.""" - def test_legacy_installation_preserved_on_upgrade(self, mock_hass, mock_config_entry): + def test_legacy_installation_preserved_on_upgrade(self, mock_config_entry): """Test that legacy installations (pre-1.0.4) are preserved during upgrades.""" - # Legacy installation: no device prefix, no circuit numbers mock_config_entry.options = { USE_DEVICE_PREFIX: False, USE_CIRCUIT_NUMBERS: False, } + assert ( + get_current_naming_pattern(mock_config_entry) + == EntityNamingPattern.LEGACY_NAMES.value + ) - with patch.object(OptionsFlowHandler, "__init__", return_value=None): - flow = OptionsFlowHandler.__new__(OptionsFlowHandler) - flow.hass = mock_hass - - # Mock the config_entry property directly instead of going through the registry - with patch.object( - OptionsFlowHandler, - "config_entry", - new_callable=lambda: mock_config_entry, - ): - # Should detect as legacy pattern - current_pattern = flow._get_current_naming_pattern() - assert current_pattern == EntityNamingPattern.LEGACY_NAMES.value - - def test_post_104_friendly_names_preserved_on_upgrade(self, mock_hass, mock_config_entry): + def test_post_104_friendly_names_preserved_on_upgrade(self, mock_config_entry): """Test that post-1.0.4 friendly names installations are preserved during upgrades.""" - # Post-1.0.4 with friendly names: device prefix enabled, circuit numbers disabled mock_config_entry.options = { USE_DEVICE_PREFIX: True, USE_CIRCUIT_NUMBERS: False, } + assert ( + get_current_naming_pattern(mock_config_entry) + == EntityNamingPattern.FRIENDLY_NAMES.value + ) - with patch.object(OptionsFlowHandler, "__init__", return_value=None): - flow = OptionsFlowHandler.__new__(OptionsFlowHandler) - flow.hass = mock_hass - - # Mock the config_entry property directly - with patch.object( - OptionsFlowHandler, - "config_entry", - new_callable=lambda: mock_config_entry, - ): - # Should detect as friendly names pattern - current_pattern = flow._get_current_naming_pattern() - assert current_pattern == EntityNamingPattern.FRIENDLY_NAMES.value - - def test_modern_circuit_numbers_preserved_on_upgrade(self, mock_hass, mock_config_entry): + def test_modern_circuit_numbers_preserved_on_upgrade(self, mock_config_entry): """Test that modern circuit numbers installations are preserved during upgrades.""" - # Modern installation: both device prefix and circuit numbers enabled mock_config_entry.options = { USE_DEVICE_PREFIX: True, USE_CIRCUIT_NUMBERS: True, } - - with patch.object(OptionsFlowHandler, "__init__", return_value=None): - flow = OptionsFlowHandler.__new__(OptionsFlowHandler) - flow.hass = mock_hass - - # Mock the config_entry property directly - with patch.object( - OptionsFlowHandler, - "config_entry", - new_callable=lambda: mock_config_entry, - ): - # Should detect as circuit numbers pattern - current_pattern = flow._get_current_naming_pattern() - assert current_pattern == EntityNamingPattern.CIRCUIT_NUMBERS.value + assert ( + get_current_naming_pattern(mock_config_entry) + == EntityNamingPattern.CIRCUIT_NUMBERS.value + ) def test_missing_options_default_to_new_installation_behavior( - self, mock_hass, mock_config_entry + self, mock_config_entry ): """Test that missing options default to existing installation behavior (legacy).""" - # Empty options (like an existing installation with missing flags) mock_config_entry.options = {} + assert ( + get_current_naming_pattern(mock_config_entry) + == EntityNamingPattern.LEGACY_NAMES.value + ) - with patch.object(OptionsFlowHandler, "__init__", return_value=None): - flow = OptionsFlowHandler.__new__(OptionsFlowHandler) - flow.hass = mock_hass - - # Mock the config_entry property directly - with patch.object( - OptionsFlowHandler, - "config_entry", - new_callable=lambda: mock_config_entry, - ): - # Should default to legacy pattern (existing installation behavior) - current_pattern = flow._get_current_naming_pattern() - assert current_pattern == EntityNamingPattern.LEGACY_NAMES.value - - def test_partial_options_default_correctly(self, mock_hass, mock_config_entry): + def test_partial_options_default_correctly(self, mock_config_entry): """Test that partial options still work correctly with defaults.""" - # Only one option set (edge case) mock_config_entry.options = { USE_DEVICE_PREFIX: False, - # USE_CIRCUIT_NUMBERS missing - defaults to False } + assert ( + get_current_naming_pattern(mock_config_entry) + == EntityNamingPattern.LEGACY_NAMES.value + ) - with patch.object(OptionsFlowHandler, "__init__", return_value=None): - flow = OptionsFlowHandler.__new__(OptionsFlowHandler) - flow.hass = mock_hass - - # Mock the config_entry property directly - with patch.object( - OptionsFlowHandler, - "config_entry", - new_callable=lambda: mock_config_entry, - ): - # With defaults: USE_DEVICE_PREFIX=False, USE_CIRCUIT_NUMBERS=False (default) - # This results in legacy pattern - current_pattern = flow._get_current_naming_pattern() - assert current_pattern == EntityNamingPattern.LEGACY_NAMES.value - - def test_new_installation_gets_modern_defaults(self, mock_hass, mock_config_entry): + def test_new_installation_gets_modern_defaults(self, mock_config_entry): """Test that new installations get modern defaults (circuit numbers).""" - # New installation: explicit modern defaults (as set by create_new_entry) mock_config_entry.options = { USE_DEVICE_PREFIX: True, USE_CIRCUIT_NUMBERS: True, } - - with patch.object(OptionsFlowHandler, "__init__", return_value=None): - flow = OptionsFlowHandler.__new__(OptionsFlowHandler) - flow.hass = mock_hass - - # Mock the config_entry property directly - with patch.object( - OptionsFlowHandler, - "config_entry", - new_callable=lambda: mock_config_entry, - ): - # Should detect as circuit numbers pattern (new installation default) - current_pattern = flow._get_current_naming_pattern() - assert current_pattern == EntityNamingPattern.CIRCUIT_NUMBERS.value + assert ( + get_current_naming_pattern(mock_config_entry) + == EntityNamingPattern.CIRCUIT_NUMBERS.value + ) class TestEntityIdConstructionUpgradeScenarios: """Test entity ID construction preserves existing patterns during upgrades.""" - def test_legacy_entity_id_construction_preserved(self, mock_coordinator, mock_span_panel): + def _make_coordinator_and_snapshot(self, options, circuit_tabs=None): + """Build a coordinator mock and snapshot with a single circuit.""" + circuit = SpanCircuitSnapshotFactory.create( + circuit_id="15", + name="Kitchen Outlets", + tabs=circuit_tabs or [15], + ) + snapshot = SpanPanelSnapshotFactory.create(circuits={"15": circuit}) + coordinator = MagicMock() + coordinator.data = snapshot + coordinator.config_entry = MagicMock() + coordinator.config_entry.title = "Span Panel" + coordinator.config_entry.data = {"device_name": "Span Panel"} + coordinator.config_entry.options = options + coordinator.hass = MagicMock() + return coordinator, snapshot, circuit + + def test_legacy_entity_id_construction_preserved(self): """Test that legacy entity ID construction is preserved.""" - # Legacy installation - mock_config_entry = MagicMock() - mock_config_entry.options = { - USE_DEVICE_PREFIX: False, - USE_CIRCUIT_NUMBERS: False, - } - mock_config_entry.title = "Span Panel" # Provide string title - mock_coordinator.config_entry = mock_config_entry - - # Mock device info - with patch("custom_components.span_panel.helpers.panel_to_device_info") as mock_device_info: - mock_device_info.return_value = {"name": "Span Panel"} - - entity_id = construct_entity_id( - mock_coordinator, - mock_span_panel, - "sensor", - "Kitchen Outlets", - 15, - "power", - ) - - # Legacy format: no device prefix, use circuit name - assert entity_id == "sensor.kitchen_outlets_power" - - def test_post_104_friendly_names_entity_id_construction_preserved( - self, mock_coordinator, mock_span_panel - ): + coordinator, snapshot, circuit = self._make_coordinator_and_snapshot( + {USE_DEVICE_PREFIX: False, USE_CIRCUIT_NUMBERS: False} + ) + entity_id = construct_single_circuit_entity_id( + coordinator, + snapshot, + "sensor", + "power", + circuit, + ) + assert entity_id == "sensor.kitchen_outlets_power" + + def test_post_104_friendly_names_entity_id_construction_preserved(self): """Test that post-1.0.4 friendly names entity ID construction is preserved.""" - # Post-1.0.4 friendly names installation - mock_config_entry = MagicMock() - mock_config_entry.options = { - USE_DEVICE_PREFIX: True, - USE_CIRCUIT_NUMBERS: False, - } - mock_config_entry.title = "Span Panel" # Provide string title - mock_config_entry.data = {"device_name": "Span Panel"} - mock_coordinator.config_entry = mock_config_entry - - # Mock device info - with patch("custom_components.span_panel.helpers.panel_to_device_info") as mock_device_info: - mock_device_info.return_value = {"name": "Span Panel"} - - entity_id = construct_entity_id( - mock_coordinator, - mock_span_panel, - "sensor", - "Kitchen Outlets", - 15, - "power", - ) - - # Post-1.0.4 format: device prefix + circuit name - assert entity_id == "sensor.span_panel_kitchen_outlets_power" - - def test_modern_circuit_numbers_entity_id_construction_preserved( - self, mock_coordinator, mock_span_panel - ): - """Test that modern circuit numbers entity ID construction is preserved.""" - # Modern circuit numbers installation - mock_config_entry = MagicMock() - mock_config_entry.options = { - USE_DEVICE_PREFIX: True, - USE_CIRCUIT_NUMBERS: True, - } - mock_config_entry.title = "Span Panel" # Provide string title - mock_config_entry.data = {"device_name": "Span Panel"} - mock_coordinator.config_entry = mock_config_entry - - # Mock device info - with patch("custom_components.span_panel.helpers.panel_to_device_info") as mock_device_info: - mock_device_info.return_value = {"name": "Span Panel"} - - entity_id = construct_entity_id( - mock_coordinator, - mock_span_panel, - "sensor", - "Kitchen Outlets", - 15, - "power", - ) - - # Modern format: device prefix + circuit number - assert entity_id == "sensor.span_panel_circuit_15_power" - - def test_missing_options_use_new_installation_defaults(self, mock_coordinator, mock_span_panel): - """Test that new installations with explicit options use modern defaults.""" - # New installation: explicit modern defaults (as set by create_new_entry) - mock_config_entry = MagicMock() - mock_config_entry.options = { - USE_DEVICE_PREFIX: True, - USE_CIRCUIT_NUMBERS: True, - } - mock_config_entry.title = "Span Panel" # Provide string title - mock_config_entry.data = {"device_name": "Span Panel"} - mock_coordinator.config_entry = mock_config_entry - - # Mock device info - with patch("custom_components.span_panel.helpers.panel_to_device_info") as mock_device_info: - mock_device_info.return_value = {"name": "Span Panel"} - - entity_id = construct_entity_id( - mock_coordinator, - mock_span_panel, - "sensor", - "Kitchen Outlets", - 15, - "power", - ) - - # New installation defaults: device prefix + circuit numbers - assert entity_id == "sensor.span_panel_circuit_15_power" + coordinator, snapshot, circuit = self._make_coordinator_and_snapshot( + {USE_DEVICE_PREFIX: True, USE_CIRCUIT_NUMBERS: False} + ) + entity_id = construct_single_circuit_entity_id( + coordinator, + snapshot, + "sensor", + "power", + circuit, + ) + assert entity_id == "sensor.span_panel_kitchen_outlets_power" + + def test_modern_circuit_numbers_120v_entity_id_construction_preserved(self): + """Test that modern circuit numbers entity ID for 120V is preserved.""" + coordinator, snapshot, circuit = self._make_coordinator_and_snapshot( + {USE_DEVICE_PREFIX: True, USE_CIRCUIT_NUMBERS: True}, + circuit_tabs=[15], + ) + entity_id = construct_single_circuit_entity_id( + coordinator, + snapshot, + "sensor", + "power", + circuit, + ) + assert entity_id == "sensor.span_panel_circuit_15_power" + + def test_modern_circuit_numbers_240v_entity_id_includes_both_tabs(self): + """Test that 240V circuits include both tabs in entity ID.""" + coordinator, snapshot, circuit = self._make_coordinator_and_snapshot( + {USE_DEVICE_PREFIX: True, USE_CIRCUIT_NUMBERS: True}, + circuit_tabs=[15, 17], + ) + entity_id = construct_single_circuit_entity_id( + coordinator, + snapshot, + "sensor", + "power", + circuit, + ) + assert entity_id == "sensor.span_panel_circuit_15_17_power" class TestSyntheticEntityUpgradeScenarios: @@ -322,30 +219,24 @@ def test_legacy_synthetic_entity_construction_preserved( """Test that legacy synthetic entity construction is preserved.""" mock_registry.return_value = None - # Legacy installation mock_config_entry = MagicMock() mock_config_entry.options = { USE_DEVICE_PREFIX: False, USE_CIRCUIT_NUMBERS: False, } - mock_config_entry.title = "Span Panel" # Provide string title + mock_config_entry.title = "Span Panel" + mock_config_entry.data = {"device_name": "Span Panel"} mock_coordinator.config_entry = mock_config_entry - # Mock device info - with patch("custom_components.span_panel.helpers.panel_to_device_info") as mock_device_info: - mock_device_info.return_value = {"name": "Span Panel"} - - entity_id = construct_multi_circuit_entity_id( - mock_coordinator, - mock_span_panel, - "sensor", - "power", - circuit_numbers=[30, 32], # Solar inverter on circuits 30 and 32 - friendly_name="Solar Inverter", - ) - - # Legacy format: no device prefix, use friendly name - assert entity_id == "sensor.solar_inverter_power" + entity_id = construct_multi_circuit_entity_id( + mock_coordinator, + mock_span_panel, + "sensor", + "power", + circuit_numbers=[30, 32], + friendly_name="Solar Inverter", + ) + assert entity_id == "sensor.solar_inverter_power" @patch("custom_components.span_panel.helpers.er.async_get") def test_post_104_synthetic_entity_construction_preserved( @@ -354,31 +245,24 @@ def test_post_104_synthetic_entity_construction_preserved( """Test that post-1.0.4 synthetic entity construction is preserved.""" mock_registry.return_value = None - # Post-1.0.4 friendly names installation mock_config_entry = MagicMock() mock_config_entry.options = { USE_DEVICE_PREFIX: True, USE_CIRCUIT_NUMBERS: False, } - mock_config_entry.title = "Span Panel" # Provide string title + mock_config_entry.title = "Span Panel" mock_config_entry.data = {"device_name": "Span Panel"} mock_coordinator.config_entry = mock_config_entry - # Mock device info - with patch("custom_components.span_panel.helpers.panel_to_device_info") as mock_device_info: - mock_device_info.return_value = {"name": "Span Panel"} - - entity_id = construct_multi_circuit_entity_id( - mock_coordinator, - mock_span_panel, - "sensor", - "power", - circuit_numbers=[30, 32], # Solar inverter on circuits 30 and 32 - friendly_name="Solar Inverter", - ) - - # Post-1.0.4 format: device prefix + friendly name - assert entity_id == "sensor.span_panel_solar_inverter_power" + entity_id = construct_multi_circuit_entity_id( + mock_coordinator, + mock_span_panel, + "sensor", + "power", + circuit_numbers=[30, 32], + friendly_name="Solar Inverter", + ) + assert entity_id == "sensor.span_panel_solar_inverter_power" @patch("custom_components.span_panel.helpers.er.async_get") def test_modern_synthetic_entity_construction_preserved( @@ -387,37 +271,32 @@ def test_modern_synthetic_entity_construction_preserved( """Test that modern synthetic entity construction is preserved.""" mock_registry.return_value = None - # Modern circuit numbers installation mock_config_entry = MagicMock() mock_config_entry.options = { USE_DEVICE_PREFIX: True, USE_CIRCUIT_NUMBERS: True, } - mock_config_entry.title = "Span Panel" # Provide string title + mock_config_entry.title = "Span Panel" mock_config_entry.data = {"device_name": "Span Panel"} mock_coordinator.config_entry = mock_config_entry - # Mock device info - with patch("custom_components.span_panel.helpers.panel_to_device_info") as mock_device_info: - mock_device_info.return_value = {"name": "Span Panel"} - - entity_id = construct_multi_circuit_entity_id( - mock_coordinator, - mock_span_panel, - "sensor", - "power", - circuit_numbers=[30, 32], # Solar inverter on circuits 30 and 32 - friendly_name="Solar Inverter", - ) - - # Modern format: device prefix + circuit numbers (when USE_CIRCUIT_NUMBERS is True) - assert entity_id == "sensor.span_panel_circuit_30_32_power" + entity_id = construct_multi_circuit_entity_id( + mock_coordinator, + mock_span_panel, + "sensor", + "power", + circuit_numbers=[30, 32], + friendly_name="Solar Inverter", + ) + assert entity_id == "sensor.span_panel_circuit_30_32_power" class TestGeneralOptionsPreservesNamingFlags: """Test that general options flow preserves naming flags.""" - async def test_general_options_preserves_legacy_flags(self, mock_hass, mock_config_entry): + async def test_general_options_preserves_legacy_flags( + self, mock_hass, mock_config_entry + ): """Test that general options flow preserves legacy naming flags.""" # Legacy installation flags mock_config_entry.options = { @@ -439,7 +318,6 @@ async def test_general_options_preserves_legacy_flags(self, mock_hass, mock_conf "leg2": 32, # Unchanged } - # Mock the config_entry property and async_step_general_options method with ( patch.object( OptionsFlowHandler, @@ -447,16 +325,20 @@ async def test_general_options_preserves_legacy_flags(self, mock_hass, mock_conf new_callable=lambda: mock_config_entry, ), patch.object(flow, "async_create_entry") as mock_create_entry, + patch("custom_components.span_panel.async_load_panel_settings", return_value={}), + patch("custom_components.span_panel.async_save_panel_settings"), + patch("custom_components.span_panel.async_apply_panel_registration"), ): await flow.async_step_general_options(user_input) - # Verify that naming flags were preserved mock_create_entry.assert_called_once() result_data = mock_create_entry.call_args[1]["data"] assert result_data.get(USE_DEVICE_PREFIX) is False # Preserved assert result_data.get(USE_CIRCUIT_NUMBERS) is False # Preserved - async def test_general_options_preserves_modern_flags(self, mock_hass, mock_config_entry): + async def test_general_options_preserves_modern_flags( + self, mock_hass, mock_config_entry + ): """Test that general options flow preserves modern naming flags.""" # Modern installation flags mock_config_entry.options = { @@ -478,7 +360,6 @@ async def test_general_options_preserves_modern_flags(self, mock_hass, mock_conf "leg2": 30, # Changed } - # Mock the config_entry property and async_step_general_options method with ( patch.object( OptionsFlowHandler, @@ -486,10 +367,12 @@ async def test_general_options_preserves_modern_flags(self, mock_hass, mock_conf new_callable=lambda: mock_config_entry, ), patch.object(flow, "async_create_entry") as mock_create_entry, + patch("custom_components.span_panel.async_load_panel_settings", return_value={}), + patch("custom_components.span_panel.async_save_panel_settings"), + patch("custom_components.span_panel.async_apply_panel_registration"), ): await flow.async_step_general_options(user_input) - # Verify that naming flags were preserved mock_create_entry.assert_called_once() result_data = mock_create_entry.call_args[1]["data"] assert result_data.get(USE_DEVICE_PREFIX) is True # Preserved @@ -518,7 +401,6 @@ async def test_general_options_handles_missing_flags_with_defaults( "leg2": 32, } - # Mock the config_entry property and async_step_general_options method with ( patch.object( OptionsFlowHandler, @@ -526,10 +408,12 @@ async def test_general_options_handles_missing_flags_with_defaults( new_callable=lambda: mock_config_entry, ), patch.object(flow, "async_create_entry") as mock_create_entry, + patch("custom_components.span_panel.async_load_panel_settings", return_value={}), + patch("custom_components.span_panel.async_save_panel_settings"), + patch("custom_components.span_panel.async_apply_panel_registration"), ): await flow.async_step_general_options(user_input) - # Verify that missing flags get defaults (True for safety, prevents treating as legacy) mock_create_entry.assert_called_once() result_data = mock_create_entry.call_args[1]["data"] assert ( @@ -538,111 +422,3 @@ async def test_general_options_handles_missing_flags_with_defaults( assert ( result_data.get(USE_CIRCUIT_NUMBERS) is False ) # Default for existing installations (circuit numbers off by default) - - -class TestUpgradeDocumentationCompliance: - """Test that upgrade scenarios comply with documentation.""" - - def test_readme_compliance_legacy_pattern(self, mock_hass, mock_config_entry): - """Test that README examples match actual legacy pattern behavior.""" - # Legacy installation (pre-1.0.4 or upgraded without options) - mock_config_entry.options = { - USE_DEVICE_PREFIX: False, - USE_CIRCUIT_NUMBERS: False, - } - - with patch.object(OptionsFlowHandler, "__init__", return_value=None): - flow = OptionsFlowHandler.__new__(OptionsFlowHandler) - flow.hass = mock_hass - - # Mock the config_entry property directly - with patch.object( - OptionsFlowHandler, - "config_entry", - new_callable=lambda: mock_config_entry, - ): - pattern = flow._get_current_naming_pattern() - assert pattern == EntityNamingPattern.LEGACY_NAMES.value - - def test_readme_compliance_friendly_names_pattern(self, mock_hass, mock_config_entry): - """Test that README examples match actual friendly names pattern behavior.""" - # Post-1.0.4 installation with friendly names - mock_config_entry.options = { - USE_DEVICE_PREFIX: True, - USE_CIRCUIT_NUMBERS: False, - } - - with patch.object(OptionsFlowHandler, "__init__", return_value=None): - flow = OptionsFlowHandler.__new__(OptionsFlowHandler) - flow.hass = mock_hass - - # Mock the config_entry property directly - with patch.object( - OptionsFlowHandler, - "config_entry", - new_callable=lambda: mock_config_entry, - ): - pattern = flow._get_current_naming_pattern() - assert pattern == EntityNamingPattern.FRIENDLY_NAMES.value - - def test_readme_compliance_circuit_numbers_pattern(self, mock_hass, mock_config_entry): - """Test that README examples match actual circuit numbers pattern behavior.""" - # Post-1.0.9 installation with circuit numbers - mock_config_entry.options = { - USE_DEVICE_PREFIX: True, - USE_CIRCUIT_NUMBERS: True, - } - - with patch.object(OptionsFlowHandler, "__init__", return_value=None): - flow = OptionsFlowHandler.__new__(OptionsFlowHandler) - flow.hass = mock_hass - - # Mock the config_entry property directly - with patch.object( - OptionsFlowHandler, - "config_entry", - new_callable=lambda: mock_config_entry, - ): - pattern = flow._get_current_naming_pattern() - assert pattern == EntityNamingPattern.CIRCUIT_NUMBERS.value - - def test_readme_compliance_new_installation_default(self, mock_hass, mock_config_entry): - """Test that new installations default to circuit numbers as documented.""" - # New installation (post-1.0.9) - explicit defaults set by create_new_entry - mock_config_entry.options = { - USE_DEVICE_PREFIX: True, - USE_CIRCUIT_NUMBERS: True, - } - - with patch.object(OptionsFlowHandler, "__init__", return_value=None): - flow = OptionsFlowHandler.__new__(OptionsFlowHandler) - flow.hass = mock_hass - - # Mock the config_entry property directly - with patch.object( - OptionsFlowHandler, - "config_entry", - new_callable=lambda: mock_config_entry, - ): - pattern = flow._get_current_naming_pattern() - assert pattern == EntityNamingPattern.CIRCUIT_NUMBERS.value - - def test_readme_compliance_existing_installation_empty_options( - self, mock_hass, mock_config_entry - ): - """Test that existing installations with empty options default to legacy as documented.""" - # Existing installation with no options (pre-1.0.4 or upgraded without options) - mock_config_entry.options = {} - - with patch.object(OptionsFlowHandler, "__init__", return_value=None): - flow = OptionsFlowHandler.__new__(OptionsFlowHandler) - flow.hass = mock_hass - - # Mock the config_entry property directly - with patch.object( - OptionsFlowHandler, - "config_entry", - new_callable=lambda: mock_config_entry, - ): - pattern = flow._get_current_naming_pattern() - assert pattern == EntityNamingPattern.LEGACY_NAMES.value diff --git a/tests/test_v2_config_flow.py b/tests/test_v2_config_flow.py new file mode 100644 index 00000000..5ec001e2 --- /dev/null +++ b/tests/test_v2_config_flow.py @@ -0,0 +1,2088 @@ +"""Tests for v2 eBus config flow changes.""" + +from __future__ import annotations + +import ipaddress +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from span_panel_api import DetectionResult, V2AuthResponse, V2StatusInfo +from span_panel_api.exceptions import SpanPanelAuthError, SpanPanelConnectionError + +from homeassistant import config_entries +from custom_components.span_panel import ( + CURRENT_CONFIG_VERSION, + async_migrate_entry, + async_setup_entry, +) +from custom_components.span_panel.config_flow import ( + SpanPanelConfigFlow, + TriggerFlowType, +) +from custom_components.span_panel.const import ( + CONF_API_VERSION, + CONF_EBUS_BROKER_HOST, + CONF_EBUS_BROKER_PASSWORD, + CONF_EBUS_BROKER_PORT, + CONF_EBUS_BROKER_USERNAME, + CONF_HOP_PASSPHRASE, + CONF_HTTP_PORT, + CONF_PANEL_SERIAL, + CONF_REGISTERED_FQDN, + DOMAIN, +) +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.hassio import HassioServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo + +from pytest_homeassistant_custom_component.common import MockConfigEntry + +# Shared mock detection for a different panel (used in reconfigure/duplicate tests) +MOCK_V2_DETECTION_OTHER = DetectionResult( + api_version="v2", + status_info=V2StatusInfo( + serial_number="SPAN-V2-OTHER", + firmware_version="2.0.0", + ), +) + +# ---------- helpers ---------- + +MOCK_HOST = "192.168.1.100" +MOCK_PASSPHRASE = "correct-horse-battery-staple" + +MOCK_V2_DETECTION = DetectionResult( + api_version="v2", + status_info=V2StatusInfo( + serial_number="SPAN-V2-001", + firmware_version="2.0.0", + ), +) + +MOCK_V2_DETECTION_PROXIMITY_PROVEN = DetectionResult( + api_version="v2", + status_info=V2StatusInfo( + serial_number="SPAN-V2-001", + firmware_version="2.0.0", + proximity_proven=True, + ), +) + +MOCK_V2_DETECTION_PROXIMITY_NOT_PROVEN = DetectionResult( + api_version="v2", + status_info=V2StatusInfo( + serial_number="SPAN-V2-001", + firmware_version="2.0.0", + proximity_proven=False, + ), +) + +MOCK_V1_DETECTION = DetectionResult( + api_version="v1", + status_info=None, +) + +MOCK_V2_AUTH = V2AuthResponse( + access_token="v2-token-abc", + token_type="bearer", + iat_ms=1700000000000, + ebus_broker_host="192.168.1.100", + ebus_broker_mqtts_port=8883, + ebus_broker_ws_port=8080, + ebus_broker_wss_port=8443, + ebus_broker_username="span-user", + ebus_broker_password="mqtt-secret", + hostname="span-panel.local", + serial_number="SPAN-V2-001", + hop_passphrase=MOCK_PASSPHRASE, +) + + +# ---------- v2 detection routing ---------- + + +@pytest.mark.asyncio +async def test_user_flow_detects_v2_and_shows_auth_choice(hass: HomeAssistant) -> None: + """When detect_api_version returns v2, the user flow should show the auth choice menu.""" + with ( + patch( + "custom_components.span_panel.config_flow.detect_api_version", + return_value=MOCK_V2_DETECTION, + ), + patch( + "custom_components.span_panel.config_flow.validate_host", + return_value=True, + ), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: MOCK_HOST}, + ) + + assert result2["type"] == FlowResultType.MENU + assert result2["step_id"] == "choose_v2_auth" + assert "auth_passphrase" in result2["menu_options"] + assert "auth_proximity" in result2["menu_options"] + + +@pytest.mark.asyncio +async def test_user_flow_passes_ha_httpx_client_to_detect_api_version( + hass: HomeAssistant, +) -> None: + """User flow should pass the Home Assistant shared httpx client to detection.""" + fake_client = MagicMock() + with ( + patch( + "custom_components.span_panel.config_flow.get_async_client", + return_value=fake_client, + ) as mock_get_client, + patch( + "custom_components.span_panel.config_flow.detect_api_version", + return_value=MOCK_V2_DETECTION, + ) as mock_detect, + patch( + "custom_components.span_panel.config_flow.validate_host", + return_value=True, + ), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: MOCK_HOST}, + ) + + mock_get_client.assert_called_once_with(hass, verify_ssl=False) + mock_detect.assert_awaited_once_with(MOCK_HOST, port=80, httpx_client=fake_client) + + +@pytest.mark.asyncio +async def test_user_flow_v1_aborts(hass: HomeAssistant) -> None: + """When detect_api_version returns v1, the user flow should abort (v1 not supported).""" + with ( + patch( + "custom_components.span_panel.config_flow.detect_api_version", + return_value=MOCK_V1_DETECTION, + ), + patch( + "custom_components.span_panel.config_flow.validate_host", + return_value=True, + ), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: MOCK_HOST}, + ) + + # Non-v2 panels are not supported and should abort + assert result2["type"] in (FlowResultType.FORM, FlowResultType.ABORT) + + +# ---------- passphrase auth ---------- + + +@pytest.mark.asyncio +async def test_passphrase_auth_success(hass: HomeAssistant) -> None: + """Successful passphrase auth should proceed to naming step.""" + with ( + patch( + "custom_components.span_panel.config_flow.detect_api_version", + return_value=MOCK_V2_DETECTION, + ), + patch( + "custom_components.span_panel.config_flow.validate_host", + return_value=True, + ), + patch( + "custom_components.span_panel.config_flow.validate_v2_passphrase", + return_value=MOCK_V2_AUTH, + ), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: MOCK_HOST}, + ) + assert result2["step_id"] == "choose_v2_auth" + + # Select passphrase auth from the menu + result2b = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {"next_step_id": "auth_passphrase"}, + ) + assert result2b["step_id"] == "auth_passphrase" + + result3 = await hass.config_entries.flow.async_configure( + result2b["flow_id"], + {CONF_HOP_PASSPHRASE: MOCK_PASSPHRASE}, + ) + + assert result3["type"] == FlowResultType.FORM + assert result3["step_id"] == "choose_entity_naming_initial" + + +@pytest.mark.asyncio +async def test_passphrase_auth_bad_passphrase(hass: HomeAssistant) -> None: + """Bad passphrase should re-show the form with invalid_auth error.""" + with ( + patch( + "custom_components.span_panel.config_flow.detect_api_version", + return_value=MOCK_V2_DETECTION, + ), + patch( + "custom_components.span_panel.config_flow.validate_host", + return_value=True, + ), + patch( + "custom_components.span_panel.config_flow.validate_v2_passphrase", + side_effect=SpanPanelAuthError("Invalid passphrase"), + ), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: MOCK_HOST}, + ) + + # Select passphrase auth from the menu + result2b = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {"next_step_id": "auth_passphrase"}, + ) + + result3 = await hass.config_entries.flow.async_configure( + result2b["flow_id"], + {CONF_HOP_PASSPHRASE: "wrong-passphrase"}, + ) + + assert result3["type"] == FlowResultType.FORM + assert result3["step_id"] == "auth_passphrase" + assert result3["errors"] == {"base": "invalid_auth"} + + +@pytest.mark.asyncio +async def test_passphrase_auth_connection_error(hass: HomeAssistant) -> None: + """Connection error should re-show form with cannot_connect.""" + with ( + patch( + "custom_components.span_panel.config_flow.detect_api_version", + return_value=MOCK_V2_DETECTION, + ), + patch( + "custom_components.span_panel.config_flow.validate_host", + return_value=True, + ), + patch( + "custom_components.span_panel.config_flow.validate_v2_passphrase", + side_effect=SpanPanelConnectionError("timeout"), + ), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: MOCK_HOST}, + ) + + # Select passphrase auth from the menu + result2b = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {"next_step_id": "auth_passphrase"}, + ) + + result3 = await hass.config_entries.flow.async_configure( + result2b["flow_id"], + {CONF_HOP_PASSPHRASE: MOCK_PASSPHRASE}, + ) + + assert result3["type"] == FlowResultType.FORM + assert result3["step_id"] == "auth_passphrase" + assert result3["errors"] == {"base": "cannot_connect"} + + +# ---------- v2 entry creation ---------- + + +@pytest.mark.usefixtures("socket_enabled") +@pytest.mark.asyncio +async def test_v2_entry_contains_mqtt_credentials(hass: HomeAssistant) -> None: + """A completed v2 flow should create an entry with MQTT broker fields.""" + with ( + patch( + "custom_components.span_panel.config_flow.detect_api_version", + return_value=MOCK_V2_DETECTION, + ), + patch( + "custom_components.span_panel.config_flow.validate_host", + return_value=True, + ), + patch( + "custom_components.span_panel.config_flow.validate_v2_passphrase", + return_value=MOCK_V2_AUTH, + ), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + # Step 1: submit host + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: MOCK_HOST}, + ) + + # Step 2: choose auth method (passphrase) + result2b = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {"next_step_id": "auth_passphrase"}, + ) + + # Step 3: submit passphrase + result3 = await hass.config_entries.flow.async_configure( + result2b["flow_id"], + {CONF_HOP_PASSPHRASE: MOCK_PASSPHRASE}, + ) + + # Step 4: choose entity naming pattern (accept default) + result4 = await hass.config_entries.flow.async_configure( + result3["flow_id"], + {"entity_naming_pattern": "friendly_names"}, + ) + + assert result4["type"] == FlowResultType.CREATE_ENTRY + data = result4["data"] + assert data[CONF_API_VERSION] == "v2" + assert data[CONF_HOST] == MOCK_HOST + assert data[CONF_ACCESS_TOKEN] == "v2-token-abc" + assert data[CONF_EBUS_BROKER_HOST] == "192.168.1.100" + assert data[CONF_EBUS_BROKER_PORT] == 8883 + assert data[CONF_EBUS_BROKER_USERNAME] == "span-user" + assert data[CONF_EBUS_BROKER_PASSWORD] == "mqtt-secret" + assert data[CONF_HOP_PASSPHRASE] == MOCK_PASSPHRASE + assert data[CONF_PANEL_SERIAL] == "SPAN-V2-001" + + +# ---------- config entry migration (2.0.4 baseline) ---------- + + +@pytest.mark.asyncio +async def test_config_flow_uses_current_config_entry_version() -> None: + """New core entries should use the current storage version.""" + + assert SpanPanelConfigFlow.VERSION == CURRENT_CONFIG_VERSION + + +@pytest.mark.asyncio +async def test_migration_updates_older_entry_to_current_version( + hass: HomeAssistant, +) -> None: + """v1.3.1 entries (version 2) should migrate through v3→v4→v5→v6.""" + entry = MockConfigEntry( + version=2, + minor_version=1, + domain=DOMAIN, + title="Span Panel", + data={ + CONF_HOST: "192.168.1.50", + CONF_ACCESS_TOKEN: "old-token", + }, + source=config_entries.SOURCE_USER, + options={}, + unique_id="SN-LIVE-001", + ) + entry.add_to_hass(hass) + + result = await async_migrate_entry(hass, entry) + + assert result is True + assert entry.version == 6 + # v2→v3 migration adds api_version field + assert entry.data[CONF_API_VERSION] == "v1" + + +@pytest.mark.asyncio +async def test_simulation_entry_migrates_normally(hass: HomeAssistant) -> None: + """Simulation entries migrate to v6; setup will fail naturally at connection time.""" + entry = MockConfigEntry( + version=5, + minor_version=1, + domain=DOMAIN, + title="Span Simulator", + data={ + CONF_HOST: "sim-001", + CONF_ACCESS_TOKEN: "simulator_token", + CONF_API_VERSION: "simulation", + "simulation_mode": True, + }, + source=config_entries.SOURCE_USER, + options={}, + unique_id="SIM-001", + ) + entry.add_to_hass(hass) + + result = await async_migrate_entry(hass, entry) + assert result is True + assert entry.version == 6 + + +# ---------- zeroconf v2 discovery ---------- + + +@pytest.mark.asyncio +async def test_zeroconf_ebus_discovery_routes_to_confirm(hass: HomeAssistant) -> None: + """Discovering an _ebus._tcp.local. service should set api_version=v2 and show confirm.""" + + discovery_info = ZeroconfServiceInfo( + ip_address=ipaddress.IPv4Address("192.168.1.200"), + ip_addresses=[ipaddress.IPv4Address("192.168.1.200")], + hostname="span-panel.local.", + name="SPAN Panel._ebus._tcp.local.", + port=8883, + properties={}, + type="_ebus._tcp.local.", + ) + + with ( + patch( + "custom_components.span_panel.config_flow.detect_api_version", + return_value=MOCK_V2_DETECTION, + ), + patch( + "custom_components.span_panel.config_flow.is_ipv4_address", + return_value=True, + ), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=discovery_info, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "confirm_discovery" + + +# ---------- reauth ---------- + + +@pytest.mark.asyncio +async def test_reauth_v2_shows_auth_choice(hass: HomeAssistant) -> None: + """Reauth for a v2 panel should show the auth choice menu.""" + entry = MockConfigEntry( + version=3, + minor_version=1, + domain=DOMAIN, + title="Span Panel", + data={ + CONF_HOST: MOCK_HOST, + CONF_ACCESS_TOKEN: "old-v2-token", + CONF_API_VERSION: "v2", + }, + source=config_entries.SOURCE_USER, + options={}, + unique_id="SPAN-V2-001", + ) + entry.add_to_hass(hass) + + with patch( + "custom_components.span_panel.config_flow.detect_api_version", + return_value=MOCK_V2_DETECTION, + ): + result = await entry.start_reauth_flow(hass) + + assert result["type"] == FlowResultType.MENU + assert result["step_id"] == "reauth_confirm" + + +@pytest.mark.asyncio +async def test_reauth_aborts_cannot_connect_when_probe_failed( + hass: HomeAssistant, +) -> None: + """Reauth must abort with cannot_connect when detection probe fails.""" + entry = MockConfigEntry( + version=3, + minor_version=1, + domain=DOMAIN, + title="Span Panel", + data={ + CONF_HOST: MOCK_HOST, + CONF_ACCESS_TOKEN: "old-v2-token", + CONF_API_VERSION: "v2", + }, + source=config_entries.SOURCE_USER, + options={}, + unique_id="SPAN-V2-001", + ) + entry.add_to_hass(hass) + + probe_failed = DetectionResult( + api_version="v1", + status_info=None, + probe_failed=True, + ) + with patch( + "custom_components.span_panel.config_flow.detect_api_version", + return_value=probe_failed, + ): + result = await entry.start_reauth_flow(hass) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +@pytest.mark.asyncio +async def test_reauth_v2_success_updates_entry(hass: HomeAssistant) -> None: + """Successful v2 reauth should update the config entry with new MQTT creds.""" + entry = MockConfigEntry( + version=3, + minor_version=1, + domain=DOMAIN, + title="Span Panel", + data={ + CONF_HOST: MOCK_HOST, + CONF_ACCESS_TOKEN: "old-v2-token", + CONF_API_VERSION: "v2", + CONF_EBUS_BROKER_HOST: "old-host", + CONF_EBUS_BROKER_PORT: 8883, + CONF_EBUS_BROKER_USERNAME: "old-user", + CONF_EBUS_BROKER_PASSWORD: "old-pass", + }, + source=config_entries.SOURCE_USER, + options={}, + unique_id="SPAN-V2-001", + ) + entry.add_to_hass(hass) + + with ( + patch( + "custom_components.span_panel.config_flow.detect_api_version", + return_value=MOCK_V2_DETECTION, + ), + patch( + "custom_components.span_panel.config_flow.validate_v2_passphrase", + return_value=MOCK_V2_AUTH, + ), + patch.object(hass.config_entries, "async_reload", return_value=True), + ): + result = await entry.start_reauth_flow(hass) + assert result["type"] == FlowResultType.MENU + assert result["step_id"] == "reauth_confirm" + + # Select passphrase auth from the reauth menu + result1 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "auth_passphrase"}, + ) + assert result1["step_id"] == "auth_passphrase" + + result2 = await hass.config_entries.flow.async_configure( + result1["flow_id"], + {CONF_HOP_PASSPHRASE: MOCK_PASSPHRASE}, + ) + + assert result2["type"] == FlowResultType.ABORT + assert result2["reason"] == "reauth_successful" + + assert entry.data[CONF_ACCESS_TOKEN] == "v2-token-abc" + assert entry.data[CONF_EBUS_BROKER_USERNAME] == "span-user" + assert entry.data[CONF_EBUS_BROKER_PASSWORD] == "mqtt-secret" + + +# ---------- user flow error paths ---------- + + +@pytest.mark.asyncio +async def test_user_flow_empty_host(hass: HomeAssistant) -> None: + """Submitting an empty host should re-show the form with host_required error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["step_id"] == "user" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: ""}, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["step_id"] == "user" + assert result2["errors"] == {"base": "host_required"} + + +@pytest.mark.asyncio +async def test_user_flow_host_unreachable(hass: HomeAssistant) -> None: + """Unreachable host should re-show the form with cannot_connect error.""" + with patch( + "custom_components.span_panel.config_flow.validate_host", + return_value=False, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "10.0.0.99"}, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["step_id"] == "user" + assert result2["errors"] == {"base": "cannot_connect"} + + +@pytest.mark.asyncio +async def test_user_flow_cannot_connect_when_second_detection_probe_failed( + hass: HomeAssistant, +) -> None: + """Second detection with probe_failed must show cannot_connect, not v1_not_supported.""" + probe_failed = DetectionResult( + api_version="v1", + status_info=None, + probe_failed=True, + ) + with ( + patch( + "custom_components.span_panel.config_flow.validate_host", + return_value=True, + ), + patch( + "custom_components.span_panel.config_flow.detect_api_version", + return_value=probe_failed, + ), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: MOCK_HOST}, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["step_id"] == "user" + assert result2["errors"] == {"base": "cannot_connect"} + + +@pytest.mark.asyncio +async def test_user_flow_recovery_after_bad_host(hass: HomeAssistant) -> None: + """User can complete setup after an initial host validation failure.""" + with ( + patch( + "custom_components.span_panel.config_flow.validate_host", + side_effect=[False, True], + ), + patch( + "custom_components.span_panel.config_flow.detect_api_version", + return_value=MOCK_V2_DETECTION, + ), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + # First attempt fails + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "bad-host"}, + ) + assert result2["errors"] == {"base": "cannot_connect"} + + # Second attempt succeeds + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {CONF_HOST: MOCK_HOST}, + ) + assert result3["type"] == FlowResultType.MENU + assert result3["step_id"] == "choose_v2_auth" + + +# ---------- passphrase auth: empty passphrase ---------- + + +@pytest.mark.asyncio +async def test_passphrase_auth_empty_passphrase(hass: HomeAssistant) -> None: + """Empty passphrase should re-show the form with invalid_auth error.""" + with ( + patch( + "custom_components.span_panel.config_flow.detect_api_version", + return_value=MOCK_V2_DETECTION, + ), + patch( + "custom_components.span_panel.config_flow.validate_host", + return_value=True, + ), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: MOCK_HOST}, + ) + + result2b = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {"next_step_id": "auth_passphrase"}, + ) + + result3 = await hass.config_entries.flow.async_configure( + result2b["flow_id"], + {CONF_HOP_PASSPHRASE: ""}, + ) + + assert result3["type"] == FlowResultType.FORM + assert result3["step_id"] == "auth_passphrase" + assert result3["errors"] == {"base": "invalid_auth"} + + +@pytest.mark.asyncio +async def test_passphrase_auth_recovery_after_error(hass: HomeAssistant) -> None: + """User can complete auth after an initial bad passphrase.""" + with ( + patch( + "custom_components.span_panel.config_flow.detect_api_version", + return_value=MOCK_V2_DETECTION, + ), + patch( + "custom_components.span_panel.config_flow.validate_host", + return_value=True, + ), + patch( + "custom_components.span_panel.config_flow.validate_v2_passphrase", + side_effect=[SpanPanelAuthError("bad"), MOCK_V2_AUTH], + ), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: MOCK_HOST}, + ) + + result2b = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {"next_step_id": "auth_passphrase"}, + ) + + # First attempt: bad passphrase + result3 = await hass.config_entries.flow.async_configure( + result2b["flow_id"], + {CONF_HOP_PASSPHRASE: "wrong"}, + ) + assert result3["errors"] == {"base": "invalid_auth"} + + # Second attempt: correct passphrase + result4 = await hass.config_entries.flow.async_configure( + result3["flow_id"], + {CONF_HOP_PASSPHRASE: MOCK_PASSPHRASE}, + ) + assert result4["type"] == FlowResultType.FORM + assert result4["step_id"] == "choose_entity_naming_initial" + + +# ---------- proximity auth ---------- + + +@pytest.mark.asyncio +async def test_proximity_auth_success(hass: HomeAssistant) -> None: + """Successful proximity auth should proceed to naming step.""" + with ( + patch( + "custom_components.span_panel.config_flow.detect_api_version", + side_effect=[MOCK_V2_DETECTION, MOCK_V2_DETECTION_PROXIMITY_PROVEN], + ), + patch( + "custom_components.span_panel.config_flow.validate_host", + return_value=True, + ), + patch( + "custom_components.span_panel.config_flow.validate_v2_proximity", + return_value=MOCK_V2_AUTH, + ), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: MOCK_HOST}, + ) + assert result2["step_id"] == "choose_v2_auth" + + result2b = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {"next_step_id": "auth_proximity"}, + ) + assert result2b["step_id"] == "auth_proximity" + + # User confirms they opened the door + result3 = await hass.config_entries.flow.async_configure( + result2b["flow_id"], + {"next_step_id": "auth_proximity_confirm"}, + ) + + assert result3["type"] == FlowResultType.FORM + assert result3["step_id"] == "choose_entity_naming_initial" + + +@pytest.mark.asyncio +async def test_proximity_not_proven_returns_to_menu(hass: HomeAssistant) -> None: + """Unproven proximity should return to the auth proximity menu.""" + with ( + patch( + "custom_components.span_panel.config_flow.detect_api_version", + side_effect=[MOCK_V2_DETECTION, MOCK_V2_DETECTION_PROXIMITY_NOT_PROVEN], + ), + patch( + "custom_components.span_panel.config_flow.validate_host", + return_value=True, + ), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: MOCK_HOST}, + ) + + result2b = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {"next_step_id": "auth_proximity"}, + ) + + # User claims they opened the door but proximityProven is false + result3 = await hass.config_entries.flow.async_configure( + result2b["flow_id"], + {"next_step_id": "auth_proximity_confirm"}, + ) + + # Should return to the proximity menu + assert result3["type"] == FlowResultType.MENU + assert result3["step_id"] == "auth_proximity" + + +@pytest.mark.asyncio +async def test_proximity_switch_to_passphrase(hass: HomeAssistant) -> None: + """User should be able to switch from proximity menu to passphrase.""" + with ( + patch( + "custom_components.span_panel.config_flow.detect_api_version", + return_value=MOCK_V2_DETECTION, + ), + patch( + "custom_components.span_panel.config_flow.validate_host", + return_value=True, + ), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: MOCK_HOST}, + ) + + result2b = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {"next_step_id": "auth_proximity"}, + ) + assert result2b["step_id"] == "auth_proximity" + + # User picks "Use passphrase instead" + result3 = await hass.config_entries.flow.async_configure( + result2b["flow_id"], + {"next_step_id": "auth_passphrase"}, + ) + + assert result3["type"] == FlowResultType.FORM + assert result3["step_id"] == "auth_passphrase" + + +# ---------- duplicate entry prevention ---------- + + +@pytest.mark.asyncio +async def test_duplicate_entry_aborts(hass: HomeAssistant) -> None: + """Setting up a panel that is already configured should abort.""" + existing = MockConfigEntry( + version=3, + minor_version=1, + domain=DOMAIN, + title="Span Panel", + data={ + CONF_HOST: MOCK_HOST, + CONF_ACCESS_TOKEN: "existing-token", + CONF_API_VERSION: "v2", + }, + source=config_entries.SOURCE_USER, + options={}, + unique_id="SPAN-V2-001", + ) + existing.add_to_hass(hass) + + with ( + patch( + "custom_components.span_panel.config_flow.detect_api_version", + return_value=MOCK_V2_DETECTION, + ), + patch( + "custom_components.span_panel.config_flow.validate_host", + return_value=True, + ), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: MOCK_HOST}, + ) + + assert result2["type"] == FlowResultType.ABORT + assert result2["reason"] == "already_configured" + + +# ---------- zeroconf edge cases ---------- + + +@pytest.mark.asyncio +async def test_zeroconf_non_ipv4_aborts(hass: HomeAssistant) -> None: + """Non-IPv4 discovery addresses should abort.""" + + discovery_info = ZeroconfServiceInfo( + ip_address=ipaddress.IPv6Address("fe80::1"), + ip_addresses=[ipaddress.IPv6Address("fe80::1")], + hostname="span-panel.local.", + name="SPAN Panel._ebus._tcp.local.", + port=8883, + properties={}, + type="_ebus._tcp.local.", + ) + + with patch( + "custom_components.span_panel.config_flow.is_ipv4_address", + return_value=False, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=discovery_info, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "not_ipv4_address" + + +@pytest.mark.asyncio +async def test_zeroconf_already_configured_aborts(hass: HomeAssistant) -> None: + """Zeroconf discovery of an already-configured host should abort.""" + existing = MockConfigEntry( + version=3, + minor_version=1, + domain=DOMAIN, + title="Span Panel", + data={ + CONF_HOST: "192.168.1.200", + CONF_ACCESS_TOKEN: "existing-token", + CONF_API_VERSION: "v2", + }, + source=config_entries.SOURCE_USER, + options={}, + unique_id="SPAN-V2-001", + ) + existing.add_to_hass(hass) + + discovery_info = ZeroconfServiceInfo( + ip_address=ipaddress.IPv4Address("192.168.1.200"), + ip_addresses=[ipaddress.IPv4Address("192.168.1.200")], + hostname="span-panel.local.", + name="SPAN Panel._ebus._tcp.local.", + port=8883, + properties={}, + type="_ebus._tcp.local.", + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=discovery_info, + ) + + assert result["type"] == FlowResultType.ABORT + + +@pytest.mark.asyncio +async def test_zeroconf_not_span_panel_aborts(hass: HomeAssistant) -> None: + """Zeroconf discovery where v2 endpoint does not respond should abort.""" + + # Detection returns v1 (not v2) — this IP is not a valid v2 panel + mock_bad_detection = DetectionResult( + api_version="v1", + status_info=None, + ) + + discovery_info = ZeroconfServiceInfo( + ip_address=ipaddress.IPv4Address("192.168.1.200"), + ip_addresses=[ipaddress.IPv4Address("192.168.1.200")], + hostname="span-panel.local.", + name="SPAN Panel._ebus._tcp.local.", + port=8883, + properties={}, + type="_ebus._tcp.local.", + ) + + with ( + patch( + "custom_components.span_panel.config_flow.detect_api_version", + return_value=mock_bad_detection, + ), + patch( + "custom_components.span_panel.config_flow.is_ipv4_address", + return_value=True, + ), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=discovery_info, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "not_span_panel" + + +@pytest.mark.usefixtures("socket_enabled") +@pytest.mark.asyncio +async def test_zeroconf_end_to_end_entry_creation(hass: HomeAssistant) -> None: + """Zeroconf discovery through confirm → passphrase → naming → entry creation.""" + + discovery_info = ZeroconfServiceInfo( + ip_address=ipaddress.IPv4Address("192.168.1.200"), + ip_addresses=[ipaddress.IPv4Address("192.168.1.200")], + hostname="span-panel.local.", + name="SPAN Panel._ebus._tcp.local.", + port=8883, + properties={}, + type="_ebus._tcp.local.", + ) + + with ( + patch( + "custom_components.span_panel.config_flow.detect_api_version", + return_value=MOCK_V2_DETECTION, + ), + patch( + "custom_components.span_panel.config_flow.is_ipv4_address", + return_value=True, + ), + patch( + "custom_components.span_panel.config_flow.validate_v2_passphrase", + return_value=MOCK_V2_AUTH, + ), + ): + # Step 1: zeroconf discovery → confirm + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=discovery_info, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "confirm_discovery" + + # Step 2: confirm → auth choice + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + assert result2["type"] == FlowResultType.MENU + assert result2["step_id"] == "choose_v2_auth" + + # Step 3: choose passphrase + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {"next_step_id": "auth_passphrase"}, + ) + assert result3["step_id"] == "auth_passphrase" + + # Step 4: enter passphrase + result4 = await hass.config_entries.flow.async_configure( + result3["flow_id"], + {CONF_HOP_PASSPHRASE: MOCK_PASSPHRASE}, + ) + assert result4["step_id"] == "choose_entity_naming_initial" + + # Step 5: accept naming default → entry created + result5 = await hass.config_entries.flow.async_configure( + result4["flow_id"], + {"entity_naming_pattern": "friendly_names"}, + ) + assert result5["type"] == FlowResultType.CREATE_ENTRY + assert result5["data"][CONF_API_VERSION] == "v2" + assert result5["data"][CONF_HOST] == "192.168.1.200" + + +# ---------- reauth: proximity ---------- + + +@pytest.mark.asyncio +async def test_reauth_v2_proximity_success(hass: HomeAssistant) -> None: + """Reauth via proximity should update credentials.""" + entry = MockConfigEntry( + version=3, + minor_version=1, + domain=DOMAIN, + title="Span Panel", + data={ + CONF_HOST: MOCK_HOST, + CONF_ACCESS_TOKEN: "old-token", + CONF_API_VERSION: "v2", + CONF_EBUS_BROKER_HOST: "old-host", + CONF_EBUS_BROKER_PORT: 8883, + CONF_EBUS_BROKER_USERNAME: "old-user", + CONF_EBUS_BROKER_PASSWORD: "old-pass", + }, + source=config_entries.SOURCE_USER, + options={}, + unique_id="SPAN-V2-001", + ) + entry.add_to_hass(hass) + + with ( + patch( + "custom_components.span_panel.config_flow.detect_api_version", + side_effect=[MOCK_V2_DETECTION, MOCK_V2_DETECTION_PROXIMITY_PROVEN], + ), + patch( + "custom_components.span_panel.config_flow.validate_v2_proximity", + return_value=MOCK_V2_AUTH, + ), + patch.object(hass.config_entries, "async_reload", return_value=True), + ): + result = await entry.start_reauth_flow(hass) + assert result["type"] == FlowResultType.MENU + assert result["step_id"] == "reauth_confirm" + + # Select proximity auth from the reauth menu + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "auth_proximity"}, + ) + assert result2["step_id"] == "auth_proximity" + + # User confirms door challenge + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {"next_step_id": "auth_proximity_confirm"}, + ) + + assert result3["type"] == FlowResultType.ABORT + assert result3["reason"] == "reauth_successful" + + assert entry.data[CONF_ACCESS_TOKEN] == "v2-token-abc" + assert entry.data[CONF_EBUS_BROKER_USERNAME] == "span-user" + + +# ---------- reconfigure ---------- + + +@pytest.mark.asyncio +async def test_reconfigure_shows_current_host(hass: HomeAssistant) -> None: + """Reconfigure step should pre-fill the current host.""" + entry = MockConfigEntry( + version=3, + minor_version=1, + domain=DOMAIN, + title="Span Panel", + data={ + CONF_HOST: MOCK_HOST, + CONF_ACCESS_TOKEN: "token", + CONF_API_VERSION: "v2", + }, + source=config_entries.SOURCE_USER, + options={}, + unique_id="SPAN-V2-001", + ) + entry.add_to_hass(hass) + + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + +@pytest.mark.asyncio +async def test_reconfigure_success(hass: HomeAssistant) -> None: + """Reconfigure should update the host and reload.""" + entry = MockConfigEntry( + version=3, + minor_version=1, + domain=DOMAIN, + title="Span Panel", + data={ + CONF_HOST: MOCK_HOST, + CONF_ACCESS_TOKEN: "token", + CONF_API_VERSION: "v2", + }, + source=config_entries.SOURCE_USER, + options={}, + unique_id="SPAN-V2-001", + ) + entry.add_to_hass(hass) + + new_host = "192.168.1.200" + + with patch( + "custom_components.span_panel.config_flow.detect_api_version", + return_value=MOCK_V2_DETECTION, + ): + result = await entry.start_reconfigure_flow(hass) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: new_host}, + ) + + assert result2["type"] == FlowResultType.ABORT + assert result2["reason"] == "reconfigure_successful" + + assert entry.data[CONF_HOST] == new_host + # Other data should be preserved + assert entry.data[CONF_ACCESS_TOKEN] == "token" + assert entry.data[CONF_API_VERSION] == "v2" + + +@pytest.mark.asyncio +async def test_reconfigure_unreachable_host(hass: HomeAssistant) -> None: + """Reconfigure with unreachable host should show cannot_connect error.""" + entry = MockConfigEntry( + version=3, + minor_version=1, + domain=DOMAIN, + title="Span Panel", + data={ + CONF_HOST: MOCK_HOST, + CONF_ACCESS_TOKEN: "token", + CONF_API_VERSION: "v2", + }, + source=config_entries.SOURCE_USER, + options={}, + unique_id="SPAN-V2-001", + ) + entry.add_to_hass(hass) + + with patch( + "custom_components.span_panel.config_flow.detect_api_version", + side_effect=SpanPanelConnectionError("timeout"), + ): + result = await entry.start_reconfigure_flow(hass) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "10.0.0.99"}, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["step_id"] == "reconfigure" + assert result2["errors"] == {"base": "cannot_connect"} + + +@pytest.mark.asyncio +async def test_reconfigure_different_panel_aborts(hass: HomeAssistant) -> None: + """Reconfigure to a different panel serial should abort with unique_id_mismatch.""" + entry = MockConfigEntry( + version=3, + minor_version=1, + domain=DOMAIN, + title="Span Panel", + data={ + CONF_HOST: MOCK_HOST, + CONF_ACCESS_TOKEN: "token", + CONF_API_VERSION: "v2", + }, + source=config_entries.SOURCE_USER, + options={}, + unique_id="SPAN-V2-001", + ) + entry.add_to_hass(hass) + + with patch( + "custom_components.span_panel.config_flow.detect_api_version", + return_value=MOCK_V2_DETECTION_OTHER, + ): + result = await entry.start_reconfigure_flow(hass) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "192.168.1.250"}, + ) + + assert result2["type"] == FlowResultType.ABORT + assert result2["reason"] == "unique_id_mismatch" + + +@pytest.mark.asyncio +async def test_reconfigure_empty_host(hass: HomeAssistant) -> None: + """Reconfigure with empty host should re-show with host_required error.""" + entry = MockConfigEntry( + version=3, + minor_version=1, + domain=DOMAIN, + title="Span Panel", + data={ + CONF_HOST: MOCK_HOST, + CONF_ACCESS_TOKEN: "token", + CONF_API_VERSION: "v2", + }, + source=config_entries.SOURCE_USER, + options={}, + unique_id="SPAN-V2-001", + ) + entry.add_to_hass(hass) + + result = await entry.start_reconfigure_flow(hass) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: " "}, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["step_id"] == "reconfigure" + assert result2["errors"] == {"base": "host_required"} + + +@pytest.mark.asyncio +async def test_reconfigure_recovery_after_error(hass: HomeAssistant) -> None: + """User can successfully reconfigure after an initial connection error.""" + entry = MockConfigEntry( + version=3, + minor_version=1, + domain=DOMAIN, + title="Span Panel", + data={ + CONF_HOST: MOCK_HOST, + CONF_ACCESS_TOKEN: "token", + CONF_API_VERSION: "v2", + }, + source=config_entries.SOURCE_USER, + options={}, + unique_id="SPAN-V2-001", + ) + entry.add_to_hass(hass) + + with patch( + "custom_components.span_panel.config_flow.detect_api_version", + side_effect=[SpanPanelConnectionError("timeout"), MOCK_V2_DETECTION], + ): + result = await entry.start_reconfigure_flow(hass) + + # First attempt: connection error + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "10.0.0.99"}, + ) + assert result2["errors"] == {"base": "cannot_connect"} + + # Second attempt: success + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {CONF_HOST: "192.168.1.200"}, + ) + assert result3["type"] == FlowResultType.ABORT + assert result3["reason"] == "reconfigure_successful" + + assert entry.data[CONF_HOST] == "192.168.1.200" + + +# ---------- hassio (Supervisor) discovery ---------- + + +MOCK_HASSIO_CONFIG = { + "host": "192.168.1.50", + "port": 9090, + "serial": "SPAN-SIM-001", +} + +MOCK_V2_DETECTION_SIM = DetectionResult( + api_version="v2", + status_info=V2StatusInfo( + serial_number="SPAN-SIM-001", + firmware_version="2.0.0", + ), +) + + +def _hassio_service_info(config: dict[str, str | int]) -> HassioServiceInfo: + """Build a HassioServiceInfo for testing.""" + return HassioServiceInfo( + config=config, + name="SPAN Panel Simulator", + slug="span_panel_simulator", + uuid="test-uuid-1234", + ) + + +@pytest.mark.asyncio +async def test_hassio_missing_host_aborts(hass: HomeAssistant) -> None: + """Hassio discovery with no host should abort with no_host.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_HASSIO}, + data=_hassio_service_info({"port": 9090, "serial": "SPAN-SIM-001"}), + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "no_host" + + +@pytest.mark.asyncio +async def test_hassio_missing_host_empty_string_aborts(hass: HomeAssistant) -> None: + """Hassio discovery with empty host string should abort with no_host.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_HASSIO}, + data=_hassio_service_info({"host": "", "port": 9090, "serial": "SPAN-SIM-001"}), + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "no_host" + + +@pytest.mark.asyncio +async def test_hassio_not_v2_aborts(hass: HomeAssistant) -> None: + """Hassio discovery of a non-v2 panel should abort with not_span_panel.""" + with patch( + "custom_components.span_panel.config_flow.detect_api_version", + return_value=MOCK_V1_DETECTION, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_HASSIO}, + data=_hassio_service_info(MOCK_HASSIO_CONFIG), + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "not_span_panel" + + +@pytest.mark.asyncio +async def test_hassio_no_serial_aborts(hass: HomeAssistant) -> None: + """Hassio discovery where panel returns no serial should abort.""" + detection_no_serial = DetectionResult( + api_version="v2", + status_info=V2StatusInfo( + serial_number="", + firmware_version="2.0.0", + ), + ) + config_no_serial = {"host": "192.168.1.50", "port": 9090, "serial": ""} + + with patch( + "custom_components.span_panel.config_flow.detect_api_version", + return_value=detection_no_serial, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_HASSIO}, + data=_hassio_service_info(config_no_serial), + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "no_serial" + + +@pytest.mark.asyncio +async def test_hassio_discovery_routes_to_confirm(hass: HomeAssistant) -> None: + """Successful hassio discovery should route to confirm_discovery.""" + with patch( + "custom_components.span_panel.config_flow.detect_api_version", + return_value=MOCK_V2_DETECTION_SIM, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_HASSIO}, + data=_hassio_service_info(MOCK_HASSIO_CONFIG), + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "confirm_discovery" + + +@pytest.mark.asyncio +async def test_hassio_dedup_by_serial(hass: HomeAssistant) -> None: + """Hassio discovery of an already-configured serial should abort and update host/port.""" + existing = MockConfigEntry( + version=3, + minor_version=1, + domain=DOMAIN, + title="Span Panel", + data={ + CONF_HOST: "192.168.1.40", + CONF_ACCESS_TOKEN: "existing-token", + CONF_API_VERSION: "v2", + CONF_HTTP_PORT: 80, + }, + source=config_entries.SOURCE_USER, + options={}, + unique_id="SPAN-SIM-001", + ) + existing.add_to_hass(hass) + + with patch( + "custom_components.span_panel.config_flow.detect_api_version", + return_value=MOCK_V2_DETECTION_SIM, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_HASSIO}, + data=_hassio_service_info(MOCK_HASSIO_CONFIG), + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + # Host and port should be updated to the new values + assert existing.data[CONF_HOST] == "192.168.1.50" + assert existing.data[CONF_HTTP_PORT] == 9090 + + +@pytest.mark.usefixtures("socket_enabled") +@pytest.mark.asyncio +async def test_hassio_end_to_end_entry_creation(hass: HomeAssistant) -> None: + """Hassio discovery through confirm -> passphrase -> naming -> entry creation.""" + with ( + patch( + "custom_components.span_panel.config_flow.detect_api_version", + return_value=MOCK_V2_DETECTION_SIM, + ), + patch( + "custom_components.span_panel.config_flow.validate_v2_passphrase", + return_value=MOCK_V2_AUTH, + ), + ): + # Step 1: hassio discovery -> confirm + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_HASSIO}, + data=_hassio_service_info(MOCK_HASSIO_CONFIG), + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "confirm_discovery" + + # Step 2: confirm -> auth choice + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + assert result2["type"] == FlowResultType.MENU + assert result2["step_id"] == "choose_v2_auth" + + # Step 3: choose passphrase + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {"next_step_id": "auth_passphrase"}, + ) + assert result3["step_id"] == "auth_passphrase" + + # Step 4: enter passphrase + result4 = await hass.config_entries.flow.async_configure( + result3["flow_id"], + {CONF_HOP_PASSPHRASE: MOCK_PASSPHRASE}, + ) + assert result4["step_id"] == "choose_entity_naming_initial" + + # Step 5: accept naming default -> entry created + result5 = await hass.config_entries.flow.async_configure( + result4["flow_id"], + {"entity_naming_pattern": "friendly_names"}, + ) + assert result5["type"] == FlowResultType.CREATE_ENTRY + assert result5["data"][CONF_API_VERSION] == "v2" + assert result5["data"][CONF_HOST] == "192.168.1.50" + assert result5["data"][CONF_HTTP_PORT] == 9090 + + +# ---------- user flow: null status_info ---------- + + +@pytest.mark.asyncio +async def test_user_flow_v2_null_status_info_shows_error(hass: HomeAssistant) -> None: + """User flow should show cannot_connect when v2 detection has null status_info.""" + detection_no_status = DetectionResult( + api_version="v2", + status_info=None, + ) + + with ( + patch( + "custom_components.span_panel.config_flow.detect_api_version", + return_value=detection_no_status, + ), + patch( + "custom_components.span_panel.config_flow.validate_host", + return_value=True, + ), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: MOCK_HOST}, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["step_id"] == "user" + assert result2["errors"] == {"base": "cannot_connect"} + + +@pytest.mark.asyncio +async def test_reauth_v2_null_status_info_aborts(hass: HomeAssistant) -> None: + """Reauth should abort with cannot_connect when v2 detection has null status_info.""" + entry = MockConfigEntry( + version=3, + minor_version=1, + domain=DOMAIN, + title="Span Panel", + data={ + CONF_HOST: MOCK_HOST, + CONF_ACCESS_TOKEN: "old-token", + CONF_API_VERSION: "v2", + }, + source=config_entries.SOURCE_USER, + options={}, + unique_id="SPAN-V2-001", + ) + entry.add_to_hass(hass) + + detection_no_status = DetectionResult( + api_version="v2", + status_info=None, + ) + + with patch( + "custom_components.span_panel.config_flow.detect_api_version", + return_value=detection_no_status, + ): + result = await entry.start_reauth_flow(hass) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +@pytest.mark.asyncio +async def test_zeroconf_invalid_http_port_defaults_to_80(hass: HomeAssistant) -> None: + """Invalid httpPort TXT records should fall back to port 80.""" + + discovery_info = ZeroconfServiceInfo( + ip_address=ipaddress.IPv4Address("192.168.1.200"), + ip_addresses=[ipaddress.IPv4Address("192.168.1.200")], + hostname="span-panel.local.", + name="SPAN Panel._ebus._tcp.local.", + port=8883, + properties={"httpPort": "bad-port"}, + type="_ebus._tcp.local.", + ) + + fake_client = MagicMock() + with ( + patch( + "custom_components.span_panel.config_flow.get_async_client", + return_value=fake_client, + ), + patch( + "custom_components.span_panel.config_flow.detect_api_version", + return_value=MOCK_V2_DETECTION, + ) as mock_detect, + patch( + "custom_components.span_panel.config_flow.is_ipv4_address", + return_value=True, + ), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=discovery_info, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "confirm_discovery" + mock_detect.assert_awaited_once_with( + "192.168.1.200", port=80, httpx_client=fake_client + ) + + +@pytest.mark.usefixtures("socket_enabled") +@pytest.mark.asyncio +async def test_user_flow_fqdn_registration_progress_then_naming( + hass: HomeAssistant, +) -> None: + """FQDN hosts should route through the registration progress step.""" + with ( + patch( + "custom_components.span_panel.config_flow.detect_api_version", + return_value=MOCK_V2_DETECTION, + ), + patch( + "custom_components.span_panel.config_flow.validate_host", + return_value=True, + ), + patch( + "custom_components.span_panel.config_flow.validate_v2_passphrase", + return_value=MOCK_V2_AUTH, + ), + patch( + "custom_components.span_panel.config_flow.register_fqdn", + new=AsyncMock(), + ), + patch( + "custom_components.span_panel.config_flow.check_fqdn_tls_ready", + new=AsyncMock(return_value=True), + ), + patch( + "custom_components.span_panel.config_flow.asyncio.sleep", + new=AsyncMock(), + ), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "panel.example.com"}, + ) + result2b = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {"next_step_id": "auth_passphrase"}, + ) + result3 = await hass.config_entries.flow.async_configure( + result2b["flow_id"], + {CONF_HOP_PASSPHRASE: MOCK_PASSPHRASE}, + ) + assert result3["type"] == FlowResultType.CREATE_ENTRY + assert result3["data"][CONF_REGISTERED_FQDN] == "panel.example.com" + + +@pytest.mark.usefixtures("socket_enabled") +@pytest.mark.asyncio +async def test_user_flow_fqdn_registration_failure_can_continue( + hass: HomeAssistant, +) -> None: + """Failed FQDN registration should allow continuing without registration.""" + with ( + patch( + "custom_components.span_panel.config_flow.detect_api_version", + return_value=MOCK_V2_DETECTION, + ), + patch( + "custom_components.span_panel.config_flow.validate_host", + return_value=True, + ), + patch( + "custom_components.span_panel.config_flow.validate_v2_passphrase", + return_value=MOCK_V2_AUTH, + ), + patch( + "custom_components.span_panel.config_flow.register_fqdn", + new=AsyncMock(), + ), + patch( + "custom_components.span_panel.config_flow.check_fqdn_tls_ready", + new=AsyncMock(return_value=False), + ), + patch( + "custom_components.span_panel.config_flow.asyncio.sleep", + new=AsyncMock(), + ), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "panel.example.com"}, + ) + result2b = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {"next_step_id": "auth_passphrase"}, + ) + result3 = await hass.config_entries.flow.async_configure( + result2b["flow_id"], + {CONF_HOP_PASSPHRASE: MOCK_PASSPHRASE}, + ) + assert result3["type"] == FlowResultType.FORM + assert result3["step_id"] == "choose_entity_naming_initial" + + +@pytest.mark.usefixtures("socket_enabled") +@pytest.mark.asyncio +async def test_fqdn_entry_creation_sets_registered_fqdn_and_unique_title( + hass: HomeAssistant, +) -> None: + """FQDN-based entries should store the registered host and keep unique titles.""" + existing = MockConfigEntry( + domain=DOMAIN, + title="Span Panel", + data={CONF_HOST: "192.168.1.10", CONF_ACCESS_TOKEN: "existing-token"}, + unique_id="EXISTING-PANEL-001", + ) + existing.add_to_hass(hass) + + with ( + patch( + "custom_components.span_panel.config_flow.detect_api_version", + return_value=MOCK_V2_DETECTION, + ), + patch( + "custom_components.span_panel.config_flow.validate_host", + return_value=True, + ), + patch( + "custom_components.span_panel.config_flow.validate_v2_passphrase", + return_value=MOCK_V2_AUTH, + ), + patch( + "custom_components.span_panel.config_flow.register_fqdn", + new=AsyncMock(), + ), + patch( + "custom_components.span_panel.config_flow.check_fqdn_tls_ready", + new=AsyncMock(return_value=True), + ), + patch( + "custom_components.span_panel.config_flow.asyncio.sleep", + new=AsyncMock(), + ), + patch( + "custom_components.span_panel.async_setup_entry", + new=AsyncMock(return_value=True), + ), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "panel.example.com", CONF_HTTP_PORT: 8080}, + ) + result2b = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {"next_step_id": "auth_passphrase"}, + ) + result3 = await hass.config_entries.flow.async_configure( + result2b["flow_id"], + {CONF_HOP_PASSPHRASE: MOCK_PASSPHRASE}, + ) + assert result3["type"] == FlowResultType.CREATE_ENTRY + assert result3["title"] == "Span Panel 2" + assert result3["data"][CONF_HOST] == "panel.example.com" + assert result3["data"][CONF_REGISTERED_FQDN] == "panel.example.com" + assert result3["data"][CONF_HTTP_PORT] == 8080 + + +@pytest.mark.asyncio +async def test_update_v2_entry_missing_entry_aborts_with_reauth_failed( + hass: HomeAssistant, +) -> None: + """Missing entries during reauth should abort cleanly.""" + flow = SpanPanelConfigFlow() + flow.hass = hass + flow.trigger_flow_type = TriggerFlowType.UPDATE_ENTRY + flow.context = {"entry_id": "missing-entry"} + flow.host = MOCK_HOST + flow.serial_number = "SPAN-V2-001" + flow.access_token = MOCK_V2_AUTH.access_token + flow._is_flow_setup = True + flow._store_v2_auth_result(MOCK_V2_AUTH, MOCK_PASSPHRASE) + + result = await flow._async_finalize_v2_auth() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "reauth_failed" + + +@pytest.mark.asyncio +async def test_reconfigure_to_fqdn_registers_and_updates_registered_fqdn( + hass: HomeAssistant, +) -> None: + """Reconfiguring to an FQDN should go through registration and persist it.""" + entry = MockConfigEntry( + version=3, + minor_version=1, + domain=DOMAIN, + title="Span Panel", + data={ + CONF_HOST: MOCK_HOST, + CONF_ACCESS_TOKEN: "token", + CONF_API_VERSION: "v2", + CONF_EBUS_BROKER_PORT: 8883, + }, + source=config_entries.SOURCE_USER, + options={}, + unique_id="SPAN-V2-001", + ) + entry.add_to_hass(hass) + + with ( + patch( + "custom_components.span_panel.config_flow.detect_api_version", + return_value=MOCK_V2_DETECTION, + ), + patch( + "custom_components.span_panel.config_flow.register_fqdn", + new=AsyncMock(), + ), + patch( + "custom_components.span_panel.config_flow.check_fqdn_tls_ready", + new=AsyncMock(return_value=True), + ), + patch( + "custom_components.span_panel.config_flow.asyncio.sleep", + new=AsyncMock(), + ), + ): + result = await entry.start_reconfigure_flow(hass) + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "panel.example.com"}, + ) + result3 = result2 + + assert result3["type"] == FlowResultType.ABORT + assert result3["reason"] == "reconfigure_successful" + assert entry.data[CONF_HOST] == "panel.example.com" + assert entry.data[CONF_REGISTERED_FQDN] == "panel.example.com" + + +@pytest.mark.asyncio +async def test_reconfigure_fqdn_failure_can_continue_without_registration( + hass: HomeAssistant, +) -> None: + """Failed FQDN registration during reconfigure should allow continue.""" + entry = MockConfigEntry( + version=3, + minor_version=1, + domain=DOMAIN, + title="Span Panel", + data={ + CONF_HOST: MOCK_HOST, + CONF_ACCESS_TOKEN: "token", + CONF_API_VERSION: "v2", + CONF_EBUS_BROKER_PORT: 8883, + }, + source=config_entries.SOURCE_USER, + options={}, + unique_id="SPAN-V2-001", + ) + entry.add_to_hass(hass) + + with ( + patch( + "custom_components.span_panel.config_flow.detect_api_version", + return_value=MOCK_V2_DETECTION, + ), + patch( + "custom_components.span_panel.config_flow.register_fqdn", + new=AsyncMock(), + ), + patch( + "custom_components.span_panel.config_flow.check_fqdn_tls_ready", + new=AsyncMock(return_value=False), + ), + patch( + "custom_components.span_panel.config_flow.asyncio.sleep", + new=AsyncMock(), + ), + ): + result = await entry.start_reconfigure_flow(hass) + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "panel.example.com"}, + ) + result4 = result2 + + assert result4["type"] == FlowResultType.ABORT + assert result4["reason"] == "reconfigure_successful" + assert entry.data[CONF_HOST] == "panel.example.com" + assert CONF_REGISTERED_FQDN not in entry.data + + +@pytest.mark.asyncio +async def test_reconfigure_switch_from_fqdn_to_ip_clears_registration( + hass: HomeAssistant, +) -> None: + """Switching from FQDN back to IP should delete the old registration.""" + entry = MockConfigEntry( + version=3, + minor_version=1, + domain=DOMAIN, + title="Span Panel", + data={ + CONF_HOST: "panel.example.com", + CONF_REGISTERED_FQDN: "panel.example.com", + CONF_ACCESS_TOKEN: "token", + CONF_API_VERSION: "v2", + }, + source=config_entries.SOURCE_USER, + options={}, + unique_id="SPAN-V2-001", + ) + entry.add_to_hass(hass) + + fake_client = MagicMock() + with ( + patch( + "custom_components.span_panel.config_flow.get_async_client", + return_value=fake_client, + ), + patch( + "custom_components.span_panel.config_flow.detect_api_version", + return_value=MOCK_V2_DETECTION, + ), + patch( + "custom_components.span_panel.config_flow.delete_fqdn", + new=AsyncMock(), + ) as mock_delete, + ): + result = await entry.start_reconfigure_flow(hass) + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "192.168.1.201"}, + ) + + assert result2["type"] == FlowResultType.ABORT + assert result2["reason"] == "reconfigure_successful" + mock_delete.assert_awaited_once_with( + "192.168.1.201", "token", port=80, httpx_client=fake_client + ) + assert entry.data[CONF_HOST] == "192.168.1.201" + assert entry.data[CONF_REGISTERED_FQDN] == "" diff --git a/tests/test_websocket.py b/tests/test_websocket.py new file mode 100644 index 00000000..ea45eb5a --- /dev/null +++ b/tests/test_websocket.py @@ -0,0 +1,821 @@ +"""Tests for the span_panel/panel_topology WebSocket command.""" + +from __future__ import annotations + +import inspect +from unittest.mock import MagicMock + +import pytest + +from custom_components.span_panel import SpanPanelRuntimeData +from custom_components.span_panel.const import DOMAIN +from custom_components.span_panel.websocket import ( + _build_circuit_entity_map, + _classify_sensor_role, + _classify_sub_device, + _find_config_entry_id, + async_register_commands, + handle_panel_topology, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from .factories import ( + SpanBatterySnapshotFactory, + SpanCircuitSnapshotFactory, + SpanEvseSnapshotFactory, + SpanPanelSnapshotFactory, +) + +from pytest_homeassistant_custom_component.common import MockConfigEntry, MockUser +from pytest_homeassistant_custom_component.typing import WebSocketGenerator + +# The command stack includes wrappers such as @async_response and +# @require_admin. Unwrap until we reach the original async handler so the +# direct-call tests can await it. +_handle_panel_topology_inner = handle_panel_topology +while not inspect.iscoroutinefunction(_handle_panel_topology_inner): + _handle_panel_topology_inner = _handle_panel_topology_inner.__wrapped__ + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_mock_connection() -> MagicMock: + """Create a mock WebSocket connection.""" + connection = MagicMock() + connection.send_result = MagicMock() + connection.send_error = MagicMock() + return connection + + +def _make_coordinator(snapshot): + """Create a mock coordinator wrapping a snapshot.""" + coordinator = MagicMock() + coordinator.data = snapshot + return coordinator + + +def _register_panel_device( + hass: HomeAssistant, + config_entry_id: str, + serial: str = "sp3-242424-001", +) -> dr.DeviceEntry: + """Register a SPAN panel device in the device registry.""" + device_registry = dr.async_get(hass) + return device_registry.async_get_or_create( + config_entry_id=config_entry_id, + identifiers={(DOMAIN, serial)}, + manufacturer="Span", + model="SPAN Panel", + name="SPAN Panel", + ) + + +def _register_sub_device( + hass: HomeAssistant, + config_entry_id: str, + identifier: str, + name: str, + via_device_id: str, +) -> dr.DeviceEntry: + """Register a sub-device linked to a parent panel.""" + device_registry = dr.async_get(hass) + dev = device_registry.async_get_or_create( + config_entry_id=config_entry_id, + identifiers={(DOMAIN, identifier)}, + name=name, + ) + device_registry.async_update_device(dev.id, via_device_id=via_device_id) + updated = device_registry.async_get(dev.id) + assert updated is not None + return updated + + +def _register_entity( + hass: HomeAssistant, + config_entry_id: str, + device_id: str, + domain: str, + unique_id: str, + entity_id: str, + original_name: str | None = None, +) -> er.RegistryEntry: + """Register an entity in the entity registry.""" + config_entry = hass.config_entries.async_get_entry(config_entry_id) + entity_registry = er.async_get(hass) + return entity_registry.async_get_or_create( + domain, + DOMAIN, + unique_id, + config_entry=config_entry, + device_id=device_id, + original_name=original_name, + suggested_object_id=entity_id.split(".", 1)[1] + if "." in entity_id + else entity_id, + ) + + +# --------------------------------------------------------------------------- +# Unit tests for helper functions +# --------------------------------------------------------------------------- + + +class TestClassifySensorRole: + """Tests for _classify_sensor_role.""" + + def test_power(self): + """Recognize circuit power sensor suffixes.""" + assert _classify_sensor_role("span_panel_kitchen_power") == "power" + + def test_produced_energy(self): + """Recognize produced energy sensor suffixes.""" + assert ( + _classify_sensor_role("span_panel_kitchen_energy_produced") + == "produced_energy" + ) + + def test_consumed_energy(self): + """Recognize consumed energy sensor suffixes.""" + assert ( + _classify_sensor_role("span_panel_kitchen_energy_consumed") + == "consumed_energy" + ) + + def test_net_energy(self): + """Recognize net energy sensor suffixes.""" + assert _classify_sensor_role("span_panel_kitchen_energy_net") == "net_energy" + + def test_current(self): + """Recognize circuit current sensor suffixes.""" + assert _classify_sensor_role("span_panel_kitchen_current") == "current" + + def test_breaker_rating(self): + """Recognize breaker rating sensor suffixes.""" + assert ( + _classify_sensor_role("span_panel_kitchen_breaker_rating") + == "breaker_rating" + ) + + def test_unrecognized(self): + """Return None for unsupported sensor suffixes.""" + assert _classify_sensor_role("span_panel_kitchen_somethingElse") is None + + def test_empty_string(self): + """Return None for empty unique ids.""" + assert _classify_sensor_role("") is None + + +class TestClassifySubDevice: + """Tests for _classify_sub_device.""" + + def test_bess(self): + """Classify battery sub-devices from their identifiers.""" + device = MagicMock() + device.identifiers = {(DOMAIN, "sp3-242424-001_bess")} + assert _classify_sub_device(device) == "bess" + + def test_evse(self): + """Classify EVSE sub-devices from their identifiers.""" + device = MagicMock() + device.identifiers = {(DOMAIN, "sp3-242424-001_evse_0")} + assert _classify_sub_device(device) == "evse" + + def test_unknown(self): + """Treat the panel device itself as an unknown sub-device type.""" + device = MagicMock() + device.identifiers = {(DOMAIN, "sp3-242424-001")} + assert _classify_sub_device(device) == "unknown" + + +class TestFindConfigEntryId: + """Tests for _find_config_entry_id.""" + + def test_finds_span_entry(self): + """Return the config entry id for SPAN panel devices.""" + device = MagicMock() + device.identifiers = {(DOMAIN, "sp3-242424-001")} + device.config_entries = {"entry_123"} + assert _find_config_entry_id(device) == "entry_123" + + def test_non_span_device(self): + """Ignore devices that do not belong to the SPAN domain.""" + device = MagicMock() + device.identifiers = {("other_domain", "some_id")} + device.config_entries = {"entry_123"} + assert _find_config_entry_id(device) is None + + def test_no_config_entries(self): + """Return None when a SPAN device has no linked config entries.""" + device = MagicMock() + device.identifiers = {(DOMAIN, "sp3-242424-001")} + device.config_entries = set() + assert _find_config_entry_id(device) is None + + +class TestBuildCircuitEntityMap: + """Tests for _build_circuit_entity_map.""" + + def _make_entity( + self, domain: str, unique_id: str | None, entity_id: str + ) -> MagicMock: + ent = MagicMock() + ent.domain = domain + ent.unique_id = unique_id + ent.entity_id = entity_id + return ent + + def test_skips_none_unique_id(self): + """Skip entities whose unique_id is None.""" + entities = [ + self._make_entity("sensor", None, "sensor.orphan"), + ] + result = _build_circuit_entity_map({"circuit1"}, entities) + assert result == {} + + def test_no_substring_collision(self): + """Circuit id '1' must not match unique_id containing '15'.""" + entities = [ + self._make_entity("sensor", "panel_15_power", "sensor.circuit15_power"), + ] + result = _build_circuit_entity_map({"1"}, entities) + assert "1" not in result + + def test_maps_power_sensor(self): + """Map a circuit power sensor onto its circuit entry.""" + entities = [ + self._make_entity("sensor", "panel_circuit1_power", "sensor.kitchen_power"), + ] + result = _build_circuit_entity_map({"circuit1"}, entities) + assert result["circuit1"]["power"] == "sensor.kitchen_power" + + def test_maps_switch_and_select(self): + """Map switch and select entities onto the same circuit entry.""" + entities = [ + self._make_entity( + "switch", "panel_circuit1_relay", "switch.kitchen_breaker" + ), + self._make_entity( + "select", "panel_circuit1_priority", "select.kitchen_priority" + ), + ] + result = _build_circuit_entity_map({"circuit1"}, entities) + assert result["circuit1"]["switch"] == "switch.kitchen_breaker" + assert result["circuit1"]["select"] == "select.kitchen_priority" + + def test_ignores_non_matching_entities(self): + """Ignore entities that do not match the requested circuit ids.""" + entities = [ + self._make_entity( + "sensor", "panel_circuit2_instantPowerW", "sensor.bedroom_power" + ), + ] + result = _build_circuit_entity_map({"circuit1"}, entities) + assert "circuit1" not in result + + def test_multiple_circuits(self): + """Map entities for multiple circuits independently.""" + entities = [ + self._make_entity("sensor", "panel_c1_power", "sensor.c1_power"), + self._make_entity("sensor", "panel_c2_power", "sensor.c2_power"), + ] + result = _build_circuit_entity_map({"c1", "c2"}, entities) + assert result["c1"]["power"] == "sensor.c1_power" + assert result["c2"]["power"] == "sensor.c2_power" + + def test_empty_entities(self): + """Return an empty mapping when no entities are present.""" + result = _build_circuit_entity_map({"circuit1"}, []) + assert result == {} + + def test_sensor_with_unknown_suffix_skipped(self): + """Skip sensors whose suffix does not map to a supported role.""" + entities = [ + self._make_entity( + "sensor", "panel_circuit1_unknownSuffix", "sensor.kitchen_unknown" + ), + ] + result = _build_circuit_entity_map({"circuit1"}, entities) + assert result.get("circuit1", {}) == {} + + def test_all_sensor_roles(self): + """Map every supported circuit sensor role into the topology payload.""" + entities = [ + self._make_entity("sensor", "panel_c1_power", "sensor.c1_power"), + self._make_entity( + "sensor", "panel_c1_energy_produced", "sensor.c1_produced" + ), + self._make_entity( + "sensor", "panel_c1_energy_consumed", "sensor.c1_consumed" + ), + self._make_entity("sensor", "panel_c1_energy_net", "sensor.c1_net"), + self._make_entity("sensor", "panel_c1_current", "sensor.c1_current"), + self._make_entity("sensor", "panel_c1_breaker_rating", "sensor.c1_breaker"), + ] + result = _build_circuit_entity_map({"c1"}, entities) + assert len(result["c1"]) == 6 + + +# --------------------------------------------------------------------------- +# Integration tests for handle_panel_topology +# --------------------------------------------------------------------------- + + +class TestHandlePanelTopology: + """Tests for the panel_topology WebSocket command handler.""" + + @pytest.mark.asyncio + async def test_requires_admin( + self, + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + hass_admin_user: MockUser, + ) -> None: + """Reject non-admin users before resolving topology data.""" + hass_admin_user.groups = [] + async_register_commands(hass) + websocket_client = await hass_ws_client(hass) + + await websocket_client.send_json_auto_id( + {"type": "span_panel/panel_topology", "device_id": "any-device-id"} + ) + + msg = await websocket_client.receive_json() + assert not msg["success"] + assert msg["error"]["code"] == "unauthorized" + + @pytest.mark.asyncio + async def test_device_not_found(self, hass: HomeAssistant): + """Error when device_id doesn't exist.""" + connection = _make_mock_connection() + msg = {"id": 1, "type": "span_panel/panel_topology", "device_id": "nonexistent"} + + await _handle_panel_topology_inner(hass, connection, msg) + + connection.send_error.assert_called_once_with( + 1, "device_not_found", "Device not found" + ) + + @pytest.mark.asyncio + async def test_non_span_device(self, hass: HomeAssistant): + """Error when device exists but isn't a SPAN panel.""" + entry = MockConfigEntry(domain="other_domain", data={}, entry_id="other_entry") + entry.add_to_hass(hass) + + device_registry = dr.async_get(hass) + device = device_registry.async_get_or_create( + config_entry_id="other_entry", + identifiers={("other_domain", "other_serial")}, + ) + + connection = _make_mock_connection() + msg = {"id": 1, "type": "span_panel/panel_topology", "device_id": device.id} + + await _handle_panel_topology_inner(hass, connection, msg) + + connection.send_error.assert_called_once_with( + 1, "not_span_panel", "Device is not a SPAN Panel device" + ) + + @pytest.mark.asyncio + async def test_sub_device_id_rejected(self, hass: HomeAssistant): + """Error when device_id is a BESS/EVSE sub-device, not the panel.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={}, + entry_id="span_entry", + unique_id="sp3-subdev-001", + ) + entry.add_to_hass(hass) + entry.mock_state(hass, ConfigEntryState.LOADED) + entry.runtime_data = SpanPanelRuntimeData( + coordinator=_make_coordinator(SpanPanelSnapshotFactory.create()) + ) + + panel_device = _register_panel_device( + hass, "span_entry", serial="sp3-subdev-001" + ) + bess_device = _register_sub_device( + hass, + "span_entry", + "sp3-subdev-001_bess", + "SPAN Panel Battery", + panel_device.id, + ) + + connection = _make_mock_connection() + msg = { + "id": 1, + "type": "span_panel/panel_topology", + "device_id": bess_device.id, + } + + await _handle_panel_topology_inner(hass, connection, msg) + + connection.send_error.assert_called_once_with( + 1, + "not_panel_device", + "Use the SPAN panel device registry ID, not a BESS or EVSE sub-device.", + ) + + @pytest.mark.asyncio + async def test_entry_not_loaded(self, hass: HomeAssistant): + """Error when config entry exists but is not loaded.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={}, + entry_id="span_entry", + unique_id="sp3-242424-001", + ) + entry.add_to_hass(hass) + # State defaults to NOT_LOADED + + device = _register_panel_device(hass, "span_entry") + + connection = _make_mock_connection() + msg = {"id": 1, "type": "span_panel/panel_topology", "device_id": device.id} + + await _handle_panel_topology_inner(hass, connection, msg) + + connection.send_error.assert_called_once_with( + 1, "not_loaded", "SPAN Panel integration is not loaded" + ) + + @pytest.mark.asyncio + async def test_successful_topology_basic(self, hass: HomeAssistant): + """Successful topology with basic circuits.""" + kitchen = SpanCircuitSnapshotFactory.create( + circuit_id="uuid_kitchen", + name="Kitchen", + tabs=[5, 6], + relay_state="CLOSED", + is_user_controllable=True, + breaker_rating_a=30, + ) + bedroom = SpanCircuitSnapshotFactory.create( + circuit_id="uuid_bedroom", + name="Bedroom", + tabs=[15], + relay_state="CLOSED", + is_user_controllable=True, + breaker_rating_a=15, + ) + snapshot = SpanPanelSnapshotFactory.create( + serial_number="sp3-test-001", + firmware_version="spanos2/r202603/05", + circuits={"uuid_kitchen": kitchen, "uuid_bedroom": bedroom}, + panel_size=32, + ) + + entry = MockConfigEntry( + domain=DOMAIN, + data={}, + entry_id="span_entry", + unique_id="sp3-test-001", + ) + entry.add_to_hass(hass) + entry.mock_state(hass, ConfigEntryState.LOADED) + entry.runtime_data = SpanPanelRuntimeData( + coordinator=_make_coordinator(snapshot) + ) + + device = _register_panel_device(hass, "span_entry", serial="sp3-test-001") + + # Register a power sensor for the kitchen circuit. + _register_entity( + hass, + "span_entry", + device.id, + "sensor", + "span_panel_uuid_kitchen_power", + "sensor.span_panel_kitchen_power", + original_name="Kitchen Power", + ) + + connection = _make_mock_connection() + msg = {"id": 1, "type": "span_panel/panel_topology", "device_id": device.id} + + await _handle_panel_topology_inner(hass, connection, msg) + + connection.send_error.assert_not_called() + connection.send_result.assert_called_once() + + result = connection.send_result.call_args[0][1] + + assert result["serial"] == "sp3-test-001" + assert result["firmware"] == "spanos2/r202603/05" + assert result["panel_size"] == 32 + assert result["device_name"] == "SPAN Panel" + + # Kitchen circuit (240V). + kitchen_data = result["circuits"]["uuid_kitchen"] + assert kitchen_data["tabs"] == [5, 6] + assert kitchen_data["name"] == "Kitchen" + assert kitchen_data["voltage"] == 240 + assert kitchen_data["relay_state"] == "CLOSED" + assert kitchen_data["breaker_rating_a"] == 30 + assert kitchen_data["entities"]["power"] == "sensor.span_panel_kitchen_power" + + # Bedroom circuit (120V). + bedroom_data = result["circuits"]["uuid_bedroom"] + assert bedroom_data["tabs"] == [15] + assert bedroom_data["voltage"] == 120 + assert bedroom_data["entities"] == {} + + @pytest.mark.asyncio + async def test_unmapped_circuits_excluded(self, hass: HomeAssistant): + """Unmapped tab circuits are excluded from the topology.""" + circuit = SpanCircuitSnapshotFactory.create(circuit_id="uuid_real", name="Real") + unmapped = SpanCircuitSnapshotFactory.create( + circuit_id="unmapped_tab_5", name="Unmapped" + ) + snapshot = SpanPanelSnapshotFactory.create( + circuits={"uuid_real": circuit, "unmapped_tab_5": unmapped}, + ) + + entry = MockConfigEntry( + domain=DOMAIN, data={}, entry_id="span_entry", unique_id="sp3-242424-001" + ) + entry.add_to_hass(hass) + entry.mock_state(hass, ConfigEntryState.LOADED) + entry.runtime_data = SpanPanelRuntimeData( + coordinator=_make_coordinator(snapshot) + ) + + device = _register_panel_device(hass, "span_entry") + + connection = _make_mock_connection() + msg = {"id": 1, "type": "span_panel/panel_topology", "device_id": device.id} + + await _handle_panel_topology_inner(hass, connection, msg) + + result = connection.send_result.call_args[0][1] + assert "uuid_real" in result["circuits"] + assert "unmapped_tab_5" not in result["circuits"] + + @pytest.mark.asyncio + async def test_sub_devices_included(self, hass: HomeAssistant): + """BESS and EVSE sub-devices appear in the topology.""" + snapshot = SpanPanelSnapshotFactory.create( + serial_number="sp3-sub-001", + battery=SpanBatterySnapshotFactory.create(soe_percentage=85.0), + evse={"evse-0": SpanEvseSnapshotFactory.create()}, + ) + + entry = MockConfigEntry( + domain=DOMAIN, data={}, entry_id="span_entry", unique_id="sp3-sub-001" + ) + entry.add_to_hass(hass) + entry.mock_state(hass, ConfigEntryState.LOADED) + entry.runtime_data = SpanPanelRuntimeData( + coordinator=_make_coordinator(snapshot) + ) + + panel_device = _register_panel_device(hass, "span_entry", serial="sp3-sub-001") + + bess_device = _register_sub_device( + hass, + "span_entry", + "sp3-sub-001_bess", + "SPAN Panel Battery", + panel_device.id, + ) + evse_device = _register_sub_device( + hass, + "span_entry", + "sp3-sub-001_evse_0", + "SPAN Panel SPAN Drive", + panel_device.id, + ) + + # Register an entity on the BESS device. + _register_entity( + hass, + "span_entry", + bess_device.id, + "sensor", + "sp3-sub-001_bess_battery_level", + "sensor.span_panel_battery_level", + original_name="Battery Level", + ) + + connection = _make_mock_connection() + msg = { + "id": 1, + "type": "span_panel/panel_topology", + "device_id": panel_device.id, + } + + await _handle_panel_topology_inner(hass, connection, msg) + + result = connection.send_result.call_args[0][1] + + assert bess_device.id in result["sub_devices"] + assert result["sub_devices"][bess_device.id]["type"] == "bess" + assert result["sub_devices"][bess_device.id]["name"] == "SPAN Panel Battery" + + assert evse_device.id in result["sub_devices"] + assert result["sub_devices"][evse_device.id]["type"] == "evse" + + # BESS entity should appear. + bess_entities = result["sub_devices"][bess_device.id]["entities"] + assert "sensor.span_panel_battery_level" in bess_entities + + @pytest.mark.asyncio + async def test_evse_feed_circuit_entities_found(self, hass: HomeAssistant): + """EVSE feed circuit sensor entities are found even though they live on the EVSE device.""" + evse_circuit = SpanCircuitSnapshotFactory.create( + circuit_id="uuid_evse_feed", + name="Garage", + tabs=[30, 32], + device_type="evse", + ) + snapshot = SpanPanelSnapshotFactory.create( + serial_number="sp3-evse-001", + circuits={"uuid_evse_feed": evse_circuit}, + evse={ + "evse-0": SpanEvseSnapshotFactory.create( + feed_circuit_id="uuid_evse_feed" + ) + }, + ) + + entry = MockConfigEntry( + domain=DOMAIN, data={}, entry_id="span_entry", unique_id="sp3-evse-001" + ) + entry.add_to_hass(hass) + entry.mock_state(hass, ConfigEntryState.LOADED) + entry.runtime_data = SpanPanelRuntimeData( + coordinator=_make_coordinator(snapshot) + ) + + panel_device = _register_panel_device(hass, "span_entry", serial="sp3-evse-001") + evse_device = _register_sub_device( + hass, + "span_entry", + "sp3-evse-001_evse_0", + "SPAN Drive", + panel_device.id, + ) + + # Power sensor lives on the EVSE device, not the panel device. + _register_entity( + hass, + "span_entry", + evse_device.id, + "sensor", + "span_panel_uuid_evse_feed_power", + "sensor.span_panel_garage_power", + ) + + connection = _make_mock_connection() + msg = { + "id": 1, + "type": "span_panel/panel_topology", + "device_id": panel_device.id, + } + + await _handle_panel_topology_inner(hass, connection, msg) + + result = connection.send_result.call_args[0][1] + circuit_data = result["circuits"]["uuid_evse_feed"] + assert circuit_data["device_type"] == "evse" + assert circuit_data["entities"]["power"] == "sensor.span_panel_garage_power" + + @pytest.mark.asyncio + async def test_topology_includes_always_on_and_priority(self, hass: HomeAssistant): + """Topology response includes always_on and priority for each circuit.""" + router = SpanCircuitSnapshotFactory.create( + circuit_id="uuid_router", + name="Internet Router", + tabs=[23], + relay_state="CLOSED", + is_user_controllable=False, + always_on=True, + priority="NEVER", + breaker_rating_a=15, + ) + hvac = SpanCircuitSnapshotFactory.create( + circuit_id="uuid_hvac", + name="HVAC", + tabs=[7, 8], + relay_state="CLOSED", + is_user_controllable=True, + always_on=False, + priority="SOC_THRESHOLD", + breaker_rating_a=30, + ) + snapshot = SpanPanelSnapshotFactory.create( + serial_number="sp3-prio-001", + circuits={"uuid_router": router, "uuid_hvac": hvac}, + ) + + entry = MockConfigEntry( + domain=DOMAIN, + data={}, + entry_id="span_entry", + unique_id="sp3-prio-001", + ) + entry.add_to_hass(hass) + entry.mock_state(hass, ConfigEntryState.LOADED) + entry.runtime_data = SpanPanelRuntimeData( + coordinator=_make_coordinator(snapshot) + ) + + device = _register_panel_device(hass, "span_entry", serial="sp3-prio-001") + + connection = _make_mock_connection() + msg = {"id": 1, "type": "span_panel/panel_topology", "device_id": device.id} + + await _handle_panel_topology_inner(hass, connection, msg) + + result = connection.send_result.call_args[0][1] + + # Always-on circuit (system-configured, not user-controllable) + router_data = result["circuits"]["uuid_router"] + assert router_data["always_on"] is True + assert router_data["priority"] == "NEVER" + + # User-controllable circuit with SoC threshold + hvac_data = result["circuits"]["uuid_hvac"] + assert hvac_data["always_on"] is False + assert hvac_data["priority"] == "SOC_THRESHOLD" + + @pytest.mark.asyncio + async def test_topology_includes_panel_status_entity(self, hass: HomeAssistant): + """panel_status binary sensor entity_id is included in the topology panel_entities map.""" + snapshot = SpanPanelSnapshotFactory.create(serial_number="sp3-242424-001") + + entry = MockConfigEntry( + domain=DOMAIN, + data={}, + entry_id="span_entry", + unique_id="sp3-242424-001", + ) + entry.add_to_hass(hass) + entry.mock_state(hass, ConfigEntryState.LOADED) + entry.runtime_data = SpanPanelRuntimeData( + coordinator=_make_coordinator(snapshot) + ) + + panel_device = _register_panel_device(hass, "span_entry", serial="sp3-242424-001") + + _register_entity( + hass, + "span_entry", + panel_device.id, + "binary_sensor", + "span_sp3-242424-001_panel_status", + "binary_sensor.span_panel_test_panel_status", + ) + + connection = _make_mock_connection() + msg = {"id": 1, "type": "span_panel/panel_topology", "device_id": panel_device.id} + + await _handle_panel_topology_inner(hass, connection, msg) + + connection.send_error.assert_not_called() + connection.send_result.assert_called_once() + + result = connection.send_result.call_args[0][1] + assert result["panel_entities"]["panel_status"] == "binary_sensor.span_panel_test_panel_status" + + @pytest.mark.asyncio + async def test_topology_omits_panel_status_when_entity_missing(self, hass: HomeAssistant): + """panel_status is absent from the topology map when no binary_sensor entity exists.""" + snapshot = SpanPanelSnapshotFactory.create(serial_number="sp3-242424-001") + + entry = MockConfigEntry( + domain=DOMAIN, + data={}, + entry_id="span_entry", + unique_id="sp3-242424-001", + ) + entry.add_to_hass(hass) + entry.mock_state(hass, ConfigEntryState.LOADED) + entry.runtime_data = SpanPanelRuntimeData( + coordinator=_make_coordinator(snapshot) + ) + + panel_device = _register_panel_device(hass, "span_entry", serial="sp3-242424-001") + + # Intentionally do NOT register a binary_sensor.*_panel_status entity + + connection = _make_mock_connection() + msg = {"id": 1, "type": "span_panel/panel_topology", "device_id": panel_device.id} + + await _handle_panel_topology_inner(hass, connection, msg) + + connection.send_error.assert_not_called() + connection.send_result.assert_called_once() + + result = connection.send_result.call_args[0][1] + assert "panel_status" not in result["panel_entities"] + + @pytest.mark.asyncio + async def test_registration(self, hass: HomeAssistant): + """WebSocket commands can be registered without error.""" + async_register_commands(hass) diff --git a/uv.lock b/uv.lock new file mode 100644 index 00000000..195f6f51 --- /dev/null +++ b/uv.lock @@ -0,0 +1,2941 @@ +version = 1 +revision = 3 +requires-python = ">=3.14.3, <3.15" + +[[package]] +name = "acme" +version = "5.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "josepy" }, + { name = "pyopenssl" }, + { name = "pyrfc3339" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/4f/813bc8c11a2b705e9c18d0e806aa8f069aa8faca58188500c781a793b364/acme-5.4.0.tar.gz", hash = "sha256:906e6cca7f58b5526c0ddfe3d71a7a41f8fa10acf3b083dd35cf619b5b015ca8", size = 90596, upload-time = "2026-03-10T19:06:17.383Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bb/f6/2a35be29ce639e7cc86c95fc67f98cd5fb93d51c6cd68aa8c07a89de40db/acme-5.4.0-py3-none-any.whl", hash = "sha256:e05c50f64958fe26475df21e3a18949993eff9d68a7e771f215c56b83e9e66cc", size = 94962, upload-time = "2026-03-10T19:05:49.192Z" }, +] + +[[package]] +name = "aiodns" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycares" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/10/da/97235e953109936bfeda62c1f9f1a7c5652d4dc49f2b5911f9ae1043afa9/aiodns-4.0.0.tar.gz", hash = "sha256:17be26a936ba788c849ba5fd20e0ba69d8c46e6273e846eb5430eae2630ce5b1", size = 26204, upload-time = "2026-01-10T22:33:27.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/60/14ac40c03e8a26216e4f2642497b776e52f9e3214e4fd537628829bbb082/aiodns-4.0.0-py3-none-any.whl", hash = "sha256:a188a75fb8b2b7862ac8f84811a231402fb74f5b4e6f10766dc8a4544b0cf989", size = 11334, upload-time = "2026-01-10T22:33:25.65Z" }, +] + +[[package]] +name = "aiogithubapi" +version = "26.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "backoff" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/41/4c/1319dc5f7772f2ad960bd84d47d972a64aa596b6cdd966956e5e85501333/aiogithubapi-26.0.0.tar.gz", hash = "sha256:71ee97ebb242378535551ede80605384d1d3536b83e68dae938ce201d06dac33", size = 37561, upload-time = "2026-02-28T09:56:52.621Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/a0/3102dbe2457eceb2e71f295e5d08f0f07c4982f82366cce34b2a332e348e/aiogithubapi-26.0.0-py3-none-any.whl", hash = "sha256:156b5f9217d23cb0eb65e233b19c10f499f1ba3bcf1d7d65e3a463c034e3813a", size = 72608, upload-time = "2026-02-28T09:56:51.23Z" }, +] + +[[package]] +name = "aiohappyeyeballs" +version = "2.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760, upload-time = "2025-03-12T01:42:48.764Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265, upload-time = "2025-03-12T01:42:47.083Z" }, +] + +[[package]] +name = "aiohasupervisor" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "mashumaro" }, + { name = "orjson" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/35/74/529860af5e305647c469e6ec2ac78f4d61b17349d201ab03a8073e683656/aiohasupervisor-0.4.3.tar.gz", hash = "sha256:8b6e56f385879616a6e520a092f57f41c8ba3bd3cd05b33a87e89b62604d14b8", size = 46583, upload-time = "2026-03-24T08:41:45.457Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/bf/0a61a4632930a19601c99044c2f293f98ee44c0b4a9907a7bc83e70307aa/aiohasupervisor-0.4.3-py3-none-any.whl", hash = "sha256:ae05a59e890c6ed83dd801e9ebb7e12ca67ac5aed7cef29315f5422fdf7022b5", size = 42070, upload-time = "2026-03-24T08:41:44.177Z" }, +] + +[[package]] +name = "aiohttp" +version = "3.13.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/77/9a/152096d4808df8e4268befa55fba462f440f14beab85e8ad9bf990516918/aiohttp-3.13.5.tar.gz", hash = "sha256:9d98cc980ecc96be6eb4c1994ce35d28d8b1f5e5208a23b421187d1209dbb7d1", size = 7858271, upload-time = "2026-03-31T22:01:03.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/ce/46572759afc859e867a5bc8ec3487315869013f59281ce61764f76d879de/aiohttp-3.13.5-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:eb4639f32fd4a9904ab8fb45bf3383ba71137f3d9d4ba25b3b3f3109977c5b8c", size = 745721, upload-time = "2026-03-31T21:58:50.229Z" }, + { url = "https://files.pythonhosted.org/packages/13/fe/8a2efd7626dbe6049b2ef8ace18ffda8a4dfcbe1bcff3ac30c0c7575c20b/aiohttp-3.13.5-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:7e5dc4311bd5ac493886c63cbf76ab579dbe4641268e7c74e48e774c74b6f2be", size = 497663, upload-time = "2026-03-31T21:58:52.232Z" }, + { url = "https://files.pythonhosted.org/packages/9b/91/cc8cc78a111826c54743d88651e1687008133c37e5ee615fee9b57990fac/aiohttp-3.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:756c3c304d394977519824449600adaf2be0ccee76d206ee339c5e76b70ded25", size = 499094, upload-time = "2026-03-31T21:58:54.566Z" }, + { url = "https://files.pythonhosted.org/packages/0a/33/a8362cb15cf16a3af7e86ed11962d5cd7d59b449202dc576cdc731310bde/aiohttp-3.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecc26751323224cf8186efcf7fbcbc30f4e1d8c7970659daf25ad995e4032a56", size = 1726701, upload-time = "2026-03-31T21:58:56.864Z" }, + { url = "https://files.pythonhosted.org/packages/45/0c/c091ac5c3a17114bd76cbf85d674650969ddf93387876cf67f754204bd77/aiohttp-3.13.5-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10a75acfcf794edf9d8db50e5a7ec5fc818b2a8d3f591ce93bc7b1210df016d2", size = 1683360, upload-time = "2026-03-31T21:58:59.072Z" }, + { url = "https://files.pythonhosted.org/packages/23/73/bcee1c2b79bc275e964d1446c55c54441a461938e70267c86afaae6fba27/aiohttp-3.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f7a18f258d124cd678c5fe072fe4432a4d5232b0657fca7c1847f599233c83a", size = 1773023, upload-time = "2026-03-31T21:59:01.776Z" }, + { url = "https://files.pythonhosted.org/packages/c7/ef/720e639df03004fee2d869f771799d8c23046dec47d5b81e396c7cda583a/aiohttp-3.13.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:df6104c009713d3a89621096f3e3e88cc323fd269dbd7c20afe18535094320be", size = 1853795, upload-time = "2026-03-31T21:59:04.568Z" }, + { url = "https://files.pythonhosted.org/packages/bd/c9/989f4034fb46841208de7aeeac2c6d8300745ab4f28c42f629ba77c2d916/aiohttp-3.13.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:241a94f7de7c0c3b616627aaad530fe2cb620084a8b144d3be7b6ecfe95bae3b", size = 1730405, upload-time = "2026-03-31T21:59:07.221Z" }, + { url = "https://files.pythonhosted.org/packages/ce/75/ee1fd286ca7dc599d824b5651dad7b3be7ff8d9a7e7b3fe9820d9180f7db/aiohttp-3.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c974fb66180e58709b6fc402846f13791240d180b74de81d23913abe48e96d94", size = 1558082, upload-time = "2026-03-31T21:59:09.484Z" }, + { url = "https://files.pythonhosted.org/packages/c3/20/1e9e6650dfc436340116b7aa89ff8cb2bbdf0abc11dfaceaad8f74273a10/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:6e27ea05d184afac78aabbac667450c75e54e35f62238d44463131bd3f96753d", size = 1692346, upload-time = "2026-03-31T21:59:12.068Z" }, + { url = "https://files.pythonhosted.org/packages/d8/40/8ebc6658d48ea630ac7903912fe0dd4e262f0e16825aa4c833c56c9f1f56/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a79a6d399cef33a11b6f004c67bb07741d91f2be01b8d712d52c75711b1e07c7", size = 1698891, upload-time = "2026-03-31T21:59:14.552Z" }, + { url = "https://files.pythonhosted.org/packages/d8/78/ea0ae5ec8ba7a5c10bdd6e318f1ba5e76fcde17db8275188772afc7917a4/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c632ce9c0b534fbe25b52c974515ed674937c5b99f549a92127c85f771a78772", size = 1742113, upload-time = "2026-03-31T21:59:17.068Z" }, + { url = "https://files.pythonhosted.org/packages/8a/66/9d308ed71e3f2491be1acb8769d96c6f0c47d92099f3bc9119cada27b357/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fceedde51fbd67ee2bcc8c0b33d0126cc8b51ef3bbde2f86662bd6d5a6f10ec5", size = 1553088, upload-time = "2026-03-31T21:59:19.541Z" }, + { url = "https://files.pythonhosted.org/packages/da/a6/6cc25ed8dfc6e00c90f5c6d126a98e2cf28957ad06fa1036bd34b6f24a2c/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f92995dfec9420bb69ae629abf422e516923ba79ba4403bc750d94fb4a6c68c1", size = 1757976, upload-time = "2026-03-31T21:59:22.311Z" }, + { url = "https://files.pythonhosted.org/packages/c1/2b/cce5b0ffe0de99c83e5e36d8f828e4161e415660a9f3e58339d07cce3006/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20ae0ff08b1f2c8788d6fb85afcb798654ae6ba0b747575f8562de738078457b", size = 1712444, upload-time = "2026-03-31T21:59:24.635Z" }, + { url = "https://files.pythonhosted.org/packages/6c/cf/9e1795b4160c58d29421eafd1a69c6ce351e2f7c8d3c6b7e4ca44aea1a5b/aiohttp-3.13.5-cp314-cp314-win32.whl", hash = "sha256:b20df693de16f42b2472a9c485e1c948ee55524786a0a34345511afdd22246f3", size = 438128, upload-time = "2026-03-31T21:59:27.291Z" }, + { url = "https://files.pythonhosted.org/packages/22/4d/eaedff67fc805aeba4ba746aec891b4b24cebb1a7d078084b6300f79d063/aiohttp-3.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:f85c6f327bf0b8c29da7d93b1cabb6363fb5e4e160a32fa241ed2dce21b73162", size = 464029, upload-time = "2026-03-31T21:59:29.429Z" }, + { url = "https://files.pythonhosted.org/packages/79/11/c27d9332ee20d68dd164dc12a6ecdef2e2e35ecc97ed6cf0d2442844624b/aiohttp-3.13.5-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:1efb06900858bb618ff5cee184ae2de5828896c448403d51fb633f09e109be0a", size = 778758, upload-time = "2026-03-31T21:59:31.547Z" }, + { url = "https://files.pythonhosted.org/packages/04/fb/377aead2e0a3ba5f09b7624f702a964bdf4f08b5b6728a9799830c80041e/aiohttp-3.13.5-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:fee86b7c4bd29bdaf0d53d14739b08a106fdda809ca5fe032a15f52fae5fe254", size = 512883, upload-time = "2026-03-31T21:59:34.098Z" }, + { url = "https://files.pythonhosted.org/packages/bb/a6/aa109a33671f7a5d3bd78b46da9d852797c5e665bfda7d6b373f56bff2ec/aiohttp-3.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:20058e23909b9e65f9da62b396b77dfa95965cbe840f8def6e572538b1d32e36", size = 516668, upload-time = "2026-03-31T21:59:36.497Z" }, + { url = "https://files.pythonhosted.org/packages/79/b3/ca078f9f2fa9563c36fb8ef89053ea2bb146d6f792c5104574d49d8acb63/aiohttp-3.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cf20a8d6868cb15a73cab329ffc07291ba8c22b1b88176026106ae39aa6df0f", size = 1883461, upload-time = "2026-03-31T21:59:38.723Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e3/a7ad633ca1ca497b852233a3cce6906a56c3225fb6d9217b5e5e60b7419d/aiohttp-3.13.5-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:330f5da04c987f1d5bdb8ae189137c77139f36bd1cb23779ca1a354a4b027800", size = 1747661, upload-time = "2026-03-31T21:59:41.187Z" }, + { url = "https://files.pythonhosted.org/packages/33/b9/cd6fe579bed34a906d3d783fe60f2fa297ef55b27bb4538438ee49d4dc41/aiohttp-3.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f1cbf0c7926d315c3c26c2da41fd2b5d2fe01ac0e157b78caefc51a782196cf", size = 1863800, upload-time = "2026-03-31T21:59:43.84Z" }, + { url = "https://files.pythonhosted.org/packages/c0/3f/2c1e2f5144cefa889c8afd5cf431994c32f3b29da9961698ff4e3811b79a/aiohttp-3.13.5-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:53fc049ed6390d05423ba33103ded7281fe897cf97878f369a527070bd95795b", size = 1958382, upload-time = "2026-03-31T21:59:46.187Z" }, + { url = "https://files.pythonhosted.org/packages/66/1d/f31ec3f1013723b3babe3609e7f119c2c2fb6ef33da90061a705ef3e1bc8/aiohttp-3.13.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:898703aa2667e3c5ca4c54ca36cd73f58b7a38ef87a5606414799ebce4d3fd3a", size = 1803724, upload-time = "2026-03-31T21:59:48.656Z" }, + { url = "https://files.pythonhosted.org/packages/0e/b4/57712dfc6f1542f067daa81eb61da282fab3e6f1966fca25db06c4fc62d5/aiohttp-3.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0494a01ca9584eea1e5fbd6d748e61ecff218c51b576ee1999c23db7066417d8", size = 1640027, upload-time = "2026-03-31T21:59:51.284Z" }, + { url = "https://files.pythonhosted.org/packages/25/3c/734c878fb43ec083d8e31bf029daae1beafeae582d1b35da234739e82ee7/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6cf81fe010b8c17b09495cbd15c1d35afbc8fb405c0c9cf4738e5ae3af1d65be", size = 1806644, upload-time = "2026-03-31T21:59:53.753Z" }, + { url = "https://files.pythonhosted.org/packages/20/a5/f671e5cbec1c21d044ff3078223f949748f3a7f86b14e34a365d74a5d21f/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:c564dd5f09ddc9d8f2c2d0a301cd30a79a2cc1b46dd1a73bef8f0038863d016b", size = 1791630, upload-time = "2026-03-31T21:59:56.239Z" }, + { url = "https://files.pythonhosted.org/packages/0b/63/fb8d0ad63a0b8a99be97deac8c04dacf0785721c158bdf23d679a87aa99e/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2994be9f6e51046c4f864598fd9abeb4fba6e88f0b2152422c9666dcd4aea9c6", size = 1809403, upload-time = "2026-03-31T21:59:59.103Z" }, + { url = "https://files.pythonhosted.org/packages/59/0c/bfed7f30662fcf12206481c2aac57dedee43fe1c49275e85b3a1e1742294/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:157826e2fa245d2ef46c83ea8a5faf77ca19355d278d425c29fda0beb3318037", size = 1634924, upload-time = "2026-03-31T22:00:02.116Z" }, + { url = "https://files.pythonhosted.org/packages/17/d6/fd518d668a09fd5a3319ae5e984d4d80b9a4b3df4e21c52f02251ef5a32e/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:a8aca50daa9493e9e13c0f566201a9006f080e7c50e5e90d0b06f53146a54500", size = 1836119, upload-time = "2026-03-31T22:00:04.756Z" }, + { url = "https://files.pythonhosted.org/packages/78/b7/15fb7a9d52e112a25b621c67b69c167805cb1f2ab8f1708a5c490d1b52fe/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3b13560160d07e047a93f23aaa30718606493036253d5430887514715b67c9d9", size = 1772072, upload-time = "2026-03-31T22:00:07.494Z" }, + { url = "https://files.pythonhosted.org/packages/7e/df/57ba7f0c4a553fc2bd8b6321df236870ec6fd64a2a473a8a13d4f733214e/aiohttp-3.13.5-cp314-cp314t-win32.whl", hash = "sha256:9a0f4474b6ea6818b41f82172d799e4b3d29e22c2c520ce4357856fced9af2f8", size = 471819, upload-time = "2026-03-31T22:00:10.277Z" }, + { url = "https://files.pythonhosted.org/packages/62/29/2f8418269e46454a26171bfdd6a055d74febf32234e474930f2f60a17145/aiohttp-3.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:18a2f6c1182c51baa1d28d68fea51513cb2a76612f038853c0ad3c145423d3d9", size = 505441, upload-time = "2026-03-31T22:00:12.791Z" }, +] + +[[package]] +name = "aiohttp-asyncmdnsresolver" +version = "0.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiodns" }, + { name = "aiohttp" }, + { name = "zeroconf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/05/83/09fb97705e7308f94197a09b486669696ea20f28074c14b5811a38bdedc3/aiohttp_asyncmdnsresolver-0.1.1.tar.gz", hash = "sha256:8c65d4b08b42c8a260717a2766bd5967a1d437cee852a9b21f3928b5171a7c81", size = 36129, upload-time = "2025-02-14T14:46:44.402Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/d1/4f61508a43de82bb5c60cede3bb89cc57c5e8af7978d93ca03ad60b99368/aiohttp_asyncmdnsresolver-0.1.1-py3-none-any.whl", hash = "sha256:d04ded993e9f0e07c07a1bc687cde447d9d32e05bcf55ecbf94f63b33dcab93e", size = 13582, upload-time = "2025-02-14T14:46:41.985Z" }, +] + +[[package]] +name = "aiohttp-cors" +version = "0.8.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/d89e846a5444b3d5eb8985a6ddb0daef3774928e1bfbce8e84ec97b0ffa7/aiohttp_cors-0.8.1.tar.gz", hash = "sha256:ccacf9cb84b64939ea15f859a146af1f662a6b1d68175754a07315e305fb1403", size = 38626, upload-time = "2025-03-31T14:16:20.048Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/3b/40a68de458904bcc143622015fff2352b6461cd92fd66d3527bf1c6f5716/aiohttp_cors-0.8.1-py3-none-any.whl", hash = "sha256:3180cf304c5c712d626b9162b195b1db7ddf976a2a25172b35bb2448b890a80d", size = 25231, upload-time = "2025-03-31T14:16:18.478Z" }, +] + +[[package]] +name = "aiohttp-fast-zlib" +version = "0.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0a/a6/982f3a013b42e914a2420631afcaecb729c49525cc6cc58e15d27ee4cb4b/aiohttp_fast_zlib-0.3.0.tar.gz", hash = "sha256:963a09de571b67fa0ef9cb44c5a32ede5cb1a51bc79fc21181b1cddd56b58b28", size = 8770, upload-time = "2025-06-07T12:41:49.161Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/11/ea9ecbcd6cf68c5de690fd39b66341405ab091aa0c3598277e687aa65901/aiohttp_fast_zlib-0.3.0-py3-none-any.whl", hash = "sha256:d4cb20760a3e1137c93cb42c13871cbc9cd1fdc069352f2712cd650d6c0e537e", size = 8615, upload-time = "2025-06-07T12:41:47.454Z" }, +] + +[[package]] +name = "aiooui" +version = "0.1.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/14/b7/ad0f86010bbabc4e556e98dd2921a923677188223cc524432695966f14fa/aiooui-0.1.9.tar.gz", hash = "sha256:e8c8bc59ab352419e0747628b4cce7c4e04d492574c1971e223401126389c5d8", size = 369276, upload-time = "2025-01-19T00:12:44.853Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/fa/b1310457adbea7adb84d2c144159f3b41341c40c80df3c10ce6b266874b3/aiooui-0.1.9-py3-none-any.whl", hash = "sha256:737a5e62d8726540218c2b70e5f966d9912121e4644f3d490daf8f3c18b182e5", size = 367404, upload-time = "2025-01-19T00:12:42.57Z" }, +] + +[[package]] +name = "aiosignal" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, +] + +[[package]] +name = "aiozoneinfo" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzdata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/00/e437a179ab78ed24780ded10bbb5d7e10832c07f62eab1d44ee2f335c95c/aiozoneinfo-0.2.3.tar.gz", hash = "sha256:987ce2a7d5141f3f4c2e9d50606310d0bf60d688ad9f087aa7267433ba85fff3", size = 8381, upload-time = "2025-02-04T19:32:06.489Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/a4/99e13bb4006999de2a4d63cee7497c3eb7f616b0aefc660c4c316179af3a/aiozoneinfo-0.2.3-py3-none-any.whl", hash = "sha256:5423f0354c9eed982e3f1c35edeeef1458d4cc6a10f106616891a089a8455661", size = 8009, upload-time = "2025-02-04T19:32:04.74Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "annotatedyaml" +version = "1.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "propcache" }, + { name = "pyyaml" }, + { name = "voluptuous" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ec/4b/973067092ee348e331d125acd60c45245f11663373c219650814b43d0025/annotatedyaml-1.0.2.tar.gz", hash = "sha256:f9a49952994ef1952ca17d27bb6478342eb1189d2c28e4c0ddbbb32065471fb0", size = 15366, upload-time = "2025-10-04T14:36:26.655Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/91/0acf5b74926c6964812d9ed752af77531ab4daa06fba1cb668d9006e9e1f/annotatedyaml-1.0.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c42f385c3f04f425d5948c16afbb94a876da867be276dbf2c2e7436b9a80792d", size = 58962, upload-time = "2025-10-04T14:42:01.183Z" }, + { url = "https://files.pythonhosted.org/packages/71/f6/5dac1ce125984db4cb99d883f234e6a8c0e49358a9136047a490bc2ba51a/annotatedyaml-1.0.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b5d9d24ba907fd2e905eac69c88e651310c480980a17aa57faf0599ff21f586f", size = 60252, upload-time = "2025-10-04T14:42:02.095Z" }, + { url = "https://files.pythonhosted.org/packages/39/54/81dea3e4272927518abb9c96ab299b8c4346c40267740bfb8d6b0cdb317f/annotatedyaml-1.0.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2572b7c3c630dae1dd163d6c6ba847493a7f987437941b32d0ad8354615f358a", size = 71219, upload-time = "2025-10-04T14:42:03.024Z" }, + { url = "https://files.pythonhosted.org/packages/42/fb/5aa3d7767cb92e8ba34cba582e5b088f42746e6d075f7d387fcdc4e5dd62/annotatedyaml-1.0.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b28fe13eb0014a0dd06c9a292466feed0cd298ab10525ef9a37089df01c7d333", size = 64459, upload-time = "2025-10-04T14:42:03.967Z" }, + { url = "https://files.pythonhosted.org/packages/a0/eb/b29b84eec6d3a1fc3278ff2959388f347e7853a3f82fc5275c591a523835/annotatedyaml-1.0.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:987f73a13f121c775bcdb082214c17f114447fee7dad37db2f86b035893ad58d", size = 71175, upload-time = "2025-10-04T14:42:05.22Z" }, + { url = "https://files.pythonhosted.org/packages/b7/7c/4f4bf854f4b62cade7485a9572773d4440ee535e905f166b441b2d3f19a7/annotatedyaml-1.0.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5cb4ee79c08da2b8f4f24b1775732ca6c497682f3c9b3fd65dee4ea084fc925c", size = 71824, upload-time = "2025-10-04T14:42:06.398Z" }, + { url = "https://files.pythonhosted.org/packages/68/ac/1f903eeccde636723fcf664b372a6ab253b7f13c3de446ff5bce6852d696/annotatedyaml-1.0.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:da065a8c29556219fce1aa81b406e84f73bc2181067658e57428a8b2e662fc1b", size = 65343, upload-time = "2025-10-04T14:42:07.727Z" }, + { url = "https://files.pythonhosted.org/packages/e0/aa/43e83b50a42ad5c51abf1a335cfc249e182f66542d7c7306ee07397b1956/annotatedyaml-1.0.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94ba9937418c1b189b267540b47fa0dc24c148292739d06a6ca31c2ca8482f16", size = 72328, upload-time = "2025-10-04T14:42:08.656Z" }, + { url = "https://files.pythonhosted.org/packages/f1/6a/f5d9c29633c499973f10330af31a8b135a564a4a2e056c32a6ff2c901559/annotatedyaml-1.0.2-cp314-cp314-win32.whl", hash = "sha256:003e16e91b40176dd8fe77d56c6c936106b408b62953e88ce3506e8ba10bf4e1", size = 57286, upload-time = "2025-10-04T14:42:09.597Z" }, + { url = "https://files.pythonhosted.org/packages/b7/43/d8c7464676094658f1caaee6762536ab43867d7153f7c637207c63fc4c97/annotatedyaml-1.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:17e64a7dde47a678db8aa4e934c3ed8da9a52ab1bc6946d12be86f323e6bd8c7", size = 61363, upload-time = "2025-10-04T14:42:10.968Z" }, + { url = "https://files.pythonhosted.org/packages/c2/5d/7e384f4115a7bc113162f7b6eb5d561031e303f840f304b68e3f1b0541a1/annotatedyaml-1.0.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8698bbbd1d38f8c9ba95a107d7597f5af3f2ba295d1d14227f85b62377998ffc", size = 104776, upload-time = "2025-10-04T14:42:11.921Z" }, + { url = "https://files.pythonhosted.org/packages/26/7d/77bebdd30118c1e85f11d5a83a3bb5955409bba74d81cfb0f7b551273513/annotatedyaml-1.0.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9cbc661dbc7c5f7ddf69fbf879da6a96745b8cd39ae1338dab3a0aa8eb208367", size = 107716, upload-time = "2025-10-04T14:42:14.151Z" }, + { url = "https://files.pythonhosted.org/packages/dc/19/bfc798abb154e398d5210304ba3beff9ad9c7b6ec4574ffb705493b8e2d5/annotatedyaml-1.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:db1c3ca021bbd390354037ede5c255657afb2a7544b7cfa0e091b62b888aa462", size = 130361, upload-time = "2025-10-04T14:42:15.494Z" }, +] + +[[package]] +name = "anyio" +version = "4.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, +] + +[[package]] +name = "astral" +version = "2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytz" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ad/c3/76dfe55a68c48a1a6f3d2eeab2793ebffa9db8adfba82774a7e0f5f43980/astral-2.2.tar.gz", hash = "sha256:e41d9967d5c48be421346552f0f4dedad43ff39a83574f5ff2ad32b6627b6fbe", size = 578223, upload-time = "2020-05-20T14:23:17.602Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/60/7cc241b9c3710ebadddcb323e77dd422c693183aec92449a1cf1fb59e1ba/astral-2.2-py2.py3-none-any.whl", hash = "sha256:b9ef70faf32e81a8ba174d21e8f29dc0b53b409ef035f27e0749ddc13cb5982a", size = 30775, upload-time = "2020-05-20T14:23:14.866Z" }, +] + +[[package]] +name = "astroid" +version = "4.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/07/63/0adf26577da5eff6eb7a177876c1cfa213856be9926a000f65c4add9692b/astroid-4.0.4.tar.gz", hash = "sha256:986fed8bcf79fb82c78b18a53352a0b287a73817d6dbcfba3162da36667c49a0", size = 406358, upload-time = "2026-02-07T23:35:07.509Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/cf/1c5f42b110e57bc5502eb80dbc3b03d256926062519224835ef08134f1f9/astroid-4.0.4-py3-none-any.whl", hash = "sha256:52f39653876c7dec3e3afd4c2696920e05c83832b9737afc21928f2d2eb7a753", size = 276445, upload-time = "2026-02-07T23:35:05.344Z" }, +] + +[[package]] +name = "async-interrupt" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/56/79/732a581e3ceb09f938d33ad8ab3419856181d95bb621aa2441a10f281e10/async_interrupt-1.2.2.tar.gz", hash = "sha256:be4331a029b8625777905376a6dc1370984c8c810f30b79703f3ee039d262bf7", size = 8484, upload-time = "2025-02-22T17:15:04.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/77/060b972fa7819fa9eea9a70acf8c7c0c58341a1e300ee5ccb063e757a4a7/async_interrupt-1.2.2-py3-none-any.whl", hash = "sha256:0a8deb884acfb5fe55188a693ae8a4381bbbd2cb6e670dac83869489513eec2c", size = 8907, upload-time = "2025-02-22T17:15:01.971Z" }, +] + +[[package]] +name = "atomicwrites-homeassistant" +version = "1.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/5a/10ff0fd9aa04f78a0b31bb617c8d29796a12bea33f1e48aa54687d635e44/atomicwrites-homeassistant-1.4.1.tar.gz", hash = "sha256:256a672106f16745445228d966240b77b55f46a096d20305901a57aa5d1f4c2f", size = 12223, upload-time = "2022-07-08T20:56:46.35Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/1b/872dd3b11939edb4c0a27d2569a9b7e77d3b88995a45a331f376e13528c0/atomicwrites_homeassistant-1.4.1-py2.py3-none-any.whl", hash = "sha256:01457de800961db7d5b575f3c92e7fb56e435d88512c366afb0873f4f092bb0d", size = 7128, upload-time = "2022-07-08T20:56:44.186Z" }, +] + +[[package]] +name = "attrs" +version = "25.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, +] + +[[package]] +name = "audioop-lts" +version = "0.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dd/3b/69ff8a885e4c1c42014c2765275c4bd91fe7bc9847e9d8543dbcbb09f820/audioop_lts-0.2.1.tar.gz", hash = "sha256:e81268da0baa880431b68b1308ab7257eb33f356e57a5f9b1f915dfb13dd1387", size = 30204, upload-time = "2024-08-04T21:14:43.957Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/01/91/a219253cc6e92db2ebeaf5cf8197f71d995df6f6b16091d1f3ce62cb169d/audioop_lts-0.2.1-cp313-abi3-macosx_10_13_universal2.whl", hash = "sha256:fd1345ae99e17e6910f47ce7d52673c6a1a70820d78b67de1b7abb3af29c426a", size = 46252, upload-time = "2024-08-04T21:13:56.209Z" }, + { url = "https://files.pythonhosted.org/packages/ec/f6/3cb21e0accd9e112d27cee3b1477cd04dafe88675c54ad8b0d56226c1e0b/audioop_lts-0.2.1-cp313-abi3-macosx_10_13_x86_64.whl", hash = "sha256:e175350da05d2087e12cea8e72a70a1a8b14a17e92ed2022952a4419689ede5e", size = 27183, upload-time = "2024-08-04T21:13:59.966Z" }, + { url = "https://files.pythonhosted.org/packages/ea/7e/f94c8a6a8b2571694375b4cf94d3e5e0f529e8e6ba280fad4d8c70621f27/audioop_lts-0.2.1-cp313-abi3-macosx_11_0_arm64.whl", hash = "sha256:4a8dd6a81770f6ecf019c4b6d659e000dc26571b273953cef7cd1d5ce2ff3ae6", size = 26726, upload-time = "2024-08-04T21:14:00.846Z" }, + { url = "https://files.pythonhosted.org/packages/ef/f8/a0e8e7a033b03fae2b16bc5aa48100b461c4f3a8a38af56d5ad579924a3a/audioop_lts-0.2.1-cp313-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1cd3c0b6f2ca25c7d2b1c3adeecbe23e65689839ba73331ebc7d893fcda7ffe", size = 80718, upload-time = "2024-08-04T21:14:01.989Z" }, + { url = "https://files.pythonhosted.org/packages/8f/ea/a98ebd4ed631c93b8b8f2368862cd8084d75c77a697248c24437c36a6f7e/audioop_lts-0.2.1-cp313-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ff3f97b3372c97782e9c6d3d7fdbe83bce8f70de719605bd7ee1839cd1ab360a", size = 88326, upload-time = "2024-08-04T21:14:03.509Z" }, + { url = "https://files.pythonhosted.org/packages/33/79/e97a9f9daac0982aa92db1199339bd393594d9a4196ad95ae088635a105f/audioop_lts-0.2.1-cp313-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a351af79edefc2a1bd2234bfd8b339935f389209943043913a919df4b0f13300", size = 80539, upload-time = "2024-08-04T21:14:04.679Z" }, + { url = "https://files.pythonhosted.org/packages/b2/d3/1051d80e6f2d6f4773f90c07e73743a1e19fcd31af58ff4e8ef0375d3a80/audioop_lts-0.2.1-cp313-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2aeb6f96f7f6da80354330470b9134d81b4cf544cdd1c549f2f45fe964d28059", size = 78577, upload-time = "2024-08-04T21:14:09.038Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/54f4c58bae8dc8c64a75071c7e98e105ddaca35449376fcb0180f6e3c9df/audioop_lts-0.2.1-cp313-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c589f06407e8340e81962575fcffbba1e92671879a221186c3d4662de9fe804e", size = 82074, upload-time = "2024-08-04T21:14:09.99Z" }, + { url = "https://files.pythonhosted.org/packages/36/89/2e78daa7cebbea57e72c0e1927413be4db675548a537cfba6a19040d52fa/audioop_lts-0.2.1-cp313-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fbae5d6925d7c26e712f0beda5ed69ebb40e14212c185d129b8dfbfcc335eb48", size = 84210, upload-time = "2024-08-04T21:14:11.468Z" }, + { url = "https://files.pythonhosted.org/packages/a5/57/3ff8a74df2ec2fa6d2ae06ac86e4a27d6412dbb7d0e0d41024222744c7e0/audioop_lts-0.2.1-cp313-abi3-musllinux_1_2_i686.whl", hash = "sha256:d2d5434717f33117f29b5691fbdf142d36573d751716249a288fbb96ba26a281", size = 85664, upload-time = "2024-08-04T21:14:12.394Z" }, + { url = "https://files.pythonhosted.org/packages/16/01/21cc4e5878f6edbc8e54be4c108d7cb9cb6202313cfe98e4ece6064580dd/audioop_lts-0.2.1-cp313-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:f626a01c0a186b08f7ff61431c01c055961ee28769591efa8800beadd27a2959", size = 93255, upload-time = "2024-08-04T21:14:13.707Z" }, + { url = "https://files.pythonhosted.org/packages/3e/28/7f7418c362a899ac3b0bf13b1fde2d4ffccfdeb6a859abd26f2d142a1d58/audioop_lts-0.2.1-cp313-abi3-musllinux_1_2_s390x.whl", hash = "sha256:05da64e73837f88ee5c6217d732d2584cf638003ac72df124740460531e95e47", size = 87760, upload-time = "2024-08-04T21:14:14.74Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d8/577a8be87dc7dd2ba568895045cee7d32e81d85a7e44a29000fe02c4d9d4/audioop_lts-0.2.1-cp313-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:56b7a0a4dba8e353436f31a932f3045d108a67b5943b30f85a5563f4d8488d77", size = 84992, upload-time = "2024-08-04T21:14:19.155Z" }, + { url = "https://files.pythonhosted.org/packages/ef/9a/4699b0c4fcf89936d2bfb5425f55f1a8b86dff4237cfcc104946c9cd9858/audioop_lts-0.2.1-cp313-abi3-win32.whl", hash = "sha256:6e899eb8874dc2413b11926b5fb3857ec0ab55222840e38016a6ba2ea9b7d5e3", size = 26059, upload-time = "2024-08-04T21:14:20.438Z" }, + { url = "https://files.pythonhosted.org/packages/3a/1c/1f88e9c5dd4785a547ce5fd1eb83fff832c00cc0e15c04c1119b02582d06/audioop_lts-0.2.1-cp313-abi3-win_amd64.whl", hash = "sha256:64562c5c771fb0a8b6262829b9b4f37a7b886c01b4d3ecdbae1d629717db08b4", size = 30412, upload-time = "2024-08-04T21:14:21.342Z" }, + { url = "https://files.pythonhosted.org/packages/c4/e9/c123fd29d89a6402ad261516f848437472ccc602abb59bba522af45e281b/audioop_lts-0.2.1-cp313-abi3-win_arm64.whl", hash = "sha256:c45317debeb64002e980077642afbd977773a25fa3dfd7ed0c84dccfc1fafcb0", size = 23578, upload-time = "2024-08-04T21:14:22.193Z" }, +] + +[[package]] +name = "awesomeversion" +version = "25.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/3a/c97ef69b8209aa9d7209b143345fe49c1e20126f62a775038ab6dcd78fd5/awesomeversion-25.8.0.tar.gz", hash = "sha256:e6cd08c90292a11f30b8de401863dcde7bc66a671d8173f9066ebd15d9310453", size = 70873, upload-time = "2025-08-03T08:54:07.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/b3/c6be343010721bfdd3058b708eb4868fa1a207534a3b6c80de74d35fb568/awesomeversion-25.8.0-py3-none-any.whl", hash = "sha256:1c314683abfcc3e26c62af9e609b585bbcbf2ec19568df2f60ff1034fb1dae28", size = 15919, upload-time = "2025-08-03T08:54:06.265Z" }, +] + +[[package]] +name = "backoff" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/d7/5bbeb12c44d7c4f2fb5b56abce497eb5ed9f34d85701de869acedd602619/backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba", size = 17001, upload-time = "2022-10-05T19:19:32.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8", size = 15148, upload-time = "2022-10-05T19:19:30.546Z" }, +] + +[[package]] +name = "bandit" +version = "1.9.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "pyyaml" }, + { name = "rich" }, + { name = "stevedore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/aa/c3/0cb80dfe0f3076e5da7e4c5ad8e57bac6ac357ff4a6406205501cade4965/bandit-1.9.4.tar.gz", hash = "sha256:b589e5de2afe70bd4d53fa0c1da6199f4085af666fde00e8a034f152a52cd628", size = 4242677, upload-time = "2026-02-25T06:44:15.503Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/a4/a26d5b25671d27e03afb5401a0be5899d94ff8fab6a698b1ac5be3ec29ef/bandit-1.9.4-py3-none-any.whl", hash = "sha256:f89ffa663767f5a0585ea075f01020207e966a9c0f2b9ef56a57c7963a3f6f8e", size = 134741, upload-time = "2026-02-25T06:44:13.694Z" }, +] + +[[package]] +name = "bcrypt" +version = "5.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d4/36/3329e2518d70ad8e2e5817d5a4cac6bba05a47767ec416c7d020a965f408/bcrypt-5.0.0.tar.gz", hash = "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd", size = 25386, upload-time = "2025-09-25T19:50:47.829Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/14/c18006f91816606a4abe294ccc5d1e6f0e42304df5a33710e9e8e95416e1/bcrypt-5.0.0-cp314-cp314t-macosx_10_12_universal2.whl", hash = "sha256:4870a52610537037adb382444fefd3706d96d663ac44cbb2f37e3919dca3d7ef", size = 481862, upload-time = "2025-09-25T19:49:28.365Z" }, + { url = "https://files.pythonhosted.org/packages/67/49/dd074d831f00e589537e07a0725cf0e220d1f0d5d8e85ad5bbff251c45aa/bcrypt-5.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48f753100931605686f74e27a7b49238122aa761a9aefe9373265b8b7aa43ea4", size = 268544, upload-time = "2025-09-25T19:49:30.39Z" }, + { url = "https://files.pythonhosted.org/packages/f5/91/50ccba088b8c474545b034a1424d05195d9fcbaaf802ab8bfe2be5a4e0d7/bcrypt-5.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f70aadb7a809305226daedf75d90379c397b094755a710d7014b8b117df1ebbf", size = 271787, upload-time = "2025-09-25T19:49:32.144Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e7/d7dba133e02abcda3b52087a7eea8c0d4f64d3e593b4fffc10c31b7061f3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:744d3c6b164caa658adcb72cb8cc9ad9b4b75c7db507ab4bc2480474a51989da", size = 269753, upload-time = "2025-09-25T19:49:33.885Z" }, + { url = "https://files.pythonhosted.org/packages/33/fc/5b145673c4b8d01018307b5c2c1fc87a6f5a436f0ad56607aee389de8ee3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a28bc05039bdf3289d757f49d616ab3efe8cf40d8e8001ccdd621cd4f98f4fc9", size = 289587, upload-time = "2025-09-25T19:49:35.144Z" }, + { url = "https://files.pythonhosted.org/packages/27/d7/1ff22703ec6d4f90e62f1a5654b8867ef96bafb8e8102c2288333e1a6ca6/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:7f277a4b3390ab4bebe597800a90da0edae882c6196d3038a73adf446c4f969f", size = 272178, upload-time = "2025-09-25T19:49:36.793Z" }, + { url = "https://files.pythonhosted.org/packages/c8/88/815b6d558a1e4d40ece04a2f84865b0fef233513bd85fd0e40c294272d62/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:79cfa161eda8d2ddf29acad370356b47f02387153b11d46042e93a0a95127493", size = 269295, upload-time = "2025-09-25T19:49:38.164Z" }, + { url = "https://files.pythonhosted.org/packages/51/8c/e0db387c79ab4931fc89827d37608c31cc57b6edc08ccd2386139028dc0d/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a5393eae5722bcef046a990b84dff02b954904c36a194f6cfc817d7dca6c6f0b", size = 271700, upload-time = "2025-09-25T19:49:39.917Z" }, + { url = "https://files.pythonhosted.org/packages/06/83/1570edddd150f572dbe9fc00f6203a89fc7d4226821f67328a85c330f239/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f4c94dec1b5ab5d522750cb059bb9409ea8872d4494fd152b53cca99f1ddd8c", size = 334034, upload-time = "2025-09-25T19:49:41.227Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f2/ea64e51a65e56ae7a8a4ec236c2bfbdd4b23008abd50ac33fbb2d1d15424/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0cae4cb350934dfd74c020525eeae0a5f79257e8a201c0c176f4b84fdbf2a4b4", size = 352766, upload-time = "2025-09-25T19:49:43.08Z" }, + { url = "https://files.pythonhosted.org/packages/d7/d4/1a388d21ee66876f27d1a1f41287897d0c0f1712ef97d395d708ba93004c/bcrypt-5.0.0-cp314-cp314t-win32.whl", hash = "sha256:b17366316c654e1ad0306a6858e189fc835eca39f7eb2cafd6aaca8ce0c40a2e", size = 152449, upload-time = "2025-09-25T19:49:44.971Z" }, + { url = "https://files.pythonhosted.org/packages/3f/61/3291c2243ae0229e5bca5d19f4032cecad5dfb05a2557169d3a69dc0ba91/bcrypt-5.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:92864f54fb48b4c718fc92a32825d0e42265a627f956bc0361fe869f1adc3e7d", size = 149310, upload-time = "2025-09-25T19:49:46.162Z" }, + { url = "https://files.pythonhosted.org/packages/3e/89/4b01c52ae0c1a681d4021e5dd3e45b111a8fb47254a274fa9a378d8d834b/bcrypt-5.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dd19cf5184a90c873009244586396a6a884d591a5323f0e8a5922560718d4993", size = 143761, upload-time = "2025-09-25T19:49:47.345Z" }, + { url = "https://files.pythonhosted.org/packages/84/29/6237f151fbfe295fe3e074ecc6d44228faa1e842a81f6d34a02937ee1736/bcrypt-5.0.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b", size = 494553, upload-time = "2025-09-25T19:49:49.006Z" }, + { url = "https://files.pythonhosted.org/packages/45/b6/4c1205dde5e464ea3bd88e8742e19f899c16fa8916fb8510a851fae985b5/bcrypt-5.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb", size = 275009, upload-time = "2025-09-25T19:49:50.581Z" }, + { url = "https://files.pythonhosted.org/packages/3b/71/427945e6ead72ccffe77894b2655b695ccf14ae1866cd977e185d606dd2f/bcrypt-5.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef", size = 278029, upload-time = "2025-09-25T19:49:52.533Z" }, + { url = "https://files.pythonhosted.org/packages/17/72/c344825e3b83c5389a369c8a8e58ffe1480b8a699f46c127c34580c4666b/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd", size = 275907, upload-time = "2025-09-25T19:49:54.709Z" }, + { url = "https://files.pythonhosted.org/packages/0b/7e/d4e47d2df1641a36d1212e5c0514f5291e1a956a7749f1e595c07a972038/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd", size = 296500, upload-time = "2025-09-25T19:49:56.013Z" }, + { url = "https://files.pythonhosted.org/packages/0f/c3/0ae57a68be2039287ec28bc463b82e4b8dc23f9d12c0be331f4782e19108/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464", size = 278412, upload-time = "2025-09-25T19:49:57.356Z" }, + { url = "https://files.pythonhosted.org/packages/45/2b/77424511adb11e6a99e3a00dcc7745034bee89036ad7d7e255a7e47be7d8/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75", size = 275486, upload-time = "2025-09-25T19:49:59.116Z" }, + { url = "https://files.pythonhosted.org/packages/43/0a/405c753f6158e0f3f14b00b462d8bca31296f7ecfc8fc8bc7919c0c7d73a/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff", size = 277940, upload-time = "2025-09-25T19:50:00.869Z" }, + { url = "https://files.pythonhosted.org/packages/62/83/b3efc285d4aadc1fa83db385ec64dcfa1707e890eb42f03b127d66ac1b7b/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4", size = 310776, upload-time = "2025-09-25T19:50:02.393Z" }, + { url = "https://files.pythonhosted.org/packages/95/7d/47ee337dacecde6d234890fe929936cb03ebc4c3a7460854bbd9c97780b8/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb", size = 312922, upload-time = "2025-09-25T19:50:04.232Z" }, + { url = "https://files.pythonhosted.org/packages/d6/3a/43d494dfb728f55f4e1cf8fd435d50c16a2d75493225b54c8d06122523c6/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c", size = 341367, upload-time = "2025-09-25T19:50:05.559Z" }, + { url = "https://files.pythonhosted.org/packages/55/ab/a0727a4547e383e2e22a630e0f908113db37904f58719dc48d4622139b5c/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb", size = 359187, upload-time = "2025-09-25T19:50:06.916Z" }, + { url = "https://files.pythonhosted.org/packages/1b/bb/461f352fdca663524b4643d8b09e8435b4990f17fbf4fea6bc2a90aa0cc7/bcrypt-5.0.0-cp38-abi3-win32.whl", hash = "sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538", size = 153752, upload-time = "2025-09-25T19:50:08.515Z" }, + { url = "https://files.pythonhosted.org/packages/41/aa/4190e60921927b7056820291f56fc57d00d04757c8b316b2d3c0d1d6da2c/bcrypt-5.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9", size = 150881, upload-time = "2025-09-25T19:50:09.742Z" }, + { url = "https://files.pythonhosted.org/packages/54/12/cd77221719d0b39ac0b55dbd39358db1cd1246e0282e104366ebbfb8266a/bcrypt-5.0.0-cp38-abi3-win_arm64.whl", hash = "sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980", size = 144931, upload-time = "2025-09-25T19:50:11.016Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ba/2af136406e1c3839aea9ecadc2f6be2bcd1eff255bd451dd39bcf302c47a/bcrypt-5.0.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a", size = 495313, upload-time = "2025-09-25T19:50:12.309Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ee/2f4985dbad090ace5ad1f7dd8ff94477fe089b5fab2040bd784a3d5f187b/bcrypt-5.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191", size = 275290, upload-time = "2025-09-25T19:50:13.673Z" }, + { url = "https://files.pythonhosted.org/packages/e4/6e/b77ade812672d15cf50842e167eead80ac3514f3beacac8902915417f8b7/bcrypt-5.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254", size = 278253, upload-time = "2025-09-25T19:50:15.089Z" }, + { url = "https://files.pythonhosted.org/packages/36/c4/ed00ed32f1040f7990dac7115f82273e3c03da1e1a1587a778d8cea496d8/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db", size = 276084, upload-time = "2025-09-25T19:50:16.699Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c4/fa6e16145e145e87f1fa351bbd54b429354fd72145cd3d4e0c5157cf4c70/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac", size = 297185, upload-time = "2025-09-25T19:50:18.525Z" }, + { url = "https://files.pythonhosted.org/packages/24/b4/11f8a31d8b67cca3371e046db49baa7c0594d71eb40ac8121e2fc0888db0/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822", size = 278656, upload-time = "2025-09-25T19:50:19.809Z" }, + { url = "https://files.pythonhosted.org/packages/ac/31/79f11865f8078e192847d2cb526e3fa27c200933c982c5b2869720fa5fce/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8", size = 275662, upload-time = "2025-09-25T19:50:21.567Z" }, + { url = "https://files.pythonhosted.org/packages/d4/8d/5e43d9584b3b3591a6f9b68f755a4da879a59712981ef5ad2a0ac1379f7a/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a", size = 278240, upload-time = "2025-09-25T19:50:23.305Z" }, + { url = "https://files.pythonhosted.org/packages/89/48/44590e3fc158620f680a978aafe8f87a4c4320da81ed11552f0323aa9a57/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1", size = 311152, upload-time = "2025-09-25T19:50:24.597Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/e4fbfc46f14f47b0d20493669a625da5827d07e8a88ee460af6cd9768b44/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42", size = 313284, upload-time = "2025-09-25T19:50:26.268Z" }, + { url = "https://files.pythonhosted.org/packages/25/ae/479f81d3f4594456a01ea2f05b132a519eff9ab5768a70430fa1132384b1/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10", size = 341643, upload-time = "2025-09-25T19:50:28.02Z" }, + { url = "https://files.pythonhosted.org/packages/df/d2/36a086dee1473b14276cd6ea7f61aef3b2648710b5d7f1c9e032c29b859f/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172", size = 359698, upload-time = "2025-09-25T19:50:31.347Z" }, + { url = "https://files.pythonhosted.org/packages/c0/f6/688d2cd64bfd0b14d805ddb8a565e11ca1fb0fd6817175d58b10052b6d88/bcrypt-5.0.0-cp39-abi3-win32.whl", hash = "sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683", size = 153725, upload-time = "2025-09-25T19:50:34.384Z" }, + { url = "https://files.pythonhosted.org/packages/9f/b9/9d9a641194a730bda138b3dfe53f584d61c58cd5230e37566e83ec2ffa0d/bcrypt-5.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2", size = 150912, upload-time = "2025-09-25T19:50:35.69Z" }, + { url = "https://files.pythonhosted.org/packages/27/44/d2ef5e87509158ad2187f4dd0852df80695bb1ee0cfe0a684727b01a69e0/bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927", size = 144953, upload-time = "2025-09-25T19:50:37.32Z" }, +] + +[[package]] +name = "bleak" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dbus-fast", marker = "sys_platform == 'linux'" }, + { name = "pyobjc-core", marker = "sys_platform == 'darwin'" }, + { name = "pyobjc-framework-corebluetooth", marker = "sys_platform == 'darwin'" }, + { name = "pyobjc-framework-libdispatch", marker = "sys_platform == 'darwin'" }, + { name = "winrt-runtime", marker = "sys_platform == 'win32'" }, + { name = "winrt-windows-devices-bluetooth", marker = "sys_platform == 'win32'" }, + { name = "winrt-windows-devices-bluetooth-advertisement", marker = "sys_platform == 'win32'" }, + { name = "winrt-windows-devices-bluetooth-genericattributeprofile", marker = "sys_platform == 'win32'" }, + { name = "winrt-windows-devices-enumeration", marker = "sys_platform == 'win32'" }, + { name = "winrt-windows-devices-radios", marker = "sys_platform == 'win32'" }, + { name = "winrt-windows-foundation", marker = "sys_platform == 'win32'" }, + { name = "winrt-windows-foundation-collections", marker = "sys_platform == 'win32'" }, + { name = "winrt-windows-storage-streams", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/45/8a/5acbd4da6a5a301fab56ff6d6e9e6b6945e6e4a2d1d213898c21b1d3a19b/bleak-2.1.1.tar.gz", hash = "sha256:4600cc5852f2392ce886547e127623f188e689489c5946d422172adf80635cf9", size = 120634, upload-time = "2025-12-31T20:43:28.697Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/fe/22aec895f040c1e457d6e6fcc79286fbb17d54602600ab2a58837bec7be1/bleak-2.1.1-py3-none-any.whl", hash = "sha256:61ac1925073b580c896a92a8c404088c5e5ec9dc3c5bd6fc17554a15779d83de", size = 141258, upload-time = "2025-12-31T20:43:27.302Z" }, +] + +[[package]] +name = "bleak-retry-connector" +version = "4.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "bleak" }, + { name = "bluetooth-adapters", marker = "sys_platform == 'linux'" }, + { name = "dbus-fast", marker = "sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/32/02/494f6a642b2aed04123a07071944681a0776259d656212133c10d0fb4b27/bleak_retry_connector-4.6.0.tar.gz", hash = "sha256:0645ca814fe9e0f2e0716ffdae5e54de25de75de6197145a1784f20f58e76844", size = 18732, upload-time = "2026-03-07T03:06:36.882Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/23/38/091973e8b930a551b5454f9a4d6fd3fed55a73d28769e75b1d7761b46572/bleak_retry_connector-4.6.0-py3-none-any.whl", hash = "sha256:6b5ecab9dee8a67b1e64cccec47ffa8c55737b86550c366e02d11ce003d57ebd", size = 18731, upload-time = "2026-03-07T03:06:35.472Z" }, +] + +[[package]] +name = "bluetooth-adapters" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiooui" }, + { name = "bleak" }, + { name = "dbus-fast", marker = "sys_platform == 'linux'" }, + { name = "uart-devices" }, + { name = "usb-devices" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/8a/b37a52f5243cf8bd30cc7e25c1128750d129db15a7b2c7ef107ddb7429f9/bluetooth_adapters-2.1.1.tar.gz", hash = "sha256:f289e0f08814f74252a28862f488283680584744430d7eac45820f9c20ba041a", size = 17234, upload-time = "2025-09-12T17:18:48.906Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/11/8f344d5379df2d31eea73052128136702630f28b5fb55a8d30250c8112e2/bluetooth_adapters-2.1.1-py3-none-any.whl", hash = "sha256:1f93026e530dcb2f4515a92955fa6f85934f928b009a181ee57edc8b4affd25c", size = 20276, upload-time = "2025-09-12T17:18:47.763Z" }, +] + +[[package]] +name = "bluetooth-auto-recovery" +version = "1.5.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "bluetooth-adapters" }, + { name = "btsocket" }, + { name = "pyric" }, + { name = "usb-devices" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ab/8b/1d6f338ced9b47965382c8f1325bf3d22be65e6f838b4e465227c52d333c/bluetooth_auto_recovery-1.5.3.tar.gz", hash = "sha256:0b36aa6be84474fff81c1ce328f016a6553272ac47050b1fa60f03e36a8db46d", size = 12798, upload-time = "2025-09-13T17:17:09.273Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/ab/518f14a3c3e43c34c638485cd29bfa80bd35da5a151a434f7ac3c86e1e83/bluetooth_auto_recovery-1.5.3-py3-none-any.whl", hash = "sha256:5d66b859a54ef20fdf1bd3cf6762f153e86651babe716836770da9d9c47b01c4", size = 11750, upload-time = "2025-09-13T17:17:07.681Z" }, +] + +[[package]] +name = "bluetooth-data-tools" +version = "1.28.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/77/90/46dfa84798ca4e5c2f66d9a756bb207ed21d89a32b8ef8d3ea89e079455f/bluetooth_data_tools-1.28.4.tar.gz", hash = "sha256:0617a879c30e0410c3506e263ee9e9bd51b06d64db13b4ad0bfd765f794b756f", size = 16488, upload-time = "2025-10-28T15:23:05.289Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/93/03ba322c36376532f3133c7d56bc80dd2859df9c78aa52de19ed7627b9fb/bluetooth_data_tools-1.28.4-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:81c6c2b7c844d30a0fd1527e38e47cdb0f350c0297fb11516bfa255b37241fbf", size = 383677, upload-time = "2025-10-28T15:36:33.905Z" }, + { url = "https://files.pythonhosted.org/packages/a4/2e/74e7b4857ba10a524cd00177fbd78764c50810fb523020b7d5cbf0fdbac8/bluetooth_data_tools-1.28.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:99896987f48d762694cdea7a8a7091031cdf40dc65e8e934a7422746264865ba", size = 385890, upload-time = "2025-10-28T15:36:35.196Z" }, + { url = "https://files.pythonhosted.org/packages/a0/8d/35bc257ed1935e55ac7bfb56172a290f094f8b982f65f68aadb0f03ceab5/bluetooth_data_tools-1.28.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ebac9d60786bd7c403f472fcda871cb74d0aef0d4e713715af2e5e095d15a625", size = 412966, upload-time = "2025-10-28T15:36:36.398Z" }, + { url = "https://files.pythonhosted.org/packages/7f/2c/2ed3dff30e85029e631a211d93e11aab7dc4a899d9c96a15eca18541e66e/bluetooth_data_tools-1.28.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:06a2750e49fed2310ddd7b51388b891cbd4457ee7392f3a17c387591cbb74ace", size = 129887, upload-time = "2025-10-28T15:36:38.429Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ff/824f3b34b0fab4e57efd457ea8b9bdf41d279a44eb19cfde5ede159d90b3/bluetooth_data_tools-1.28.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f5dccfe237237463c3d74fa425aaf8a9d78b26a5177e6777b10039699313a335", size = 412909, upload-time = "2025-10-28T15:36:39.552Z" }, + { url = "https://files.pythonhosted.org/packages/eb/88/f2217b88c32b470e5f9dc9fbce38f24b9548c0776be7c5e0db1249c42ae9/bluetooth_data_tools-1.28.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4a071d7af2614af9a00f65063adaacda94f4357cc2dfedda7057c005f437dacd", size = 413005, upload-time = "2025-10-28T15:36:41.572Z" }, + { url = "https://files.pythonhosted.org/packages/6d/da/cde7557972e50cbb8a92291cc34e5de07f0e2bbc28a388151e738e9efe84/bluetooth_data_tools-1.28.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:bd84c4f2d24103ff43044ccd3cf8c0e05ee285bd6f9eddc9772b2069cfb6c271", size = 131426, upload-time = "2025-10-28T15:36:42.645Z" }, + { url = "https://files.pythonhosted.org/packages/a3/7f/925fd28e2695ba810b1f7f02f2d5ab8635a11d6e415ac4039446145f9e48/bluetooth_data_tools-1.28.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8e3895dbbdad2a39de5a7b36a4ddb5e2f8ad38029628e3eddfde31a5c56d81b5", size = 414955, upload-time = "2025-10-28T15:36:43.775Z" }, + { url = "https://files.pythonhosted.org/packages/03/b1/cbf3a2c8404862605e487200d45aefb130c0c0ce3df219230155eeb95199/bluetooth_data_tools-1.28.4-cp314-cp314-win32.whl", hash = "sha256:1d9b22827144329e3ca1348b8473fe6b48127707a81539848232847c4cb08e1d", size = 286157, upload-time = "2025-10-28T15:36:45.171Z" }, + { url = "https://files.pythonhosted.org/packages/c7/68/eb168b986eebc0c98fb0a6a521719a33d218bafc46c48c5279322d15e9b2/bluetooth_data_tools-1.28.4-cp314-cp314-win_amd64.whl", hash = "sha256:04c91b6f2dfaa419652356488fa50dfb0f54cb20b1f90f9e5e1d6911430d9688", size = 286151, upload-time = "2025-10-28T15:36:46.414Z" }, + { url = "https://files.pythonhosted.org/packages/57/37/f2ce46cf82b32d6a62171753a2d6550d633af5b27f0ad2c2ff5fef1980a4/bluetooth_data_tools-1.28.4-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:a44c48bf163606a2915d12ffb3ac1b022548e566c062907f98266e8a19c6173c", size = 488264, upload-time = "2025-10-28T15:36:47.582Z" }, + { url = "https://files.pythonhosted.org/packages/ba/32/c3bbee5b7c66190f0729e71fefe44adb49e7bb94407b110d972d817561a2/bluetooth_data_tools-1.28.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b76a6c8c6d610844c8712cecf207c16373cad3361fb29e6dbcdcb12f2700bcb9", size = 492846, upload-time = "2025-10-28T15:36:48.846Z" }, + { url = "https://files.pythonhosted.org/packages/71/5c/751028e7fab907c0c2fc7749f088d19bf2b938e5cdd7d0e68ddbcacb7b79/bluetooth_data_tools-1.28.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:61b827616075ecee12c374b04b14d81575403849435bf915c9a3812138f046b7", size = 548041, upload-time = "2025-10-28T15:36:50.066Z" }, + { url = "https://files.pythonhosted.org/packages/77/02/4d8f4a9cb2a2beaaedda71fb3017f6bb5eb3de08656adfb9a8a773ec7912/bluetooth_data_tools-1.28.4-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:525646baaf5f741ea071aa4babd8313e4e9bae75b46757c4b0f6aeadfa71b52a", size = 517778, upload-time = "2025-10-28T15:36:51.628Z" }, + { url = "https://files.pythonhosted.org/packages/89/9b/90d65fed47b531b0f0f4c8be012d35c97950c97fb7b74501bfe938c7f7ca/bluetooth_data_tools-1.28.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2c06b66ef406c68a95052a87640fa34d402d31120a8b0b62f99080169621697a", size = 546643, upload-time = "2025-10-28T15:36:52.971Z" }, + { url = "https://files.pythonhosted.org/packages/d3/6b/c15363ccfc208a34cd6d627610350c72633e2a6764d37d04a1340fb13844/bluetooth_data_tools-1.28.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:152232c157f2f6d8265c0141e56423bbedd9e84044fb815e69d786a73fb195c7", size = 548872, upload-time = "2025-10-28T15:36:54.332Z" }, + { url = "https://files.pythonhosted.org/packages/85/2a/b649eeea14e6330da34f42dc1407424cd929af3ae1298b5651459d0c4bb8/bluetooth_data_tools-1.28.4-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:243163028565955e73f19c0c462b619fd0f56e31875c30f5f3af2a48b43adb67", size = 524783, upload-time = "2025-10-28T15:36:55.815Z" }, + { url = "https://files.pythonhosted.org/packages/0e/6e/96c762f8a49f65348748d72c515c5a79c9179c685d3e02694c380bdafa72/bluetooth_data_tools-1.28.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0a1608bca00e24b6ca3b98ed7d797a03988a44285d74286e045446c8161a62ea", size = 551318, upload-time = "2025-10-28T15:36:57.062Z" }, + { url = "https://files.pythonhosted.org/packages/d7/7d/796cbb679d19425ff381ebbe7a5238217b3f3e5c65b9a46e7be57ba105fc/bluetooth_data_tools-1.28.4-cp314-cp314t-win32.whl", hash = "sha256:25918d7ece36f29ebde21aaf70f3c1e1c63501206dd1c7713bbd8911d43d0dce", size = 286158, upload-time = "2025-10-28T15:36:58.717Z" }, + { url = "https://files.pythonhosted.org/packages/c3/74/639329ba05947018ba928162042dfb162a31b85757e27591bb6aa96c1f42/bluetooth_data_tools-1.28.4-cp314-cp314t-win_amd64.whl", hash = "sha256:276528d7ea2419ccab14ddf044ee7f65a5b6bc35c49264625560ad0c184dc67a", size = 286163, upload-time = "2025-10-28T15:36:59.861Z" }, +] + +[[package]] +name = "boolean-py" +version = "5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c4/cf/85379f13b76f3a69bca86b60237978af17d6aa0bc5998978c3b8cf05abb2/boolean_py-5.0.tar.gz", hash = "sha256:60cbc4bad079753721d32649545505362c754e121570ada4658b852a3a318d95", size = 37047, upload-time = "2025-04-03T10:39:49.734Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/ca/78d423b324b8d77900030fa59c4aa9054261ef0925631cd2501dd015b7b7/boolean_py-5.0-py3-none-any.whl", hash = "sha256:ef28a70bd43115208441b53a045d1549e2f0ec6e3d08a9d142cbc41c1938e8d9", size = 26577, upload-time = "2025-04-03T10:39:48.449Z" }, +] + +[[package]] +name = "boto3" +version = "1.42.73" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, + { name = "jmespath" }, + { name = "s3transfer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/8b/d00575be514744ca4839e7d85bf4a8a3c7b6b4574433291e58d14c68ae09/boto3-1.42.73.tar.gz", hash = "sha256:d37b58d6cd452ca808dd6823ae19ca65b6244096c5125ef9052988b337298bae", size = 112775, upload-time = "2026-03-20T19:39:52.814Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/aa/05/1fcf03d90abaa3d0b42a6bfd10231dd709493ecbacf794aa2eea5eae6841/boto3-1.42.73-py3-none-any.whl", hash = "sha256:1f81b79b873f130eeab14bb556417a7c66d38f3396b7f2fe3b958b3f9094f455", size = 140556, upload-time = "2026-03-20T19:39:50.298Z" }, +] + +[[package]] +name = "botocore" +version = "1.42.73" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jmespath" }, + { name = "python-dateutil" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/23/0c88ca116ef63b1ae77c901cd5d2095d22a8dbde9e80df74545db4a061b4/botocore-1.42.73.tar.gz", hash = "sha256:575858641e4949aaf2af1ced145b8524529edf006d075877af6b82ff96ad854c", size = 15008008, upload-time = "2026-03-20T19:39:40.082Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/65/971f3d55015f4d133a6ff3ad74cd39f4b8dd8f53f7775a3c2ad378ea5145/botocore-1.42.73-py3-none-any.whl", hash = "sha256:7b62e2a12f7a1b08eb7360eecd23bb16fe3b7ab7f5617cf91b25476c6f86a0fe", size = 14681861, upload-time = "2026-03-20T19:39:35.341Z" }, +] + +[[package]] +name = "btsocket" +version = "0.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/b1/0ae262ecf936f5d2472ff7387087ca674e3b88d8c76b3e0e55fbc0c6e956/btsocket-0.3.0.tar.gz", hash = "sha256:7ea495de0ff883f0d9f8eea59c72ca7fed492994df668fe476b84d814a147a0d", size = 19563, upload-time = "2024-06-10T07:05:27.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/2b/9bf3481131a24cb29350d69469448349362f6102bed9ae4a0a5bb228d731/btsocket-0.3.0-py2.py3-none-any.whl", hash = "sha256:949821c1b580a88e73804ad610f5173d6ae258e7b4e389da4f94d614344f1a9c", size = 14807, upload-time = "2024-06-10T07:05:26.381Z" }, +] + +[[package]] +name = "certifi" +version = "2026.2.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/60/e3bec1881450851b087e301bedc3daa9377a4d45f1c26aa90b0b235e38aa/charset_normalizer-3.4.6.tar.gz", hash = "sha256:1ae6b62897110aa7c79ea2f5dd38d1abca6db663687c0b1ad9aed6f6bae3d9d6", size = 143363, upload-time = "2026-03-15T18:53:25.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/6f/ffe1e1259f384594063ea1869bfb6be5cdb8bc81020fc36c3636bc8302a1/charset_normalizer-3.4.6-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:9cc6e6d9e571d2f863fa77700701dae73ed5f78881efc8b3f9a4398772ff53e8", size = 294458, upload-time = "2026-03-15T18:51:41.134Z" }, + { url = "https://files.pythonhosted.org/packages/56/60/09bb6c13a8c1016c2ed5c6a6488e4ffef506461aa5161662bd7636936fb1/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef5960d965e67165d75b7c7ffc60a83ec5abfc5c11b764ec13ea54fbef8b4421", size = 199277, upload-time = "2026-03-15T18:51:42.953Z" }, + { url = "https://files.pythonhosted.org/packages/00/50/dcfbb72a5138bbefdc3332e8d81a23494bf67998b4b100703fd15fa52d81/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b3694e3f87f8ac7ce279d4355645b3c878d24d1424581b46282f24b92f5a4ae2", size = 218758, upload-time = "2026-03-15T18:51:44.339Z" }, + { url = "https://files.pythonhosted.org/packages/03/b3/d79a9a191bb75f5aa81f3aaaa387ef29ce7cb7a9e5074ba8ea095cc073c2/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5d11595abf8dd942a77883a39d81433739b287b6aa71620f15164f8096221b30", size = 215299, upload-time = "2026-03-15T18:51:45.871Z" }, + { url = "https://files.pythonhosted.org/packages/76/7e/bc8911719f7084f72fd545f647601ea3532363927f807d296a8c88a62c0d/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7bda6eebafd42133efdca535b04ccb338ab29467b3f7bf79569883676fc628db", size = 206811, upload-time = "2026-03-15T18:51:47.308Z" }, + { url = "https://files.pythonhosted.org/packages/e2/40/c430b969d41dda0c465aa36cc7c2c068afb67177bef50905ac371b28ccc7/charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:bbc8c8650c6e51041ad1be191742b8b421d05bbd3410f43fa2a00c8db87678e8", size = 193706, upload-time = "2026-03-15T18:51:48.849Z" }, + { url = "https://files.pythonhosted.org/packages/48/15/e35e0590af254f7df984de1323640ef375df5761f615b6225ba8deb9799a/charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22c6f0c2fbc31e76c3b8a86fba1a56eda6166e238c29cdd3d14befdb4a4e4815", size = 202706, upload-time = "2026-03-15T18:51:50.257Z" }, + { url = "https://files.pythonhosted.org/packages/5e/bd/f736f7b9cc5e93a18b794a50346bb16fbfd6b37f99e8f306f7951d27c17c/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7edbed096e4a4798710ed6bc75dcaa2a21b68b6c356553ac4823c3658d53743a", size = 202497, upload-time = "2026-03-15T18:51:52.012Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ba/2cc9e3e7dfdf7760a6ed8da7446d22536f3d0ce114ac63dee2a5a3599e62/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:7f9019c9cb613f084481bd6a100b12e1547cf2efe362d873c2e31e4035a6fa43", size = 193511, upload-time = "2026-03-15T18:51:53.723Z" }, + { url = "https://files.pythonhosted.org/packages/9e/cb/5be49b5f776e5613be07298c80e1b02a2d900f7a7de807230595c85a8b2e/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:58c948d0d086229efc484fe2f30c2d382c86720f55cd9bc33591774348ad44e0", size = 220133, upload-time = "2026-03-15T18:51:55.333Z" }, + { url = "https://files.pythonhosted.org/packages/83/43/99f1b5dad345accb322c80c7821071554f791a95ee50c1c90041c157ae99/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:419a9d91bd238052642a51938af8ac05da5b3343becde08d5cdeab9046df9ee1", size = 203035, upload-time = "2026-03-15T18:51:56.736Z" }, + { url = "https://files.pythonhosted.org/packages/87/9a/62c2cb6a531483b55dddff1a68b3d891a8b498f3ca555fbcf2978e804d9d/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5273b9f0b5835ff0350c0828faea623c68bfa65b792720c453e22b25cc72930f", size = 216321, upload-time = "2026-03-15T18:51:58.17Z" }, + { url = "https://files.pythonhosted.org/packages/6e/79/94a010ff81e3aec7c293eb82c28f930918e517bc144c9906a060844462eb/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0e901eb1049fdb80f5bd11ed5ea1e498ec423102f7a9b9e4645d5b8204ff2815", size = 208973, upload-time = "2026-03-15T18:51:59.998Z" }, + { url = "https://files.pythonhosted.org/packages/2a/57/4ecff6d4ec8585342f0c71bc03efaa99cb7468f7c91a57b105bcd561cea8/charset_normalizer-3.4.6-cp314-cp314-win32.whl", hash = "sha256:b4ff1d35e8c5bd078be89349b6f3a845128e685e751b6ea1169cf2160b344c4d", size = 144610, upload-time = "2026-03-15T18:52:02.213Z" }, + { url = "https://files.pythonhosted.org/packages/80/94/8434a02d9d7f168c25767c64671fead8d599744a05d6a6c877144c754246/charset_normalizer-3.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:74119174722c4349af9708993118581686f343adc1c8c9c007d59be90d077f3f", size = 154962, upload-time = "2026-03-15T18:52:03.658Z" }, + { url = "https://files.pythonhosted.org/packages/46/4c/48f2cdbfd923026503dfd67ccea45c94fd8fe988d9056b468579c66ed62b/charset_normalizer-3.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:e5bcc1a1ae744e0bb59641171ae53743760130600da8db48cbb6e4918e186e4e", size = 143595, upload-time = "2026-03-15T18:52:05.123Z" }, + { url = "https://files.pythonhosted.org/packages/31/93/8878be7569f87b14f1d52032946131bcb6ebbd8af3e20446bc04053dc3f1/charset_normalizer-3.4.6-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ad8faf8df23f0378c6d527d8b0b15ea4a2e23c89376877c598c4870d1b2c7866", size = 314828, upload-time = "2026-03-15T18:52:06.831Z" }, + { url = "https://files.pythonhosted.org/packages/06/b6/fae511ca98aac69ecc35cde828b0a3d146325dd03d99655ad38fc2cc3293/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f5ea69428fa1b49573eef0cc44a1d43bebd45ad0c611eb7d7eac760c7ae771bc", size = 208138, upload-time = "2026-03-15T18:52:08.239Z" }, + { url = "https://files.pythonhosted.org/packages/54/57/64caf6e1bf07274a1e0b7c160a55ee9e8c9ec32c46846ce59b9c333f7008/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:06a7e86163334edfc5d20fe104db92fcd666e5a5df0977cb5680a506fe26cc8e", size = 224679, upload-time = "2026-03-15T18:52:10.043Z" }, + { url = "https://files.pythonhosted.org/packages/aa/cb/9ff5a25b9273ef160861b41f6937f86fae18b0792fe0a8e75e06acb08f1d/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e1f6e2f00a6b8edb562826e4632e26d063ac10307e80f7461f7de3ad8ef3f077", size = 223475, upload-time = "2026-03-15T18:52:11.854Z" }, + { url = "https://files.pythonhosted.org/packages/fc/97/440635fc093b8d7347502a377031f9605a1039c958f3cd18dcacffb37743/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95b52c68d64c1878818687a473a10547b3292e82b6f6fe483808fb1468e2f52f", size = 215230, upload-time = "2026-03-15T18:52:13.325Z" }, + { url = "https://files.pythonhosted.org/packages/cd/24/afff630feb571a13f07c8539fbb502d2ab494019492aaffc78ef41f1d1d0/charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:7504e9b7dc05f99a9bbb4525c67a2c155073b44d720470a148b34166a69c054e", size = 199045, upload-time = "2026-03-15T18:52:14.752Z" }, + { url = "https://files.pythonhosted.org/packages/e5/17/d1399ecdaf7e0498c327433e7eefdd862b41236a7e484355b8e0e5ebd64b/charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:172985e4ff804a7ad08eebec0a1640ece87ba5041d565fff23c8f99c1f389484", size = 211658, upload-time = "2026-03-15T18:52:16.278Z" }, + { url = "https://files.pythonhosted.org/packages/b5/38/16baa0affb957b3d880e5ac2144caf3f9d7de7bc4a91842e447fbb5e8b67/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4be9f4830ba8741527693848403e2c457c16e499100963ec711b1c6f2049b7c7", size = 210769, upload-time = "2026-03-15T18:52:17.782Z" }, + { url = "https://files.pythonhosted.org/packages/05/34/c531bc6ac4c21da9ddfddb3107be2287188b3ea4b53b70fc58f2a77ac8d8/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:79090741d842f564b1b2827c0b82d846405b744d31e84f18d7a7b41c20e473ff", size = 201328, upload-time = "2026-03-15T18:52:19.553Z" }, + { url = "https://files.pythonhosted.org/packages/fa/73/a5a1e9ca5f234519c1953608a03fe109c306b97fdfb25f09182babad51a7/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:87725cfb1a4f1f8c2fc9890ae2f42094120f4b44db9360be5d99a4c6b0e03a9e", size = 225302, upload-time = "2026-03-15T18:52:21.043Z" }, + { url = "https://files.pythonhosted.org/packages/ba/f6/cd782923d112d296294dea4bcc7af5a7ae0f86ab79f8fefbda5526b6cfc0/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:fcce033e4021347d80ed9c66dcf1e7b1546319834b74445f561d2e2221de5659", size = 211127, upload-time = "2026-03-15T18:52:22.491Z" }, + { url = "https://files.pythonhosted.org/packages/0e/c5/0b6898950627af7d6103a449b22320372c24c6feda91aa24e201a478d161/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ca0276464d148c72defa8bb4390cce01b4a0e425f3b50d1435aa6d7a18107602", size = 222840, upload-time = "2026-03-15T18:52:24.113Z" }, + { url = "https://files.pythonhosted.org/packages/7d/25/c4bba773bef442cbdc06111d40daa3de5050a676fa26e85090fc54dd12f0/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:197c1a244a274bb016dd8b79204850144ef77fe81c5b797dc389327adb552407", size = 216890, upload-time = "2026-03-15T18:52:25.541Z" }, + { url = "https://files.pythonhosted.org/packages/35/1a/05dacadb0978da72ee287b0143097db12f2e7e8d3ffc4647da07a383b0b7/charset_normalizer-3.4.6-cp314-cp314t-win32.whl", hash = "sha256:2a24157fa36980478dd1770b585c0f30d19e18f4fb0c47c13aa568f871718579", size = 155379, upload-time = "2026-03-15T18:52:27.05Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7a/d269d834cb3a76291651256f3b9a5945e81d0a49ab9f4a498964e83c0416/charset_normalizer-3.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:cd5e2801c89992ed8c0a3f0293ae83c159a60d9a5d685005383ef4caca77f2c4", size = 169043, upload-time = "2026-03-15T18:52:28.502Z" }, + { url = "https://files.pythonhosted.org/packages/23/06/28b29fba521a37a8932c6a84192175c34d49f84a6d4773fa63d05f9aff22/charset_normalizer-3.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:47955475ac79cc504ef2704b192364e51d0d473ad452caedd0002605f780101c", size = 148523, upload-time = "2026-03-15T18:52:29.956Z" }, + { url = "https://files.pythonhosted.org/packages/2a/68/687187c7e26cb24ccbd88e5069f5ef00eba804d36dde11d99aad0838ab45/charset_normalizer-3.4.6-py3-none-any.whl", hash = "sha256:947cf925bc916d90adba35a64c82aace04fa39b46b52d4630ece166655905a69", size = 61455, upload-time = "2026-03-15T18:53:23.833Z" }, +] + +[[package]] +name = "ciso8601" +version = "2.3.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c1/8a/075724aea06c98626109bfd670c27c248c87b9ba33e637f069bf46e8c4c3/ciso8601-2.3.3.tar.gz", hash = "sha256:db5d78d9fb0de8686fbad1c1c2d168ed52efb6e8bf8774ae26226e5034a46dae", size = 31909, upload-time = "2025-08-20T16:31:33.51Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/3a/54ad0ae2257870076b4990545a8f16221470fecea0aa7a4e1f39506db8c5/ciso8601-2.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:82db4047d74d8b1d129e7a8da578518729912c3bd19cb71541b147e41f426381", size = 16115, upload-time = "2025-08-20T16:30:54.971Z" }, + { url = "https://files.pythonhosted.org/packages/23/fb/9fe767d44520691e2b706769466852fbdeb44a82dc294c2766bce1049d22/ciso8601-2.3.3-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:a553f3fc03a2ed5ca6f5716de0b314fa166461df01b45d8b36043ccac3a5e79f", size = 24214, upload-time = "2025-08-20T16:30:56.359Z" }, + { url = "https://files.pythonhosted.org/packages/a1/ac/984fd3948f372c46c436a2b48da43f4fb7bc6f156a6f4bc858adaab79d42/ciso8601-2.3.3-cp314-cp314-macosx_11_0_x86_64.whl", hash = "sha256:ff59c26083b7bef6df4f0d96e4b649b484806d3d7bcc2de14ad43147c3aafb04", size = 15929, upload-time = "2025-08-20T16:30:58.352Z" }, + { url = "https://files.pythonhosted.org/packages/de/3a/5572917d4e0bec2c1ef0eda8652f9dc8d1850d29d3eef9e5e82ffe5d6791/ciso8601-2.3.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:99a1fa5a730790431d0bfcd1f3a6387f60cddc6853d8dcc5c2e140cd4d67a928", size = 41578, upload-time = "2025-08-20T16:30:59.351Z" }, + { url = "https://files.pythonhosted.org/packages/5e/cf/07321ce5cf099b98de0c02cd4bab4818610da69743003e94c8fb6e8a59cb/ciso8601-2.3.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c35265c1b0bd2ac30ed29b49818dd38b0d1dfda43086af605d8b91722727dec0", size = 42085, upload-time = "2025-08-20T16:31:00.338Z" }, + { url = "https://files.pythonhosted.org/packages/d3/c7/3c521d6779ee433d9596eb3fcded79549bbe371843f25e62006c04f74dc9/ciso8601-2.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:aa9df2f84ab25454f14df92b2dd4f9aae03dbfa581565a716b3e89b8e2110c03", size = 41313, upload-time = "2025-08-20T16:31:01.313Z" }, + { url = "https://files.pythonhosted.org/packages/f9/93/efd40db0d6b512be1cbe4e7e750882c2e88f580e17f35b3e9cc9c23004b5/ciso8601-2.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:32e06a35eb251cfc4bbe01a858c598da0a160e4ad7f42ff52477157ceaf48061", size = 41443, upload-time = "2025-08-20T16:31:02.357Z" }, + { url = "https://files.pythonhosted.org/packages/21/8e/515f9404faa39af8df5e2b899cafbca5dbe7cd2ffe5cc124ef393ffdaf1c/ciso8601-2.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:7657ba9730dc1340d73b9e61eca14f341c41dd308128c808b8b084d2b85bc03e", size = 17977, upload-time = "2025-08-20T16:31:03.429Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.10.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/14/70/025b179c993f019105b79575ac6edb5e084fb0f0e63f15cdebef4e454fb5/coverage-7.10.6.tar.gz", hash = "sha256:f644a3ae5933a552a29dbb9aa2f90c677a875f80ebea028e5a52a4f429044b90", size = 823736, upload-time = "2025-08-29T15:35:16.668Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/aa/76cf0b5ec00619ef208da4689281d48b57f2c7fde883d14bf9441b74d59f/coverage-7.10.6-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6008a021907be8c4c02f37cdc3ffb258493bdebfeaf9a839f9e71dfdc47b018e", size = 217331, upload-time = "2025-08-29T15:34:20.846Z" }, + { url = "https://files.pythonhosted.org/packages/65/91/8e41b8c7c505d398d7730206f3cbb4a875a35ca1041efc518051bfce0f6b/coverage-7.10.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5e75e37f23eb144e78940b40395b42f2321951206a4f50e23cfd6e8a198d3ceb", size = 217607, upload-time = "2025-08-29T15:34:22.433Z" }, + { url = "https://files.pythonhosted.org/packages/87/7f/f718e732a423d442e6616580a951b8d1ec3575ea48bcd0e2228386805e79/coverage-7.10.6-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0f7cb359a448e043c576f0da00aa8bfd796a01b06aa610ca453d4dde09cc1034", size = 248663, upload-time = "2025-08-29T15:34:24.425Z" }, + { url = "https://files.pythonhosted.org/packages/e6/52/c1106120e6d801ac03e12b5285e971e758e925b6f82ee9b86db3aa10045d/coverage-7.10.6-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c68018e4fc4e14b5668f1353b41ccf4bc83ba355f0e1b3836861c6f042d89ac1", size = 251197, upload-time = "2025-08-29T15:34:25.906Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ec/3a8645b1bb40e36acde9c0609f08942852a4af91a937fe2c129a38f2d3f5/coverage-7.10.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cd4b2b0707fc55afa160cd5fc33b27ccbf75ca11d81f4ec9863d5793fc6df56a", size = 252551, upload-time = "2025-08-29T15:34:27.337Z" }, + { url = "https://files.pythonhosted.org/packages/a1/70/09ecb68eeb1155b28a1d16525fd3a9b65fbe75337311a99830df935d62b6/coverage-7.10.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4cec13817a651f8804a86e4f79d815b3b28472c910e099e4d5a0e8a3b6a1d4cb", size = 250553, upload-time = "2025-08-29T15:34:29.065Z" }, + { url = "https://files.pythonhosted.org/packages/c6/80/47df374b893fa812e953b5bc93dcb1427a7b3d7a1a7d2db33043d17f74b9/coverage-7.10.6-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f2a6a8e06bbda06f78739f40bfb56c45d14eb8249d0f0ea6d4b3d48e1f7c695d", size = 248486, upload-time = "2025-08-29T15:34:30.897Z" }, + { url = "https://files.pythonhosted.org/packages/4a/65/9f98640979ecee1b0d1a7164b589de720ddf8100d1747d9bbdb84be0c0fb/coverage-7.10.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:081b98395ced0d9bcf60ada7661a0b75f36b78b9d7e39ea0790bb4ed8da14747", size = 249981, upload-time = "2025-08-29T15:34:32.365Z" }, + { url = "https://files.pythonhosted.org/packages/1f/55/eeb6603371e6629037f47bd25bef300387257ed53a3c5fdb159b7ac8c651/coverage-7.10.6-cp314-cp314-win32.whl", hash = "sha256:6937347c5d7d069ee776b2bf4e1212f912a9f1f141a429c475e6089462fcecc5", size = 220054, upload-time = "2025-08-29T15:34:34.124Z" }, + { url = "https://files.pythonhosted.org/packages/15/d1/a0912b7611bc35412e919a2cd59ae98e7ea3b475e562668040a43fb27897/coverage-7.10.6-cp314-cp314-win_amd64.whl", hash = "sha256:adec1d980fa07e60b6ef865f9e5410ba760e4e1d26f60f7e5772c73b9a5b0713", size = 220851, upload-time = "2025-08-29T15:34:35.651Z" }, + { url = "https://files.pythonhosted.org/packages/ef/2d/11880bb8ef80a45338e0b3e0725e4c2d73ffbb4822c29d987078224fd6a5/coverage-7.10.6-cp314-cp314-win_arm64.whl", hash = "sha256:a80f7aef9535442bdcf562e5a0d5a5538ce8abe6bb209cfbf170c462ac2c2a32", size = 219429, upload-time = "2025-08-29T15:34:37.16Z" }, + { url = "https://files.pythonhosted.org/packages/83/c0/1f00caad775c03a700146f55536ecd097a881ff08d310a58b353a1421be0/coverage-7.10.6-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:0de434f4fbbe5af4fa7989521c655c8c779afb61c53ab561b64dcee6149e4c65", size = 218080, upload-time = "2025-08-29T15:34:38.919Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c4/b1c5d2bd7cc412cbeb035e257fd06ed4e3e139ac871d16a07434e145d18d/coverage-7.10.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6e31b8155150c57e5ac43ccd289d079eb3f825187d7c66e755a055d2c85794c6", size = 218293, upload-time = "2025-08-29T15:34:40.425Z" }, + { url = "https://files.pythonhosted.org/packages/3f/07/4468d37c94724bf6ec354e4ec2f205fda194343e3e85fd2e59cec57e6a54/coverage-7.10.6-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:98cede73eb83c31e2118ae8d379c12e3e42736903a8afcca92a7218e1f2903b0", size = 259800, upload-time = "2025-08-29T15:34:41.996Z" }, + { url = "https://files.pythonhosted.org/packages/82/d8/f8fb351be5fee31690cd8da768fd62f1cfab33c31d9f7baba6cd8960f6b8/coverage-7.10.6-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f863c08f4ff6b64fa8045b1e3da480f5374779ef187f07b82e0538c68cb4ff8e", size = 261965, upload-time = "2025-08-29T15:34:43.61Z" }, + { url = "https://files.pythonhosted.org/packages/e8/70/65d4d7cfc75c5c6eb2fed3ee5cdf420fd8ae09c4808723a89a81d5b1b9c3/coverage-7.10.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b38261034fda87be356f2c3f42221fdb4171c3ce7658066ae449241485390d5", size = 264220, upload-time = "2025-08-29T15:34:45.387Z" }, + { url = "https://files.pythonhosted.org/packages/98/3c/069df106d19024324cde10e4ec379fe2fb978017d25e97ebee23002fbadf/coverage-7.10.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e93b1476b79eae849dc3872faeb0bf7948fd9ea34869590bc16a2a00b9c82a7", size = 261660, upload-time = "2025-08-29T15:34:47.288Z" }, + { url = "https://files.pythonhosted.org/packages/fc/8a/2974d53904080c5dc91af798b3a54a4ccb99a45595cc0dcec6eb9616a57d/coverage-7.10.6-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ff8a991f70f4c0cf53088abf1e3886edcc87d53004c7bb94e78650b4d3dac3b5", size = 259417, upload-time = "2025-08-29T15:34:48.779Z" }, + { url = "https://files.pythonhosted.org/packages/30/38/9616a6b49c686394b318974d7f6e08f38b8af2270ce7488e879888d1e5db/coverage-7.10.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ac765b026c9f33044419cbba1da913cfb82cca1b60598ac1c7a5ed6aac4621a0", size = 260567, upload-time = "2025-08-29T15:34:50.718Z" }, + { url = "https://files.pythonhosted.org/packages/76/16/3ed2d6312b371a8cf804abf4e14895b70e4c3491c6e53536d63fd0958a8d/coverage-7.10.6-cp314-cp314t-win32.whl", hash = "sha256:441c357d55f4936875636ef2cfb3bee36e466dcf50df9afbd398ce79dba1ebb7", size = 220831, upload-time = "2025-08-29T15:34:52.653Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e5/d38d0cb830abede2adb8b147770d2a3d0e7fecc7228245b9b1ae6c24930a/coverage-7.10.6-cp314-cp314t-win_amd64.whl", hash = "sha256:073711de3181b2e204e4870ac83a7c4853115b42e9cd4d145f2231e12d670930", size = 221950, upload-time = "2025-08-29T15:34:54.212Z" }, + { url = "https://files.pythonhosted.org/packages/f4/51/e48e550f6279349895b0ffcd6d2a690e3131ba3a7f4eafccc141966d4dea/coverage-7.10.6-cp314-cp314t-win_arm64.whl", hash = "sha256:137921f2bac5559334ba66122b753db6dc5d1cf01eb7b64eb412bb0d064ef35b", size = 219969, upload-time = "2025-08-29T15:34:55.83Z" }, + { url = "https://files.pythonhosted.org/packages/44/0c/50db5379b615854b5cf89146f8f5bd1d5a9693d7f3a987e269693521c404/coverage-7.10.6-py3-none-any.whl", hash = "sha256:92c4ecf6bf11b2e85fd4d8204814dc26e6a19f0c9d938c207c5cb0eadfcabbe3", size = 208986, upload-time = "2025-08-29T15:35:14.506Z" }, +] + +[[package]] +name = "cronsim" +version = "2.7" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/1a/02f105147f7f2e06ed4f734ff5a6439590bb275a53dd91fc73df6312298a/cronsim-2.7-py3-none-any.whl", hash = "sha256:1e1431fa08c51dc7f72e67e571c7c7a09af26420169b607badd4ca9677ffad1e", size = 14213, upload-time = "2025-10-21T16:38:20.431Z" }, +] + +[[package]] +name = "cryptography" +version = "46.0.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/47/93/ac8f3d5ff04d54bc814e961a43ae5b0b146154c89c61b47bb07557679b18/cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5", size = 750652, upload-time = "2026-04-08T01:57:54.692Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/5d/4a8f770695d73be252331e60e526291e3df0c9b27556a90a6b47bccca4c2/cryptography-46.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4", size = 7179869, upload-time = "2026-04-08T01:56:17.157Z" }, + { url = "https://files.pythonhosted.org/packages/5f/45/6d80dc379b0bbc1f9d1e429f42e4cb9e1d319c7a8201beffd967c516ea01/cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325", size = 4275492, upload-time = "2026-04-08T01:56:19.36Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9a/1765afe9f572e239c3469f2cb429f3ba7b31878c893b246b4b2994ffe2fe/cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308", size = 4426670, upload-time = "2026-04-08T01:56:21.415Z" }, + { url = "https://files.pythonhosted.org/packages/8f/3e/af9246aaf23cd4ee060699adab1e47ced3f5f7e7a8ffdd339f817b446462/cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77", size = 4280275, upload-time = "2026-04-08T01:56:23.539Z" }, + { url = "https://files.pythonhosted.org/packages/0f/54/6bbbfc5efe86f9d71041827b793c24811a017c6ac0fd12883e4caa86b8ed/cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1", size = 4928402, upload-time = "2026-04-08T01:56:25.624Z" }, + { url = "https://files.pythonhosted.org/packages/2d/cf/054b9d8220f81509939599c8bdbc0c408dbd2bdd41688616a20731371fe0/cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef", size = 4459985, upload-time = "2026-04-08T01:56:27.309Z" }, + { url = "https://files.pythonhosted.org/packages/f9/46/4e4e9c6040fb01c7467d47217d2f882daddeb8828f7df800cb806d8a2288/cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de", size = 3990652, upload-time = "2026-04-08T01:56:29.095Z" }, + { url = "https://files.pythonhosted.org/packages/36/5f/313586c3be5a2fbe87e4c9a254207b860155a8e1f3cca99f9910008e7d08/cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83", size = 4279805, upload-time = "2026-04-08T01:56:30.928Z" }, + { url = "https://files.pythonhosted.org/packages/69/33/60dfc4595f334a2082749673386a4d05e4f0cf4df8248e63b2c3437585f2/cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb", size = 4892883, upload-time = "2026-04-08T01:56:32.614Z" }, + { url = "https://files.pythonhosted.org/packages/c7/0b/333ddab4270c4f5b972f980adef4faa66951a4aaf646ca067af597f15563/cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b", size = 4459756, upload-time = "2026-04-08T01:56:34.306Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/633913398b43b75f1234834170947957c6b623d1701ffc7a9600da907e89/cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85", size = 4410244, upload-time = "2026-04-08T01:56:35.977Z" }, + { url = "https://files.pythonhosted.org/packages/10/f2/19ceb3b3dc14009373432af0c13f46aa08e3ce334ec6eff13492e1812ccd/cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e", size = 4674868, upload-time = "2026-04-08T01:56:38.034Z" }, + { url = "https://files.pythonhosted.org/packages/1a/bb/a5c213c19ee94b15dfccc48f363738633a493812687f5567addbcbba9f6f/cryptography-46.0.7-cp311-abi3-win32.whl", hash = "sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457", size = 3026504, upload-time = "2026-04-08T01:56:39.666Z" }, + { url = "https://files.pythonhosted.org/packages/2b/02/7788f9fefa1d060ca68717c3901ae7fffa21ee087a90b7f23c7a603c32ae/cryptography-46.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b", size = 3488363, upload-time = "2026-04-08T01:56:41.893Z" }, + { url = "https://files.pythonhosted.org/packages/7b/56/15619b210e689c5403bb0540e4cb7dbf11a6bf42e483b7644e471a2812b3/cryptography-46.0.7-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:d151173275e1728cf7839aaa80c34fe550c04ddb27b34f48c232193df8db5842", size = 7119671, upload-time = "2026-04-08T01:56:44Z" }, + { url = "https://files.pythonhosted.org/packages/74/66/e3ce040721b0b5599e175ba91ab08884c75928fbeb74597dd10ef13505d2/cryptography-46.0.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c", size = 4268551, upload-time = "2026-04-08T01:56:46.071Z" }, + { url = "https://files.pythonhosted.org/packages/03/11/5e395f961d6868269835dee1bafec6a1ac176505a167f68b7d8818431068/cryptography-46.0.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902", size = 4408887, upload-time = "2026-04-08T01:56:47.718Z" }, + { url = "https://files.pythonhosted.org/packages/40/53/8ed1cf4c3b9c8e611e7122fb56f1c32d09e1fff0f1d77e78d9ff7c82653e/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d", size = 4271354, upload-time = "2026-04-08T01:56:49.312Z" }, + { url = "https://files.pythonhosted.org/packages/50/46/cf71e26025c2e767c5609162c866a78e8a2915bbcfa408b7ca495c6140c4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022", size = 4905845, upload-time = "2026-04-08T01:56:50.916Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ea/01276740375bac6249d0a971ebdf6b4dc9ead0ee0a34ef3b5a88c1a9b0d4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce", size = 4444641, upload-time = "2026-04-08T01:56:52.882Z" }, + { url = "https://files.pythonhosted.org/packages/3d/4c/7d258f169ae71230f25d9f3d06caabcff8c3baf0978e2b7d65e0acac3827/cryptography-46.0.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f", size = 3967749, upload-time = "2026-04-08T01:56:54.597Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2a/2ea0767cad19e71b3530e4cad9605d0b5e338b6a1e72c37c9c1ceb86c333/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99", size = 4270942, upload-time = "2026-04-08T01:56:56.416Z" }, + { url = "https://files.pythonhosted.org/packages/41/3d/fe14df95a83319af25717677e956567a105bb6ab25641acaa093db79975d/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1", size = 4871079, upload-time = "2026-04-08T01:56:58.31Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/4a479e0f36f8f378d397f4eab4c850b4ffb79a2f0d58704b8fa0703ddc11/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2", size = 4443999, upload-time = "2026-04-08T01:57:00.508Z" }, + { url = "https://files.pythonhosted.org/packages/28/17/b59a741645822ec6d04732b43c5d35e4ef58be7bfa84a81e5ae6f05a1d33/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e", size = 4399191, upload-time = "2026-04-08T01:57:02.654Z" }, + { url = "https://files.pythonhosted.org/packages/59/6a/bb2e166d6d0e0955f1e9ff70f10ec4b2824c9cfcdb4da772c7dd69cc7d80/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee", size = 4655782, upload-time = "2026-04-08T01:57:04.592Z" }, + { url = "https://files.pythonhosted.org/packages/95/b6/3da51d48415bcb63b00dc17c2eff3a651b7c4fed484308d0f19b30e8cb2c/cryptography-46.0.7-cp314-cp314t-win32.whl", hash = "sha256:fdd1736fed309b4300346f88f74cd120c27c56852c3838cab416e7a166f67298", size = 3002227, upload-time = "2026-04-08T01:57:06.91Z" }, + { url = "https://files.pythonhosted.org/packages/32/a8/9f0e4ed57ec9cebe506e58db11ae472972ecb0c659e4d52bbaee80ca340a/cryptography-46.0.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e06acf3c99be55aa3b516397fe42f5855597f430add9c17fa46bf2e0fb34c9bb", size = 3475332, upload-time = "2026-04-08T01:57:08.807Z" }, + { url = "https://files.pythonhosted.org/packages/a7/7f/cd42fc3614386bc0c12f0cb3c4ae1fc2bbca5c9662dfed031514911d513d/cryptography-46.0.7-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4", size = 7165618, upload-time = "2026-04-08T01:57:10.645Z" }, + { url = "https://files.pythonhosted.org/packages/a5/d0/36a49f0262d2319139d2829f773f1b97ef8aef7f97e6e5bd21455e5a8fb5/cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7", size = 4270628, upload-time = "2026-04-08T01:57:12.885Z" }, + { url = "https://files.pythonhosted.org/packages/8a/6c/1a42450f464dda6ffbe578a911f773e54dd48c10f9895a23a7e88b3e7db5/cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832", size = 4415405, upload-time = "2026-04-08T01:57:14.923Z" }, + { url = "https://files.pythonhosted.org/packages/9a/92/4ed714dbe93a066dc1f4b4581a464d2d7dbec9046f7c8b7016f5286329e2/cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163", size = 4272715, upload-time = "2026-04-08T01:57:16.638Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e6/a26b84096eddd51494bba19111f8fffe976f6a09f132706f8f1bf03f51f7/cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2", size = 4918400, upload-time = "2026-04-08T01:57:19.021Z" }, + { url = "https://files.pythonhosted.org/packages/c7/08/ffd537b605568a148543ac3c2b239708ae0bd635064bab41359252ef88ed/cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067", size = 4450634, upload-time = "2026-04-08T01:57:21.185Z" }, + { url = "https://files.pythonhosted.org/packages/16/01/0cd51dd86ab5b9befe0d031e276510491976c3a80e9f6e31810cce46c4ad/cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0", size = 3985233, upload-time = "2026-04-08T01:57:22.862Z" }, + { url = "https://files.pythonhosted.org/packages/92/49/819d6ed3a7d9349c2939f81b500a738cb733ab62fbecdbc1e38e83d45e12/cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba", size = 4271955, upload-time = "2026-04-08T01:57:24.814Z" }, + { url = "https://files.pythonhosted.org/packages/80/07/ad9b3c56ebb95ed2473d46df0847357e01583f4c52a85754d1a55e29e4d0/cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006", size = 4879888, upload-time = "2026-04-08T01:57:26.88Z" }, + { url = "https://files.pythonhosted.org/packages/b8/c7/201d3d58f30c4c2bdbe9b03844c291feb77c20511cc3586daf7edc12a47b/cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0", size = 4449961, upload-time = "2026-04-08T01:57:29.068Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ef/649750cbf96f3033c3c976e112265c33906f8e462291a33d77f90356548c/cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85", size = 4401696, upload-time = "2026-04-08T01:57:31.029Z" }, + { url = "https://files.pythonhosted.org/packages/41/52/a8908dcb1a389a459a29008c29966c1d552588d4ae6d43f3a1a4512e0ebe/cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e", size = 4664256, upload-time = "2026-04-08T01:57:33.144Z" }, + { url = "https://files.pythonhosted.org/packages/4b/fa/f0ab06238e899cc3fb332623f337a7364f36f4bb3f2534c2bb95a35b132c/cryptography-46.0.7-cp38-abi3-win32.whl", hash = "sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246", size = 3013001, upload-time = "2026-04-08T01:57:34.933Z" }, + { url = "https://files.pythonhosted.org/packages/d2/f1/00ce3bde3ca542d1acd8f8cfa38e446840945aa6363f9b74746394b14127/cryptography-46.0.7-cp38-abi3-win_amd64.whl", hash = "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3", size = 3472985, upload-time = "2026-04-08T01:57:36.714Z" }, +] + +[[package]] +name = "dbus-fast" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3d/f7/36515d10e85ab6d6193edbabbcae974c25d6fbabb8ead84cfd2b4ee8eaf6/dbus_fast-4.0.0.tar.gz", hash = "sha256:e1d3ee49a4a81524d7caaa2d5a31fc71075a1c977b661df958cee24bef86b8fe", size = 75082, upload-time = "2026-02-01T20:56:27.85Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f0/c5/5fee1e5d59b2856db9da8372c67ed7699b262108a4540d5858f34a67699f/dbus_fast-4.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9e53d7e19d2433f2ca1d811856e4b80a3b3126f361703e5caf6e7f086a03b994", size = 804142, upload-time = "2026-02-01T21:05:33.5Z" }, + { url = "https://files.pythonhosted.org/packages/37/3e/91a9339278ccee8be93df337c69703dd9d3f5b8fc97dadb2f8a3ff06f6c0/dbus_fast-4.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b430760c925e0b695b6f1a3f21f6e57954807cab4704a3bc4bc5f311261016b", size = 846011, upload-time = "2026-02-01T21:05:34.875Z" }, + { url = "https://files.pythonhosted.org/packages/34/bf/bab415e523fc67a3b1d246a677dcac1198b5cf4d89ae594b2b25b71c02c7/dbus_fast-4.0.0-cp314-cp314-manylinux_2_41_x86_64.whl", hash = "sha256:2818d76da8291202779fe8cb23edc62488786eee791f332c2c40350552288d8b", size = 844116, upload-time = "2026-02-01T20:56:26.447Z" }, + { url = "https://files.pythonhosted.org/packages/d4/c8/5cc517508d102242656c06acb3980decd243e56470f9cb51dc736a9197ef/dbus_fast-4.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0b2aaf80991734e2bbff60b0f57b70322668acccb8bb15a0380ca80b8f8c5d72", size = 810621, upload-time = "2026-02-01T21:05:36.208Z" }, + { url = "https://files.pythonhosted.org/packages/47/84/686bd523c9966bbd9c0705984782fcb33d3a2aae75a2ebbb34b37aca1f3b/dbus_fast-4.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93a864c9e39ab03988c95e2cd9368a4b6560887d53a197037dfc73e7d966b690", size = 853111, upload-time = "2026-02-01T21:05:37.775Z" }, + { url = "https://files.pythonhosted.org/packages/9e/2d/26a2a2120c32bf6a61b81a19d7d20cd440c79f1c4679b04af85af93bc0e4/dbus_fast-4.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c71b369f8fd743c0d03e5fd566ff5d886cb5ad7f3d187f36185a372096a2a096", size = 1534384, upload-time = "2026-02-01T21:05:41.636Z" }, + { url = "https://files.pythonhosted.org/packages/d0/53/916c2bbb6601108f694b7c37c71c650ef8d06c2ed282a704b5c8cca67edf/dbus_fast-4.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ffc16ee344e68a907a40327074bca736086897f2e783541086eedb5e6855f3f0", size = 1610347, upload-time = "2026-02-01T21:05:43.086Z" }, + { url = "https://files.pythonhosted.org/packages/ad/f6/05eeb374a02f63b0e29b1ee2073569e8cf42f655970a651f938bcdbe7eae/dbus_fast-4.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1f8f4b0f8af730c39bbb83de1e299e706fbd7f7f3955764471213b013fa59516", size = 1549395, upload-time = "2026-02-01T21:05:45.159Z" }, + { url = "https://files.pythonhosted.org/packages/a4/87/d03a718e7bfdbbebaa4b6a66ba5bb069bc00a84e5ad176d8198cc785cd42/dbus_fast-4.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f6af190d8306f1bd506740c39701f5c211aa31ac660a3fcb401ebb97d33166c7", size = 1627620, upload-time = "2026-02-01T21:05:46.878Z" }, +] + +[[package]] +name = "dill" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/81/e1/56027a71e31b02ddc53c7d65b01e68edf64dea2932122fe7746a516f75d5/dill-0.4.1.tar.gz", hash = "sha256:423092df4182177d4d8ba8290c8a5b640c66ab35ec7da59ccfa00f6fa3eea5fa", size = 187315, upload-time = "2026-01-19T02:36:56.85Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl", hash = "sha256:1e1ce33e978ae97fcfcff5638477032b801c46c7c65cf717f95fbc2248f79a9d", size = 120019, upload-time = "2026-01-19T02:36:55.663Z" }, +] + +[[package]] +name = "envs" +version = "1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3c/7f/2098df91ff1499860935b4276ea0c27d3234170b03f803a8b9c97e42f0e9/envs-1.4.tar.gz", hash = "sha256:9d8435c6985d1cdd68299e04c58e2bdb8ae6cf66b2596a8079e6f9a93f2a0398", size = 9230, upload-time = "2021-12-09T22:16:52.616Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/bc/f8c625a084b6074c2295f7eab967f868d424bb8ca30c7a656024b26fe04e/envs-1.4-py3-none-any.whl", hash = "sha256:4a1fcf85e4d4443e77c348ff7cdd3bfc4c0178b181d447057de342e4172e5ed1", size = 10988, upload-time = "2021-12-09T22:16:51.127Z" }, +] + +[[package]] +name = "execnet" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd", size = 166622, upload-time = "2025-11-12T09:56:37.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" }, +] + +[[package]] +name = "fnv-hash-fast" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "fnvhash" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8d/d3/a7af16d8e1d43d3c1d1cbde7901229773b62696e377f89c6834183680ede/fnv_hash_fast-2.0.0.tar.gz", hash = "sha256:e830b6316be36b2aa629f8a2c6b83833f1f3cc8ecf1da93c7e9e934844826646", size = 5718, upload-time = "2026-03-15T00:17:39.143Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/6e/03f61d06f3ed61b10d0a07ac921d077045052db6fdd6847107e4c3405727/fnv_hash_fast-2.0.0-cp314-cp314-manylinux_2_41_x86_64.whl", hash = "sha256:ddf25f8f8b5cf54f068eaf5e3700c96f6c8da679e6efaf2084e57bdbad9d92ee", size = 7756, upload-time = "2026-03-15T00:17:37.392Z" }, +] + +[[package]] +name = "fnvhash" +version = "0.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/43/30d2dd2b14621b2004f658ba5335e5a6f5a9c1338ed37678d7fd247b7a9c/fnvhash-0.2.1.tar.gz", hash = "sha256:0c7e885f44c8f06de07f442befebc590ee9ca0cc88846681f608496284ce9cd5", size = 19057, upload-time = "2025-05-05T16:59:10.819Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/92/7c8abc21a1de7159013c0b0bd2ecf06530959bb14fd5c3bf0045e788c6d9/fnvhash-0.2.1-py3-none-any.whl", hash = "sha256:00fab14bec841e4cb29b4fd2ed9358f8bf9f4600d9d8149cde27a191193a33e8", size = 18115, upload-time = "2025-05-05T16:59:09.269Z" }, +] + +[[package]] +name = "freezegun" +version = "1.5.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c7/75/0455fa5029507a2150da59db4f165fbc458ff8bb1c4f4d7e8037a14ad421/freezegun-1.5.2.tar.gz", hash = "sha256:a54ae1d2f9c02dbf42e02c18a3ab95ab4295818b549a34dac55592d72a905181", size = 34855, upload-time = "2025-05-24T12:38:47.051Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/b2/68d4c9b6431121b6b6aa5e04a153cac41dcacc79600ed6e2e7c3382156f5/freezegun-1.5.2-py3-none-any.whl", hash = "sha256:5aaf3ba229cda57afab5bd311f0108d86b6fb119ae89d2cd9c43ec8c1733c85b", size = 18715, upload-time = "2025-05-24T12:38:45.274Z" }, +] + +[[package]] +name = "frozenlist" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" }, + { url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload-time = "2025-10-06T05:37:11.993Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload-time = "2025-10-06T05:37:13.194Z" }, + { url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload-time = "2025-10-06T05:37:14.577Z" }, + { url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload-time = "2025-10-06T05:37:15.781Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload-time = "2025-10-06T05:37:17.037Z" }, + { url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload-time = "2025-10-06T05:37:18.221Z" }, + { url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload-time = "2025-10-06T05:37:19.771Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload-time = "2025-10-06T05:37:20.969Z" }, + { url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload-time = "2025-10-06T05:37:22.252Z" }, + { url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload-time = "2025-10-06T05:37:23.5Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload-time = "2025-10-06T05:37:29.373Z" }, + { url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload-time = "2025-10-06T05:37:30.792Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload-time = "2025-10-06T05:37:32.127Z" }, + { url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload-time = "2025-10-06T05:37:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload-time = "2025-10-06T05:37:36.107Z" }, + { url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload-time = "2025-10-06T05:37:37.663Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload-time = "2025-10-06T05:37:39.261Z" }, + { url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload-time = "2025-10-06T05:37:43.213Z" }, + { url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload-time = "2025-10-06T05:37:45.337Z" }, + { url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload-time = "2025-10-06T05:37:46.657Z" }, + { url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload-time = "2025-10-06T05:37:47.946Z" }, + { url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload-time = "2025-10-06T05:37:49.499Z" }, + { url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload-time = "2025-10-06T05:37:50.745Z" }, + { url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" }, + { url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" }, + { url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, +] + +[[package]] +name = "grpcio" +version = "1.78.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/8a/3d098f35c143a89520e568e6539cc098fcd294495910e359889ce8741c84/grpcio-1.78.0.tar.gz", hash = "sha256:7382b95189546f375c174f53a5fa873cef91c4b8005faa05cc5b3beea9c4f1c5", size = 12852416, upload-time = "2026-02-06T09:57:18.093Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/29/f2/b56e43e3c968bfe822fa6ce5bca10d5c723aa40875b48791ce1029bb78c7/grpcio-1.78.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:e87cbc002b6f440482b3519e36e1313eb5443e9e9e73d6a52d43bd2004fcfd8e", size = 5920591, upload-time = "2026-02-06T09:56:20.758Z" }, + { url = "https://files.pythonhosted.org/packages/5d/81/1f3b65bd30c334167bfa8b0d23300a44e2725ce39bba5b76a2460d85f745/grpcio-1.78.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:c41bc64626db62e72afec66b0c8a0da76491510015417c127bfc53b2fe6d7f7f", size = 11813685, upload-time = "2026-02-06T09:56:24.315Z" }, + { url = "https://files.pythonhosted.org/packages/0e/1c/bbe2f8216a5bd3036119c544d63c2e592bdf4a8ec6e4a1867592f4586b26/grpcio-1.78.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8dfffba826efcf366b1e3ccc37e67afe676f290e13a3b48d31a46739f80a8724", size = 6487803, upload-time = "2026-02-06T09:56:27.367Z" }, + { url = "https://files.pythonhosted.org/packages/16/5c/a6b2419723ea7ddce6308259a55e8e7593d88464ce8db9f4aa857aba96fa/grpcio-1.78.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:74be1268d1439eaaf552c698cdb11cd594f0c49295ae6bb72c34ee31abbe611b", size = 7173206, upload-time = "2026-02-06T09:56:29.876Z" }, + { url = "https://files.pythonhosted.org/packages/df/1e/b8801345629a415ea7e26c83d75eb5dbe91b07ffe5210cc517348a8d4218/grpcio-1.78.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:be63c88b32e6c0f1429f1398ca5c09bc64b0d80950c8bb7807d7d7fb36fb84c7", size = 6693826, upload-time = "2026-02-06T09:56:32.305Z" }, + { url = "https://files.pythonhosted.org/packages/34/84/0de28eac0377742679a510784f049738a80424b17287739fc47d63c2439e/grpcio-1.78.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:3c586ac70e855c721bda8f548d38c3ca66ac791dc49b66a8281a1f99db85e452", size = 7277897, upload-time = "2026-02-06T09:56:34.915Z" }, + { url = "https://files.pythonhosted.org/packages/ca/9c/ad8685cfe20559a9edb66f735afdcb2b7d3de69b13666fdfc542e1916ebd/grpcio-1.78.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:35eb275bf1751d2ffbd8f57cdbc46058e857cf3971041521b78b7db94bdaf127", size = 8252404, upload-time = "2026-02-06T09:56:37.553Z" }, + { url = "https://files.pythonhosted.org/packages/3c/05/33a7a4985586f27e1de4803887c417ec7ced145ebd069bc38a9607059e2b/grpcio-1.78.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:207db540302c884b8848036b80db352a832b99dfdf41db1eb554c2c2c7800f65", size = 7696837, upload-time = "2026-02-06T09:56:40.173Z" }, + { url = "https://files.pythonhosted.org/packages/73/77/7382241caf88729b106e49e7d18e3116216c778e6a7e833826eb96de22f7/grpcio-1.78.0-cp314-cp314-win32.whl", hash = "sha256:57bab6deef2f4f1ca76cc04565df38dc5713ae6c17de690721bdf30cb1e0545c", size = 4142439, upload-time = "2026-02-06T09:56:43.258Z" }, + { url = "https://files.pythonhosted.org/packages/48/b2/b096ccce418882fbfda4f7496f9357aaa9a5af1896a9a7f60d9f2b275a06/grpcio-1.78.0-cp314-cp314-win_amd64.whl", hash = "sha256:dce09d6116df20a96acfdbf85e4866258c3758180e8c49845d6ba8248b6d0bbb", size = 4929852, upload-time = "2026-02-06T09:56:45.885Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "habluetooth" +version = "5.10.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "async-interrupt" }, + { name = "bleak" }, + { name = "bleak-retry-connector" }, + { name = "bluetooth-adapters" }, + { name = "bluetooth-auto-recovery" }, + { name = "bluetooth-data-tools" }, + { name = "btsocket" }, + { name = "dbus-fast", marker = "sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/10/c06b3610410931bec2bbc8ab8db0be77b8ef770a15feff683cc765756786/habluetooth-5.10.2.tar.gz", hash = "sha256:1ae243e27e1a9faf552a844431e617bf1a93ff10ee3212ec5e601d2e8d21097a", size = 49840, upload-time = "2026-03-15T09:08:36.959Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/d7/a044a60ebd7c6cb9a5d6961503a6b40e3d812ace12a9705dc1b1222a468c/habluetooth-5.10.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8f8555995a0cd2fc36ab211642caa3ce2f803e530b3dea1b3af2dba2b2dd432f", size = 572538, upload-time = "2026-03-15T09:40:30.971Z" }, + { url = "https://files.pythonhosted.org/packages/90/47/3f5372c31579c27e93a1d441c9748414fc46a5fef60b98dcd44b3dd91d31/habluetooth-5.10.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:616285f3705050e97278f26597896257bccc02c1f30f809bc805c22defc37132", size = 673476, upload-time = "2026-03-15T09:40:32.802Z" }, + { url = "https://files.pythonhosted.org/packages/b5/fc/fa1e2166fa8ede368bedcab6df8cb486d76763e043633699e2cac8653a4c/habluetooth-5.10.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5bc0f86d1b46969e286c027be3c051ac80b0471259bc3033cde5ba814204a523", size = 640804, upload-time = "2026-03-15T09:40:34.353Z" }, + { url = "https://files.pythonhosted.org/packages/72/b0/30fd29f45f1049278e5df60c4b65e63316fd702cbb256b97278869b840b4/habluetooth-5.10.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:19a46f5e65d72307ce3034ea5ebccb5e20eb4ec323320ca99a7b6df6cccd0614", size = 711562, upload-time = "2026-03-15T09:40:36.245Z" }, + { url = "https://files.pythonhosted.org/packages/8f/7a/930622e7c119887d0ca46b72248cd22cf5f88ea20b8835a9d95b10591223/habluetooth-5.10.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8414de442f14277b3542647242fea6ec7bcd5726b8531a1746af7a08a7542cf0", size = 682424, upload-time = "2026-03-15T09:40:37.85Z" }, + { url = "https://files.pythonhosted.org/packages/d0/52/d2e9be00ba1d546fc1159f2474b740e3cf75a5307d164a63f0fad6e21ee7/habluetooth-5.10.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:de0dcb3092c772636abfd57f31c63c73e350ef4b92955ce89a4de6fed6423b2c", size = 646608, upload-time = "2026-03-15T09:40:39.585Z" }, + { url = "https://files.pythonhosted.org/packages/9e/80/1fd3045b709b8357746aac45c2811920e2b3435f291fd0f456563963d89e/habluetooth-5.10.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:01e71d64c0fa3d2a3b49c43a871933ce12b5e7b31a6edd4c0c5561901c967744", size = 717325, upload-time = "2026-03-15T09:40:41.432Z" }, + { url = "https://files.pythonhosted.org/packages/ba/d3/9c5dda5ec5ede6f3f25e9b6952b5b6d4780f1c43bf09156220e7f5e85c2a/habluetooth-5.10.2-cp314-cp314-win32.whl", hash = "sha256:3f7945606e2f900e1f008d506cdfa943b64b2b1cdda18ff546c6219c9b2c424d", size = 468639, upload-time = "2026-03-15T09:40:43.336Z" }, + { url = "https://files.pythonhosted.org/packages/cc/e2/7b01b70319af48ba520ffe23aab481d77e16babef6d11a3e19861202d4b4/habluetooth-5.10.2-cp314-cp314-win_amd64.whl", hash = "sha256:f4ff9fc1c8253eaf82be457e5e35ff7abb21a5fe3655512089d0e99df73cb369", size = 542122, upload-time = "2026-03-15T09:40:45.178Z" }, + { url = "https://files.pythonhosted.org/packages/d3/cf/3bfc433d1fa04537cc4446afe157708104742665fd6021edb1cf2fa54ece/habluetooth-5.10.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f174e10fab62c1d35814ab5288f443234d069fbc9fa063d8eda54ed443610154", size = 1136533, upload-time = "2026-03-15T09:40:46.719Z" }, + { url = "https://files.pythonhosted.org/packages/d6/b9/9872ae1cd98d70ed047e33dc71efee8452536cd149f99eaee49272685388/habluetooth-5.10.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ea14411d2c7d0a48eb4bb84b4989fbeb35ab0306ccc78bfd458ad6a6d19d6783", size = 1289834, upload-time = "2026-03-15T09:40:48.348Z" }, + { url = "https://files.pythonhosted.org/packages/0f/5c/d4d704a324994c4e6713fd2cae5f85b9e516dcc6f211ef380c125457786c/habluetooth-5.10.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fc6eed8ebecc4d4987a3ed5a62b7a5181bffb49847e47bbb3b088288b764949e", size = 1205873, upload-time = "2026-03-15T09:40:50.054Z" }, + { url = "https://files.pythonhosted.org/packages/ae/ce/8fb82d316e695b2347eca9b231985b5f2c8900ebd5fd78b7f57788c907b0/habluetooth-5.10.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e6c03523b1561d0ba4a3cad08fde2163832fd6b35a15a96212bd4968bddeab4", size = 1350748, upload-time = "2026-03-15T09:40:51.652Z" }, + { url = "https://files.pythonhosted.org/packages/c4/1e/5b3f6236e73833eb756ffb7b65467d19751e754e603f84c79db03983e582/habluetooth-5.10.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:50dd2facc74f58766c8319ace068bc79fb27f73ace1bbaeae17f23c1ecae9d10", size = 1309696, upload-time = "2026-03-15T09:40:53.644Z" }, + { url = "https://files.pythonhosted.org/packages/c5/f7/04f14a0fe8de0aa4f014e0033f6de413b8a3bf21ee9d09e1cd53bb0569b5/habluetooth-5.10.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:0bb27c24ee095cdbb4078668f7e6f4d83cb4f6f15cae1741c360b3691c3b39fa", size = 1230790, upload-time = "2026-03-15T09:40:55.231Z" }, + { url = "https://files.pythonhosted.org/packages/92/c5/cef8d156f8d8860e89a7d2906edb130452fdcac4cdc7c8e4948774c8df15/habluetooth-5.10.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1fde6ca79a5754e83f1463e6c4f1cd08bfd73d21e8b7b4af23e2d3f58e811542", size = 1365273, upload-time = "2026-03-15T09:40:56.903Z" }, + { url = "https://files.pythonhosted.org/packages/04/57/ca48e9bf508fdda497e11390d81289ee87422d84c873b7d748832511de7c/habluetooth-5.10.2-cp314-cp314t-win32.whl", hash = "sha256:3aa6e047d864e207616384c6bc11eaaf38c83f778b0c1494a00ec92dcde9ef79", size = 969881, upload-time = "2026-03-15T09:40:58.598Z" }, + { url = "https://files.pythonhosted.org/packages/35/7a/326f51d82e4da23962ecbaa3c5b7665691975034e74a9b2d6758e0349951/habluetooth-5.10.2-cp314-cp314t-win_amd64.whl", hash = "sha256:7cbd53e76abbb65e2ef15f383dd3f08504cb19885325090e71a1d00e7325aaa7", size = 1142045, upload-time = "2026-03-15T09:41:00.643Z" }, +] + +[[package]] +name = "hass-nabucasa" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "acme" }, + { name = "aiohttp" }, + { name = "atomicwrites-homeassistant" }, + { name = "attrs" }, + { name = "ciso8601" }, + { name = "cryptography" }, + { name = "grpcio" }, + { name = "icmplib" }, + { name = "josepy" }, + { name = "pycognito" }, + { name = "pyjwt" }, + { name = "requests" }, + { name = "sentence-stream" }, + { name = "snitun" }, + { name = "voluptuous" }, + { name = "webrtc-models" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/59/40/53b290cecc71e7b12a4d8ce8b07c6a6c2da5c93ada8e19432cca0d6dde1f/hass_nabucasa-2.2.0.tar.gz", hash = "sha256:7bfaca35cf854197cdecfd2c1e41b263e3224e1abafbb58457552021bbbed6fc", size = 118895, upload-time = "2026-03-23T09:53:53.368Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/d2/997801ecfe37c620b5f980516b5b2be18b6e74f406e8e401273c777dc299/hass_nabucasa-2.2.0-py3-none-any.whl", hash = "sha256:366620278baed52e5fbf078d5262e84d401a938e14ca5c30d1aa280848ba865d", size = 90827, upload-time = "2026-03-23T09:53:51.865Z" }, +] + +[[package]] +name = "home-assistant-bluetooth" +version = "1.13.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "habluetooth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b4/0e/c05ee603cab1adb847a305bc8f1034cbdbc0a5d15169fcf68c0d6d21e33f/home_assistant_bluetooth-1.13.1.tar.gz", hash = "sha256:0ae0e2a8491cc762ee9e694b8bc7665f1e2b4618926f63969a23a2e3a48ce55e", size = 7607, upload-time = "2025-02-04T16:11:15.259Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/9b/9904cec885cc32c45e8c22cd7e19d9c342e30074fdb7c58f3d5b33ea1adb/home_assistant_bluetooth-1.13.1-py3-none-any.whl", hash = "sha256:cdf13b5b45f7744165677831e309ee78fbaf0c2866c6b5931e14d1e4e7dae5d7", size = 7915, upload-time = "2025-02-04T16:11:13.163Z" }, +] + +[[package]] +name = "homeassistant" +version = "2026.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiodns" }, + { name = "aiogithubapi" }, + { name = "aiohttp" }, + { name = "aiohttp-asyncmdnsresolver" }, + { name = "aiohttp-cors" }, + { name = "aiohttp-fast-zlib" }, + { name = "aiozoneinfo" }, + { name = "annotatedyaml" }, + { name = "astral" }, + { name = "async-interrupt" }, + { name = "atomicwrites-homeassistant" }, + { name = "attrs" }, + { name = "audioop-lts" }, + { name = "awesomeversion" }, + { name = "bcrypt" }, + { name = "certifi" }, + { name = "ciso8601" }, + { name = "cronsim" }, + { name = "cryptography" }, + { name = "fnv-hash-fast" }, + { name = "hass-nabucasa" }, + { name = "home-assistant-bluetooth" }, + { name = "httpx" }, + { name = "ifaddr" }, + { name = "jinja2" }, + { name = "lru-dict" }, + { name = "orjson" }, + { name = "packaging" }, + { name = "pillow" }, + { name = "propcache" }, + { name = "psutil-home-assistant" }, + { name = "pyjwt" }, + { name = "pyopenssl" }, + { name = "python-slugify" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "securetar" }, + { name = "sqlalchemy" }, + { name = "standard-aifc" }, + { name = "standard-telnetlib" }, + { name = "typing-extensions" }, + { name = "ulid-transform" }, + { name = "urllib3" }, + { name = "uv" }, + { name = "voluptuous" }, + { name = "voluptuous-openapi" }, + { name = "voluptuous-serialize" }, + { name = "webrtc-models" }, + { name = "yarl" }, + { name = "zeroconf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/3d/041a66485642537286c4b1a3ee66ace7ce43cfb61737e224cf01e6b59c97/homeassistant-2026.4.4.tar.gz", hash = "sha256:10f997fb7c00b2f8abbe30f343469428665b68d5a1e665feee2b827d9d815212", size = 32333699, upload-time = "2026-04-24T18:58:32.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/cb/120eb528c6eb5a21bc726aff1bc47898e5353a677ec18d33d65b11b8f3f8/homeassistant-2026.4.4-py3-none-any.whl", hash = "sha256:cd83240d320e9842822810b4c4aa13a132dbf7954d10576f406e66fca645467f", size = 53499095, upload-time = "2026-04-24T18:58:24.834Z" }, +] + +[[package]] +name = "homeassistant-stubs" +version = "2026.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "homeassistant" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3c/fb/2cc8df737b883498964e1a96d00497b3dfb04de5aa0ef17b567973584837/homeassistant_stubs-2026.4.4.tar.gz", hash = "sha256:5bfd79ba36b87d594d403185fbf7885b81f5f6967cc3ea4d641e825a8b469b8f", size = 1310585, upload-time = "2026-04-25T03:36:20.52Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/d2/7bf64e4a1cd1fa6f188e5378cd72339de1b6424676000279561a1d3ae598/homeassistant_stubs-2026.4.4-py3-none-any.whl", hash = "sha256:53ecbe27c5428dc21506c111a9710d7f8c48ed8c112404f2c41d132f4896b88d", size = 3700990, upload-time = "2026-04-25T03:36:17.791Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "icmplib" +version = "3.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/78/ca07444be85ec718d4a7617f43fdb5b4eaae40bc15a04a5c888b64f3e35f/icmplib-3.0.4.tar.gz", hash = "sha256:57868f2cdb011418c0e1d5586b16d1fabd206569fe9652654c27b6b2d6a316de", size = 26744, upload-time = "2023-10-10T17:05:12.902Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/ab/a47a2fdcf930e986914c642242ce2823753d7b08fda485f52323132f1240/icmplib-3.0.4-py3-none-any.whl", hash = "sha256:336b75c6c23c5ce99ddec33f718fab09661f6ad698e35b6f1fc7cc0ecf809398", size = 30561, upload-time = "2023-10-10T17:05:10.092Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "ifaddr" +version = "0.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/ac/fb4c578f4a3256561548cd825646680edcadb9440f3f68add95ade1eb791/ifaddr-0.2.0.tar.gz", hash = "sha256:cc0cbfcaabf765d44595825fb96a99bb12c79716b73b44330ea38ee2b0c4aed4", size = 10485, upload-time = "2022-06-15T21:40:27.561Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/1f/19ebc343cc71a7ffa78f17018535adc5cbdd87afb31d7c34874680148b32/ifaddr-0.2.0-py3-none-any.whl", hash = "sha256:085e0305cfe6f16ab12d72e2024030f5d52674afad6911bb1eee207177b8a748", size = 12314, upload-time = "2022-06-15T21:40:25.756Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "isort" +version = "8.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ef/7c/ec4ab396d31b3b395e2e999c8f46dec78c5e29209fac49d1f4dace04041d/isort-8.0.1.tar.gz", hash = "sha256:171ac4ff559cdc060bcfff550bc8404a486fee0caab245679c2abe7cb253c78d", size = 769592, upload-time = "2026-02-28T10:08:20.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/95/c7c34aa53c16353c56d0b802fba48d5f5caa2cdee7958acbcb795c830416/isort-8.0.1-py3-none-any.whl", hash = "sha256:28b89bc70f751b559aeca209e6120393d43fbe2490de0559662be7a9787e3d75", size = 89733, upload-time = "2026-02-28T10:08:19.466Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "jmespath" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" }, +] + +[[package]] +name = "josepy" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7f/ad/6f520aee9cc9618d33430380741e9ef859b2c560b1e7915e755c084f6bc0/josepy-2.2.0.tar.gz", hash = "sha256:74c033151337c854f83efe5305a291686cef723b4b970c43cfe7270cf4a677a9", size = 56500, upload-time = "2025-10-14T14:54:42.108Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/b2/b5caed897fbb1cc286c62c01feca977e08d99a17230ff3055b9a98eccf1d/josepy-2.2.0-py3-none-any.whl", hash = "sha256:63e9dd116d4078778c25ca88f880cc5d95f1cab0099bebe3a34c2e299f65d10b", size = 29211, upload-time = "2025-10-14T14:54:41.144Z" }, +] + +[[package]] +name = "librt" +version = "0.8.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/56/9c/b4b0c54d84da4a94b37bd44151e46d5e583c9534c7e02250b961b1b6d8a8/librt-0.8.1.tar.gz", hash = "sha256:be46a14693955b3bd96014ccbdb8339ee8c9346fbe11c1b78901b55125f14c73", size = 177471, upload-time = "2026-02-17T16:13:06.101Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c9/6a/907ef6800f7bca71b525a05f1839b21f708c09043b1c6aa77b6b827b3996/librt-0.8.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6cfa7fe54fd4d1f47130017351a959fe5804bda7a0bc7e07a2cdbc3fdd28d34f", size = 66081, upload-time = "2026-02-17T16:12:12.766Z" }, + { url = "https://files.pythonhosted.org/packages/1b/18/25e991cd5640c9fb0f8d91b18797b29066b792f17bf8493da183bf5caabe/librt-0.8.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:228c2409c079f8c11fb2e5d7b277077f694cb93443eb760e00b3b83cb8b3176c", size = 68309, upload-time = "2026-02-17T16:12:13.756Z" }, + { url = "https://files.pythonhosted.org/packages/a4/36/46820d03f058cfb5a9de5940640ba03165ed8aded69e0733c417bb04df34/librt-0.8.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7aae78ab5e3206181780e56912d1b9bb9f90a7249ce12f0e8bf531d0462dd0fc", size = 196804, upload-time = "2026-02-17T16:12:14.818Z" }, + { url = "https://files.pythonhosted.org/packages/59/18/5dd0d3b87b8ff9c061849fbdb347758d1f724b9a82241aa908e0ec54ccd0/librt-0.8.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:172d57ec04346b047ca6af181e1ea4858086c80bdf455f61994c4aa6fc3f866c", size = 206907, upload-time = "2026-02-17T16:12:16.513Z" }, + { url = "https://files.pythonhosted.org/packages/d1/96/ef04902aad1424fd7299b62d1890e803e6ab4018c3044dca5922319c4b97/librt-0.8.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b1977c4ea97ce5eb7755a78fae68d87e4102e4aaf54985e8b56806849cc06a3", size = 221217, upload-time = "2026-02-17T16:12:17.906Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ff/7e01f2dda84a8f5d280637a2e5827210a8acca9a567a54507ef1c75b342d/librt-0.8.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:10c42e1f6fd06733ef65ae7bebce2872bcafd8d6e6b0a08fe0a05a23b044fb14", size = 214622, upload-time = "2026-02-17T16:12:19.108Z" }, + { url = "https://files.pythonhosted.org/packages/1e/8c/5b093d08a13946034fed57619742f790faf77058558b14ca36a6e331161e/librt-0.8.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4c8dfa264b9193c4ee19113c985c95f876fae5e51f731494fc4e0cf594990ba7", size = 221987, upload-time = "2026-02-17T16:12:20.331Z" }, + { url = "https://files.pythonhosted.org/packages/d3/cc/86b0b3b151d40920ad45a94ce0171dec1aebba8a9d72bb3fa00c73ab25dd/librt-0.8.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:01170b6729a438f0dedc4a26ed342e3dc4f02d1000b4b19f980e1877f0c297e6", size = 215132, upload-time = "2026-02-17T16:12:21.54Z" }, + { url = "https://files.pythonhosted.org/packages/fc/be/8588164a46edf1e69858d952654e216a9a91174688eeefb9efbb38a9c799/librt-0.8.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:7b02679a0d783bdae30d443025b94465d8c3dc512f32f5b5031f93f57ac32071", size = 215195, upload-time = "2026-02-17T16:12:23.073Z" }, + { url = "https://files.pythonhosted.org/packages/f5/f2/0b9279bea735c734d69344ecfe056c1ba211694a72df10f568745c899c76/librt-0.8.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:190b109bb69592a3401fe1ffdea41a2e73370ace2ffdc4a0e8e2b39cdea81b78", size = 237946, upload-time = "2026-02-17T16:12:24.275Z" }, + { url = "https://files.pythonhosted.org/packages/e9/cc/5f2a34fbc8aeb35314a3641f9956fa9051a947424652fad9882be7a97949/librt-0.8.1-cp314-cp314-win32.whl", hash = "sha256:e70a57ecf89a0f64c24e37f38d3fe217a58169d2fe6ed6d70554964042474023", size = 50689, upload-time = "2026-02-17T16:12:25.766Z" }, + { url = "https://files.pythonhosted.org/packages/a0/76/cd4d010ab2147339ca2b93e959c3686e964edc6de66ddacc935c325883d7/librt-0.8.1-cp314-cp314-win_amd64.whl", hash = "sha256:7e2f3edca35664499fbb36e4770650c4bd4a08abc1f4458eab9df4ec56389730", size = 57875, upload-time = "2026-02-17T16:12:27.465Z" }, + { url = "https://files.pythonhosted.org/packages/84/0f/2143cb3c3ca48bd3379dcd11817163ca50781927c4537345d608b5045998/librt-0.8.1-cp314-cp314-win_arm64.whl", hash = "sha256:0d2f82168e55ddefd27c01c654ce52379c0750ddc31ee86b4b266bcf4d65f2a3", size = 48058, upload-time = "2026-02-17T16:12:28.556Z" }, + { url = "https://files.pythonhosted.org/packages/d2/0e/9b23a87e37baf00311c3efe6b48d6b6c168c29902dfc3f04c338372fd7db/librt-0.8.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2c74a2da57a094bd48d03fa5d196da83d2815678385d2978657499063709abe1", size = 68313, upload-time = "2026-02-17T16:12:29.659Z" }, + { url = "https://files.pythonhosted.org/packages/db/9a/859c41e5a4f1c84200a7d2b92f586aa27133c8243b6cac9926f6e54d01b9/librt-0.8.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a355d99c4c0d8e5b770313b8b247411ed40949ca44e33e46a4789b9293a907ee", size = 70994, upload-time = "2026-02-17T16:12:31.516Z" }, + { url = "https://files.pythonhosted.org/packages/4c/28/10605366ee599ed34223ac2bf66404c6fb59399f47108215d16d5ad751a8/librt-0.8.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2eb345e8b33fb748227409c9f1233d4df354d6e54091f0e8fc53acdb2ffedeb7", size = 220770, upload-time = "2026-02-17T16:12:33.294Z" }, + { url = "https://files.pythonhosted.org/packages/af/8d/16ed8fd452dafae9c48d17a6bc1ee3e818fd40ef718d149a8eff2c9f4ea2/librt-0.8.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9be2f15e53ce4e83cc08adc29b26fb5978db62ef2a366fbdf716c8a6c8901040", size = 235409, upload-time = "2026-02-17T16:12:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/89/1b/7bdf3e49349c134b25db816e4a3db6b94a47ac69d7d46b1e682c2c4949be/librt-0.8.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:785ae29c1f5c6e7c2cde2c7c0e148147f4503da3abc5d44d482068da5322fd9e", size = 246473, upload-time = "2026-02-17T16:12:36.656Z" }, + { url = "https://files.pythonhosted.org/packages/4e/8a/91fab8e4fd2a24930a17188c7af5380eb27b203d72101c9cc000dbdfd95a/librt-0.8.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d3a7da44baf692f0c6aeb5b2a09c5e6fc7a703bca9ffa337ddd2e2da53f7732", size = 238866, upload-time = "2026-02-17T16:12:37.849Z" }, + { url = "https://files.pythonhosted.org/packages/b9/e0/c45a098843fc7c07e18a7f8a24ca8496aecbf7bdcd54980c6ca1aaa79a8e/librt-0.8.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5fc48998000cbc39ec0d5311312dda93ecf92b39aaf184c5e817d5d440b29624", size = 250248, upload-time = "2026-02-17T16:12:39.445Z" }, + { url = "https://files.pythonhosted.org/packages/82/30/07627de23036640c952cce0c1fe78972e77d7d2f8fd54fa5ef4554ff4a56/librt-0.8.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e96baa6820280077a78244b2e06e416480ed859bbd8e5d641cf5742919d8beb4", size = 240629, upload-time = "2026-02-17T16:12:40.889Z" }, + { url = "https://files.pythonhosted.org/packages/fb/c1/55bfe1ee3542eba055616f9098eaf6eddb966efb0ca0f44eaa4aba327307/librt-0.8.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:31362dbfe297b23590530007062c32c6f6176f6099646bb2c95ab1b00a57c382", size = 239615, upload-time = "2026-02-17T16:12:42.446Z" }, + { url = "https://files.pythonhosted.org/packages/2b/39/191d3d28abc26c9099b19852e6c99f7f6d400b82fa5a4e80291bd3803e19/librt-0.8.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc3656283d11540ab0ea01978378e73e10002145117055e03722417aeab30994", size = 263001, upload-time = "2026-02-17T16:12:43.627Z" }, + { url = "https://files.pythonhosted.org/packages/b9/eb/7697f60fbe7042ab4e88f4ee6af496b7f222fffb0a4e3593ef1f29f81652/librt-0.8.1-cp314-cp314t-win32.whl", hash = "sha256:738f08021b3142c2918c03692608baed43bc51144c29e35807682f8070ee2a3a", size = 51328, upload-time = "2026-02-17T16:12:45.148Z" }, + { url = "https://files.pythonhosted.org/packages/7c/72/34bf2eb7a15414a23e5e70ecb9440c1d3179f393d9349338a91e2781c0fb/librt-0.8.1-cp314-cp314t-win_amd64.whl", hash = "sha256:89815a22daf9c51884fb5dbe4f1ef65ee6a146e0b6a8df05f753e2e4a9359bf4", size = 58722, upload-time = "2026-02-17T16:12:46.85Z" }, + { url = "https://files.pythonhosted.org/packages/b2/c8/d148e041732d631fc76036f8b30fae4e77b027a1e95b7a84bb522481a940/librt-0.8.1-cp314-cp314t-win_arm64.whl", hash = "sha256:bf512a71a23504ed08103a13c941f763db13fb11177beb3d9244c98c29fb4a61", size = 48755, upload-time = "2026-02-17T16:12:47.943Z" }, +] + +[[package]] +name = "license-expression" +version = "30.4.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "boolean-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/79/efb4637d56dcd265cb9329ab502be0e01f4daed80caffdc5065b4b7956df/license_expression-30.4.3.tar.gz", hash = "sha256:49f439fea91c4d1a642f9f2902b58db1d42396c5e331045f41ce50df9b40b1f2", size = 183031, upload-time = "2025-06-25T13:02:25.76Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/ba/f6f6573bb21e51b838f1e7b0e8ef831d50db6d0530a5afaba700a34d9e12/license_expression-30.4.3-py3-none-any.whl", hash = "sha256:fd3db53418133e0eef917606623bc125fbad3d1225ba8d23950999ee87c99280", size = 117085, upload-time = "2025-06-25T13:02:24.503Z" }, +] + +[[package]] +name = "lru-dict" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/e3/42c87871920602a3c8300915bd0292f76eccc66c38f782397acbf8a62088/lru-dict-1.3.0.tar.gz", hash = "sha256:54fd1966d6bd1fcde781596cb86068214edeebff1db13a2cea11079e3fd07b6b", size = 13123, upload-time = "2023-11-06T01:40:12.951Z" } + +[[package]] +name = "mando" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/35/24/cd70d5ae6d35962be752feccb7dca80b5e0c2d450e995b16abd6275f3296/mando-0.7.1.tar.gz", hash = "sha256:18baa999b4b613faefb00eac4efadcf14f510b59b924b66e08289aa1de8c3500", size = 37868, upload-time = "2022-02-24T08:12:27.316Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/f0/834e479e47e499b6478e807fb57b31cc2db696c4db30557bb6f5aea4a90b/mando-0.7.1-py2.py3-none-any.whl", hash = "sha256:26ef1d70928b6057ee3ca12583d73c63e05c49de8972d620c278a7b206581a8a", size = 28149, upload-time = "2022-02-24T08:12:25.24Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "mashumaro" +version = "3.20" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/64/3d/0f1bf475109a816c2a31a8b424750911343f0bce304827a5255df167547e/mashumaro-3.20.tar.gz", hash = "sha256:af4573f14ae61be3fbc3a473158ddfc1420f345410385809fd782e0d79e9215c", size = 191643, upload-time = "2026-02-09T21:53:55.24Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/5a/4fed77781061647d3be98e2f235ef1869289dd839ca0451a8d50a30fcd5c/mashumaro-3.20-py3-none-any.whl", hash = "sha256:648bc326f64c55447988eab67d6bfe3b7958c0961c83590709b1f950f88f4a3c", size = 94942, upload-time = "2026-02-09T21:53:53.343Z" }, +] + +[[package]] +name = "mccabe" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/ff/0ffefdcac38932a54d2b5eed4e0ba8a408f215002cd178ad1df0f2806ff8/mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", size = 9658, upload-time = "2022-01-24T01:14:51.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e", size = 7350, upload-time = "2022-01-24T01:14:49.62Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "mock-open" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/02/cef85a80ff6d3092a458448c46816656d1c532afd45aeeeb8f50a84aed35/mock-open-1.4.0.tar.gz", hash = "sha256:c3ecb6b8c32a5899a4f5bf4495083b598b520c698bba00e1ce2ace6e9c239100", size = 12127, upload-time = "2020-04-15T15:26:51.234Z" } + +[[package]] +name = "multidict" +version = "6.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190, upload-time = "2026-01-26T02:45:10.651Z" }, + { url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486, upload-time = "2026-01-26T02:45:11.938Z" }, + { url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219, upload-time = "2026-01-26T02:45:14.346Z" }, + { url = "https://files.pythonhosted.org/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132, upload-time = "2026-01-26T02:45:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420, upload-time = "2026-01-26T02:45:17.293Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510, upload-time = "2026-01-26T02:45:19.356Z" }, + { url = "https://files.pythonhosted.org/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094, upload-time = "2026-01-26T02:45:20.834Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786, upload-time = "2026-01-26T02:45:22.818Z" }, + { url = "https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483, upload-time = "2026-01-26T02:45:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403, upload-time = "2026-01-26T02:45:25.982Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315, upload-time = "2026-01-26T02:45:27.487Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528, upload-time = "2026-01-26T02:45:28.991Z" }, + { url = "https://files.pythonhosted.org/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784, upload-time = "2026-01-26T02:45:30.503Z" }, + { url = "https://files.pythonhosted.org/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980, upload-time = "2026-01-26T02:45:32.603Z" }, + { url = "https://files.pythonhosted.org/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602, upload-time = "2026-01-26T02:45:34.043Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930, upload-time = "2026-01-26T02:45:36.278Z" }, + { url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074, upload-time = "2026-01-26T02:45:37.546Z" }, + { url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471, upload-time = "2026-01-26T02:45:38.889Z" }, + { url = "https://files.pythonhosted.org/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401, upload-time = "2026-01-26T02:45:40.254Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143, upload-time = "2026-01-26T02:45:41.635Z" }, + { url = "https://files.pythonhosted.org/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507, upload-time = "2026-01-26T02:45:42.99Z" }, + { url = "https://files.pythonhosted.org/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358, upload-time = "2026-01-26T02:45:44.376Z" }, + { url = "https://files.pythonhosted.org/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884, upload-time = "2026-01-26T02:45:47.167Z" }, + { url = "https://files.pythonhosted.org/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878, upload-time = "2026-01-26T02:45:48.698Z" }, + { url = "https://files.pythonhosted.org/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542, upload-time = "2026-01-26T02:45:50.164Z" }, + { url = "https://files.pythonhosted.org/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403, upload-time = "2026-01-26T02:45:51.779Z" }, + { url = "https://files.pythonhosted.org/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889, upload-time = "2026-01-26T02:45:53.27Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982, upload-time = "2026-01-26T02:45:54.919Z" }, + { url = "https://files.pythonhosted.org/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415, upload-time = "2026-01-26T02:45:56.981Z" }, + { url = "https://files.pythonhosted.org/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337, upload-time = "2026-01-26T02:45:58.698Z" }, + { url = "https://files.pythonhosted.org/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788, upload-time = "2026-01-26T02:46:00.862Z" }, + { url = "https://files.pythonhosted.org/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842, upload-time = "2026-01-26T02:46:02.824Z" }, + { url = "https://files.pythonhosted.org/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237, upload-time = "2026-01-26T02:46:05.898Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008, upload-time = "2026-01-26T02:46:07.468Z" }, + { url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542, upload-time = "2026-01-26T02:46:08.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719, upload-time = "2026-01-26T02:46:11.146Z" }, + { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, +] + +[[package]] +name = "mypy" +version = "1.20.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/04/af/e3d4b3e9ec91a0ff9aabfdb38692952acf49bbb899c2e4c29acb3a6da3ae/mypy-1.20.2.tar.gz", hash = "sha256:e8222c26daaafd9e8626dec58ae36029f82585890589576f769a650dd20fd665", size = 3817349, upload-time = "2026-04-21T17:12:28.473Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/d1/b4ec96b0ecc620a4443570c6e95c867903428cfcde4206518eafdd5880c3/mypy-1.20.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:419413398fe250aae057fd2fe50166b61077083c9b82754c341cf4fd73038f30", size = 14524561, upload-time = "2026-04-21T17:06:27.325Z" }, + { url = "https://files.pythonhosted.org/packages/3a/63/d2c2ff4fa66bc49477d32dfa26e8a167ba803ea6a69c5efb416036909d30/mypy-1.20.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e73c07f23009962885c197ccb9b41356a30cc0e5a1d0c2ea8fd8fb1362d7f924", size = 13363883, upload-time = "2026-04-21T17:11:11.239Z" }, + { url = "https://files.pythonhosted.org/packages/2a/56/983916806bf4eddeaaa2c9230903c3669c6718552a921154e1c5182c701f/mypy-1.20.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c64e5973df366b747646fc98da921f9d6eba9716d57d1db94a83c026a08e0fb", size = 13742945, upload-time = "2026-04-21T17:08:34.181Z" }, + { url = "https://files.pythonhosted.org/packages/19/65/0cd9285ab010ee8214c83d67c6b49417c40d86ce46f1aa109457b5a9b8d7/mypy-1.20.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a65aa591af023864fd08a97da9974e919452cfe19cb146c8a5dc692626445dc", size = 14706163, upload-time = "2026-04-21T17:05:15.51Z" }, + { url = "https://files.pythonhosted.org/packages/94/97/48ff3b297cafcc94d185243a9190836fb1b01c1b0918fff64e941e973cc9/mypy-1.20.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4fef51b01e638974a6e69885687e9bd40c8d1e09a6cd291cca0619625cf1f558", size = 14938677, upload-time = "2026-04-21T17:05:39.562Z" }, + { url = "https://files.pythonhosted.org/packages/fd/a1/1b4233d255bdd0b38a1f284feeb1c143ca508c19184964e22f8d837ec851/mypy-1.20.2-cp314-cp314-win_amd64.whl", hash = "sha256:913485a03f1bcf5d279409a9d2b9ed565c151f61c09f29991e5faa14033da4c8", size = 11089322, upload-time = "2026-04-21T17:06:44.29Z" }, + { url = "https://files.pythonhosted.org/packages/78/c2/ce7ee2ba36aeb954ba50f18fa25d9c1188578654b97d02a66a15b6f09531/mypy-1.20.2-cp314-cp314-win_arm64.whl", hash = "sha256:c3bae4f855d965b5453784300c12ffc63a548304ac7f99e55d4dc7c898673aa3", size = 10017775, upload-time = "2026-04-21T17:07:20.732Z" }, + { url = "https://files.pythonhosted.org/packages/4e/a1/9d93a7d0b5859af0ead82b4888b46df6c8797e1bc5e1e262a08518c6d48e/mypy-1.20.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2de3dcea53babc1c3237a19002bc3d228ce1833278f093b8d619e06e7cc79609", size = 15549002, upload-time = "2026-04-21T17:08:23.107Z" }, + { url = "https://files.pythonhosted.org/packages/00/d2/09a6a10ee1bf0008f6c144d9676f2ca6a12512151b4e0ad0ff6c4fac5337/mypy-1.20.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:52b176444e2e5054dfcbcb8c75b0b719865c96247b37407184bbfca5c353f2c2", size = 14401942, upload-time = "2026-04-21T17:07:31.837Z" }, + { url = "https://files.pythonhosted.org/packages/57/da/9594b75c3c019e805250bed3583bdf4443ff9e6ef08f97e39ae308cb06f2/mypy-1.20.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:688c3312e5dadb573a2c69c82af3a298d43ecf9e6d264e0f95df960b5f6ac19c", size = 15041649, upload-time = "2026-04-21T17:09:34.653Z" }, + { url = "https://files.pythonhosted.org/packages/97/77/f75a65c278e6e8eba2071f7f5a90481891053ecc39878cc444634d892abe/mypy-1.20.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29752dbbf8cc53f89f6ac096d363314333045c257c9c75cbd189ca2de0455744", size = 15864588, upload-time = "2026-04-21T17:11:44.936Z" }, + { url = "https://files.pythonhosted.org/packages/d7/46/1a4e1c66e96c1a3246ddf5403d122ac9b0a8d2b7e65730b9d6533ba7a6d3/mypy-1.20.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:803203d2b6ea644982c644895c2f78b28d0e208bba7b27d9b921e0ec5eb207c6", size = 16093956, upload-time = "2026-04-21T17:10:17.683Z" }, + { url = "https://files.pythonhosted.org/packages/5a/2c/78a8851264dec38cd736ca5b8bc9380674df0dd0be7792f538916157716c/mypy-1.20.2-cp314-cp314t-win_amd64.whl", hash = "sha256:9bcb8aa397ff0093c824182fd76a935a9ba7ad097fcbef80ae89bf6c1731d8ec", size = 12568661, upload-time = "2026-04-21T17:11:54.473Z" }, + { url = "https://files.pythonhosted.org/packages/83/01/cd7318aa03493322ce275a0e14f4f52b8896335e4e79d4fb8153a7ad2b77/mypy-1.20.2-cp314-cp314t-win_arm64.whl", hash = "sha256:e061b58443f1736f8a37c48978d7ab581636d6ab03e3d4f99e3fa90463bb9382", size = 10389240, upload-time = "2026-04-21T17:09:42.719Z" }, + { url = "https://files.pythonhosted.org/packages/28/9a/f23c163e25b11074188251b0b5a0342625fc1cdb6af604757174fa9acc9b/mypy-1.20.2-py3-none-any.whl", hash = "sha256:a94c5a76ab46c5e6257c7972b6c8cff0574201ca7dc05647e33e795d78680563", size = 2637314, upload-time = "2026-04-21T17:05:54.5Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + +[[package]] +name = "numpy" +version = "2.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/37/7d/3fec4199c5ffb892bed55cff901e4f39a58c81df9c44c280499e92cad264/numpy-2.3.2.tar.gz", hash = "sha256:e0486a11ec30cdecb53f184d496d1c6a20786c81e55e41640270130056f8ee48", size = 20489306, upload-time = "2025-07-24T21:32:07.553Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c9/7c/7659048aaf498f7611b783e000c7268fcc4dcf0ce21cd10aad7b2e8f9591/numpy-2.3.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:448a66d052d0cf14ce9865d159bfc403282c9bc7bb2a31b03cc18b651eca8b1a", size = 20950906, upload-time = "2025-07-24T20:50:30.346Z" }, + { url = "https://files.pythonhosted.org/packages/80/db/984bea9d4ddf7112a04cfdfb22b1050af5757864cfffe8e09e44b7f11a10/numpy-2.3.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:546aaf78e81b4081b2eba1d105c3b34064783027a06b3ab20b6eba21fb64132b", size = 14185607, upload-time = "2025-07-24T20:50:51.923Z" }, + { url = "https://files.pythonhosted.org/packages/e4/76/b3d6f414f4eca568f469ac112a3b510938d892bc5a6c190cb883af080b77/numpy-2.3.2-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:87c930d52f45df092f7578889711a0768094debf73cfcde105e2d66954358125", size = 5114110, upload-time = "2025-07-24T20:51:01.041Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d2/6f5e6826abd6bca52392ed88fe44a4b52aacb60567ac3bc86c67834c3a56/numpy-2.3.2-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:8dc082ea901a62edb8f59713c6a7e28a85daddcb67454c839de57656478f5b19", size = 6642050, upload-time = "2025-07-24T20:51:11.64Z" }, + { url = "https://files.pythonhosted.org/packages/c4/43/f12b2ade99199e39c73ad182f103f9d9791f48d885c600c8e05927865baf/numpy-2.3.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:af58de8745f7fa9ca1c0c7c943616c6fe28e75d0c81f5c295810e3c83b5be92f", size = 14296292, upload-time = "2025-07-24T20:51:33.488Z" }, + { url = "https://files.pythonhosted.org/packages/5d/f9/77c07d94bf110a916b17210fac38680ed8734c236bfed9982fd8524a7b47/numpy-2.3.2-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed5527c4cf10f16c6d0b6bee1f89958bccb0ad2522c8cadc2efd318bcd545f5", size = 16638913, upload-time = "2025-07-24T20:51:58.517Z" }, + { url = "https://files.pythonhosted.org/packages/9b/d1/9d9f2c8ea399cc05cfff8a7437453bd4e7d894373a93cdc46361bbb49a7d/numpy-2.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:095737ed986e00393ec18ec0b21b47c22889ae4b0cd2d5e88342e08b01141f58", size = 16071180, upload-time = "2025-07-24T20:52:22.827Z" }, + { url = "https://files.pythonhosted.org/packages/4c/41/82e2c68aff2a0c9bf315e47d61951099fed65d8cb2c8d9dc388cb87e947e/numpy-2.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5e40e80299607f597e1a8a247ff8d71d79c5b52baa11cc1cce30aa92d2da6e0", size = 18576809, upload-time = "2025-07-24T20:52:51.015Z" }, + { url = "https://files.pythonhosted.org/packages/14/14/4b4fd3efb0837ed252d0f583c5c35a75121038a8c4e065f2c259be06d2d8/numpy-2.3.2-cp314-cp314-win32.whl", hash = "sha256:7d6e390423cc1f76e1b8108c9b6889d20a7a1f59d9a60cac4a050fa734d6c1e2", size = 6366410, upload-time = "2025-07-24T20:56:44.949Z" }, + { url = "https://files.pythonhosted.org/packages/11/9e/b4c24a6b8467b61aced5c8dc7dcfce23621baa2e17f661edb2444a418040/numpy-2.3.2-cp314-cp314-win_amd64.whl", hash = "sha256:b9d0878b21e3918d76d2209c924ebb272340da1fb51abc00f986c258cd5e957b", size = 12918821, upload-time = "2025-07-24T20:57:06.479Z" }, + { url = "https://files.pythonhosted.org/packages/0e/0f/0dc44007c70b1007c1cef86b06986a3812dd7106d8f946c09cfa75782556/numpy-2.3.2-cp314-cp314-win_arm64.whl", hash = "sha256:2738534837c6a1d0c39340a190177d7d66fdf432894f469728da901f8f6dc910", size = 10477303, upload-time = "2025-07-24T20:57:22.879Z" }, + { url = "https://files.pythonhosted.org/packages/8b/3e/075752b79140b78ddfc9c0a1634d234cfdbc6f9bbbfa6b7504e445ad7d19/numpy-2.3.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:4d002ecf7c9b53240be3bb69d80f86ddbd34078bae04d87be81c1f58466f264e", size = 21047524, upload-time = "2025-07-24T20:53:22.086Z" }, + { url = "https://files.pythonhosted.org/packages/fe/6d/60e8247564a72426570d0e0ea1151b95ce5bd2f1597bb878a18d32aec855/numpy-2.3.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:293b2192c6bcce487dbc6326de5853787f870aeb6c43f8f9c6496db5b1781e45", size = 14300519, upload-time = "2025-07-24T20:53:44.053Z" }, + { url = "https://files.pythonhosted.org/packages/4d/73/d8326c442cd428d47a067070c3ac6cc3b651a6e53613a1668342a12d4479/numpy-2.3.2-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:0a4f2021a6da53a0d580d6ef5db29947025ae8b35b3250141805ea9a32bbe86b", size = 5228972, upload-time = "2025-07-24T20:53:53.81Z" }, + { url = "https://files.pythonhosted.org/packages/34/2e/e71b2d6dad075271e7079db776196829019b90ce3ece5c69639e4f6fdc44/numpy-2.3.2-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:9c144440db4bf3bb6372d2c3e49834cc0ff7bb4c24975ab33e01199e645416f2", size = 6737439, upload-time = "2025-07-24T20:54:04.742Z" }, + { url = "https://files.pythonhosted.org/packages/15/b0/d004bcd56c2c5e0500ffc65385eb6d569ffd3363cb5e593ae742749b2daa/numpy-2.3.2-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f92d6c2a8535dc4fe4419562294ff957f83a16ebdec66df0805e473ffaad8bd0", size = 14352479, upload-time = "2025-07-24T20:54:25.819Z" }, + { url = "https://files.pythonhosted.org/packages/11/e3/285142fcff8721e0c99b51686426165059874c150ea9ab898e12a492e291/numpy-2.3.2-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cefc2219baa48e468e3db7e706305fcd0c095534a192a08f31e98d83a7d45fb0", size = 16702805, upload-time = "2025-07-24T20:54:50.814Z" }, + { url = "https://files.pythonhosted.org/packages/33/c3/33b56b0e47e604af2c7cd065edca892d180f5899599b76830652875249a3/numpy-2.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:76c3e9501ceb50b2ff3824c3589d5d1ab4ac857b0ee3f8f49629d0de55ecf7c2", size = 16133830, upload-time = "2025-07-24T20:55:17.306Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ae/7b1476a1f4d6a48bc669b8deb09939c56dd2a439db1ab03017844374fb67/numpy-2.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:122bf5ed9a0221b3419672493878ba4967121514b1d7d4656a7580cd11dddcbf", size = 18652665, upload-time = "2025-07-24T20:55:46.665Z" }, + { url = "https://files.pythonhosted.org/packages/14/ba/5b5c9978c4bb161034148ade2de9db44ec316fab89ce8c400db0e0c81f86/numpy-2.3.2-cp314-cp314t-win32.whl", hash = "sha256:6f1ae3dcb840edccc45af496f312528c15b1f79ac318169d094e85e4bb35fdf1", size = 6514777, upload-time = "2025-07-24T20:55:57.66Z" }, + { url = "https://files.pythonhosted.org/packages/eb/46/3dbaf0ae7c17cdc46b9f662c56da2054887b8d9e737c1476f335c83d33db/numpy-2.3.2-cp314-cp314t-win_amd64.whl", hash = "sha256:087ffc25890d89a43536f75c5fe8770922008758e8eeeef61733957041ed2f9b", size = 13111856, upload-time = "2025-07-24T20:56:17.318Z" }, + { url = "https://files.pythonhosted.org/packages/c1/9e/1652778bce745a67b5fe05adde60ed362d38eb17d919a540e813d30f6874/numpy-2.3.2-cp314-cp314t-win_arm64.whl", hash = "sha256:092aeb3449833ea9c0bf0089d70c29ae480685dd2377ec9cdbbb620257f84631", size = 10544226, upload-time = "2025-07-24T20:56:34.509Z" }, +] + +[[package]] +name = "orjson" +version = "3.11.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/45/b268004f745ede84e5798b48ee12b05129d19235d0e15267aa57dcdb400b/orjson-3.11.7.tar.gz", hash = "sha256:9b1a67243945819ce55d24a30b59d6a168e86220452d2c96f4d1f093e71c0c49", size = 6144992, upload-time = "2026-02-02T15:38:49.29Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/1e/745565dca749813db9a093c5ebc4bac1a9475c64d54b95654336ac3ed961/orjson-3.11.7-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:de0a37f21d0d364954ad5de1970491d7fbd0fb1ef7417d4d56a36dc01ba0c0a0", size = 228391, upload-time = "2026-02-02T15:38:27.757Z" }, + { url = "https://files.pythonhosted.org/packages/46/19/e40f6225da4d3aa0c8dc6e5219c5e87c2063a560fe0d72a88deb59776794/orjson-3.11.7-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:c2428d358d85e8da9d37cba18b8c4047c55222007a84f97156a5b22028dfbfc0", size = 125188, upload-time = "2026-02-02T15:38:29.241Z" }, + { url = "https://files.pythonhosted.org/packages/9d/7e/c4de2babef2c0817fd1f048fd176aa48c37bec8aef53d2fa932983032cce/orjson-3.11.7-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c4bc6c6ac52cdaa267552544c73e486fecbd710b7ac09bc024d5a78555a22f6", size = 128097, upload-time = "2026-02-02T15:38:30.618Z" }, + { url = "https://files.pythonhosted.org/packages/eb/74/233d360632bafd2197f217eee7fb9c9d0229eac0c18128aee5b35b0014fe/orjson-3.11.7-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd0d68edd7dfca1b2eca9361a44ac9f24b078de3481003159929a0573f21a6bf", size = 123364, upload-time = "2026-02-02T15:38:32.363Z" }, + { url = "https://files.pythonhosted.org/packages/79/51/af79504981dd31efe20a9e360eb49c15f06df2b40e7f25a0a52d9ae888e8/orjson-3.11.7-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:623ad1b9548ef63886319c16fa317848e465a21513b31a6ad7b57443c3e0dcf5", size = 129076, upload-time = "2026-02-02T15:38:33.68Z" }, + { url = "https://files.pythonhosted.org/packages/67/e2/da898eb68b72304f8de05ca6715870d09d603ee98d30a27e8a9629abc64b/orjson-3.11.7-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6e776b998ac37c0396093d10290e60283f59cfe0fc3fccbd0ccc4bd04dd19892", size = 141705, upload-time = "2026-02-02T15:38:34.989Z" }, + { url = "https://files.pythonhosted.org/packages/c5/89/15364d92acb3d903b029e28d834edb8780c2b97404cbf7929aa6b9abdb24/orjson-3.11.7-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:652c6c3af76716f4a9c290371ba2e390ede06f6603edb277b481daf37f6f464e", size = 130855, upload-time = "2026-02-02T15:38:36.379Z" }, + { url = "https://files.pythonhosted.org/packages/c2/8b/ecdad52d0b38d4b8f514be603e69ccd5eacf4e7241f972e37e79792212ec/orjson-3.11.7-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a56df3239294ea5964adf074c54bcc4f0ccd21636049a2cf3ca9cf03b5d03cf1", size = 133386, upload-time = "2026-02-02T15:38:37.704Z" }, + { url = "https://files.pythonhosted.org/packages/b9/0e/45e1dcf10e17d0924b7c9162f87ec7b4ca79e28a0548acf6a71788d3e108/orjson-3.11.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bda117c4148e81f746655d5a3239ae9bd00cb7bc3ca178b5fc5a5997e9744183", size = 138295, upload-time = "2026-02-02T15:38:39.096Z" }, + { url = "https://files.pythonhosted.org/packages/63/d7/4d2e8b03561257af0450f2845b91fbd111d7e526ccdf737267108075e0ba/orjson-3.11.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:23d6c20517a97a9daf1d48b580fcdc6f0516c6f4b5038823426033690b4d2650", size = 408720, upload-time = "2026-02-02T15:38:40.634Z" }, + { url = "https://files.pythonhosted.org/packages/78/cf/d45343518282108b29c12a65892445fc51f9319dc3c552ceb51bb5905ed2/orjson-3.11.7-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:8ff206156006da5b847c9304b6308a01e8cdbc8cce824e2779a5ba71c3def141", size = 144152, upload-time = "2026-02-02T15:38:42.262Z" }, + { url = "https://files.pythonhosted.org/packages/a9/3a/d6001f51a7275aacd342e77b735c71fa04125a3f93c36fee4526bc8c654e/orjson-3.11.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:962d046ee1765f74a1da723f4b33e3b228fe3a48bd307acce5021dfefe0e29b2", size = 134814, upload-time = "2026-02-02T15:38:43.627Z" }, + { url = "https://files.pythonhosted.org/packages/1d/d3/f19b47ce16820cc2c480f7f1723e17f6d411b3a295c60c8ad3aa9ff1c96a/orjson-3.11.7-cp314-cp314-win32.whl", hash = "sha256:89e13dd3f89f1c38a9c9eba5fbf7cdc2d1feca82f5f290864b4b7a6aac704576", size = 127997, upload-time = "2026-02-02T15:38:45.06Z" }, + { url = "https://files.pythonhosted.org/packages/12/df/172771902943af54bf661a8d102bdf2e7f932127968080632bda6054b62c/orjson-3.11.7-cp314-cp314-win_amd64.whl", hash = "sha256:845c3e0d8ded9c9271cd79596b9b552448b885b97110f628fb687aee2eed11c1", size = 124985, upload-time = "2026-02-02T15:38:46.388Z" }, + { url = "https://files.pythonhosted.org/packages/6f/1c/f2a8d8a1b17514660a614ce5f7aac74b934e69f5abc2700cc7ced882a009/orjson-3.11.7-cp314-cp314-win_arm64.whl", hash = "sha256:4a2e9c5be347b937a2e0203866f12bba36082e89b402ddb9e927d5822e43088d", size = 126038, upload-time = "2026-02-02T15:38:47.703Z" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "paho-mqtt" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/39/15/0a6214e76d4d32e7f663b109cf71fb22561c2be0f701d67f93950cd40542/paho_mqtt-2.1.0.tar.gz", hash = "sha256:12d6e7511d4137555a3f6ea167ae846af2c7357b10bc6fa4f7c3968fc1723834", size = 148848, upload-time = "2024-04-29T19:52:55.591Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/cb/00451c3cf31790287768bb12c6bec834f5d292eaf3022afc88e14b8afc94/paho_mqtt-2.1.0-py3-none-any.whl", hash = "sha256:6db9ba9b34ed5bc6b6e3812718c7e06e2fd7444540df2455d2c51bd58808feee", size = 67219, upload-time = "2024-04-29T19:52:48.345Z" }, +] + +[[package]] +name = "pathspec" +version = "1.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200, upload-time = "2026-01-27T03:59:46.938Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" }, +] + +[[package]] +name = "pillow" +version = "12.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" }, + { url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" }, + { url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" }, + { url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" }, + { url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" }, + { url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" }, + { url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" }, + { url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" }, + { url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" }, + { url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" }, + { url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" }, + { url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" }, + { url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" }, + { url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" }, + { url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" }, + { url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" }, + { url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" }, + { url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" }, + { url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" }, + { url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" }, +] + +[[package]] +name = "pip" +version = "26.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/48/83/0d7d4e9efe3344b8e2fe25d93be44f64b65364d3c8d7bc6dc90198d5422e/pip-26.0.1.tar.gz", hash = "sha256:c4037d8a277c89b320abe636d59f91e6d0922d08a05b60e85e53b296613346d8", size = 1812747, upload-time = "2026-02-05T02:20:18.702Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/f0/c81e05b613866b76d2d1066490adf1a3dbc4ee9d9c839961c3fc8a6997af/pip-26.0.1-py3-none-any.whl", hash = "sha256:bdb1b08f4274833d62c1aa29e20907365a2ceb950410df15fc9521bad440122b", size = 1787723, upload-time = "2026-02-05T02:20:16.416Z" }, +] + +[[package]] +name = "pipdeptree" +version = "2.26.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "pip" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/ef/9158ee3b28274667986d39191760c988a2de22c6321be1262e21c8a19ccf/pipdeptree-2.26.1.tar.gz", hash = "sha256:92a8f37ab79235dacb46af107e691a1309ca4a429315ba2a1df97d1cd56e27ac", size = 41024, upload-time = "2025-04-20T03:27:42.489Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/a5/f9f143b420e53a296869636d1c3bdc144be498ca3136a113f52b53ea2b02/pipdeptree-2.26.1-py3-none-any.whl", hash = "sha256:3849d62a2ed641256afac3058c4f9b85ac4a47e9d8c991ee17a8f3d230c5cffb", size = 32802, upload-time = "2025-04-20T03:27:40.413Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.9.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/56/8d4c30c8a1d07013911a8fdbd8f89440ef9f08d07a1b50ab8ca8be5a20f9/platformdirs-4.9.4.tar.gz", hash = "sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934", size = 28737, upload-time = "2026-03-05T18:34:13.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/d7/97f7e3a6abb67d8080dd406fd4df842c2be0efaf712d1c899c32a075027c/platformdirs-4.9.4-py3-none-any.whl", hash = "sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868", size = 21216, upload-time = "2026-03-05T18:34:12.172Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "prek" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/68/00050a4184f038a622855b1989b013d0ac5bfc0a29bf3cdbd1ed823595d8/prek-0.4.0.tar.gz", hash = "sha256:47f42477c8453c7440e4e656e5ab0c2a1e4c25daa5ed441a9ac1a2b7634abc12", size = 446399, upload-time = "2026-05-14T10:50:35.194Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2b/83/e4f5f574b8b3a80305a638e0cb2d46e3aa9596ac2b1e2dd6c910fedea376/prek-0.4.0-py3-none-linux_armv6l.whl", hash = "sha256:f4cba2132e038349b4b0a00a73b300e4192bae9f78fff8df0365c00bd19140e7", size = 5509604, upload-time = "2026-05-14T10:50:39.306Z" }, + { url = "https://files.pythonhosted.org/packages/05/de/ee9b8648944d44a8403ca49172d60936f5476196a7baf38c86df4aec7243/prek-0.4.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:944670565dfb3800465355f299effa31572822566d10c6210e6f76bf399ddf53", size = 5877244, upload-time = "2026-05-14T10:50:54.213Z" }, + { url = "https://files.pythonhosted.org/packages/33/f8/2e6021993332a249667102de0960160aa942880b0634d3e8920d062ebdb4/prek-0.4.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a8272f32e698eae514556086ad73d02026ec00bbd4d26c420f761ea857cfd795", size = 5435063, upload-time = "2026-05-14T10:50:31.75Z" }, + { url = "https://files.pythonhosted.org/packages/2a/8b/98fef34684f52c7f5b7b56bce14aec2b24b7abbcd7b6523cc83a63f74c58/prek-0.4.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:a860fda0f27f872622689358a583e5f2a5771241331848233274f4cfeb8ae9bb", size = 5690323, upload-time = "2026-05-14T10:50:42.583Z" }, + { url = "https://files.pythonhosted.org/packages/00/18/bf18cf0d3ab5b5eddadc6ade754ba6269ae857b32a4605036397ec56ccc1/prek-0.4.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:473ce0262e36b8e77b327941ad6d5747ac451b4c441cdc86dab95640b68fff72", size = 5423877, upload-time = "2026-05-14T10:50:36.434Z" }, + { url = "https://files.pythonhosted.org/packages/ce/1e/881f18b3cbf6fdca4ec8eb62d2c2e0cb9458fd4cebd136f5e0f76c7aa8d2/prek-0.4.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e46277b577991ccdc7b13e2cfa7d260ae13e77f5af543e50e188799f649f0ad8", size = 5830461, upload-time = "2026-05-14T10:50:45.885Z" }, + { url = "https://files.pythonhosted.org/packages/91/ea/1623391bdf1e103d7959cc7d41caedcd1ab982412c7db9f9f9ee5c0e09f4/prek-0.4.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b60bd4fdc323896a5bada2708734d16ff59ee5e32b8c390ae2ebe328426964a3", size = 6717949, upload-time = "2026-05-14T10:50:51.133Z" }, + { url = "https://files.pythonhosted.org/packages/da/56/59d73f20d3bf5ec1d1e88f24473cf77fb485acefd285c1eb79d2f102e8dc/prek-0.4.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7203bff21e622a5482e956384c43990b0092faab02f72118275839a77a45e939", size = 6104294, upload-time = "2026-05-14T10:50:57.251Z" }, + { url = "https://files.pythonhosted.org/packages/b7/7f/c03e17c06069b96805c3579c00f830bad0e700f30aa3fed08c78958be77a/prek-0.4.0-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:44e3716a3e2983add2c094775d564536342011e84f770132389819c965513d6d", size = 5703788, upload-time = "2026-05-14T10:50:55.717Z" }, + { url = "https://files.pythonhosted.org/packages/93/6f/f5664a1712ba214f67a0e93411dc62893a02165b5f04f3182e2c188d0623/prek-0.4.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:6e3a0e43d7f345a5e32962736c335b5fc466d499f2556fb6f24028dd08108618", size = 5538311, upload-time = "2026-05-14T10:50:33.859Z" }, + { url = "https://files.pythonhosted.org/packages/d9/af/8a2aa0e02363e96d05a21ca73b4ea862459535bf6cf293710594396df450/prek-0.4.0-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:0d19efff4537e4a68b5bd61dcbf5ccb2dd8343df3ea9482aabe37d241ff18ddf", size = 5398617, upload-time = "2026-05-14T10:50:37.828Z" }, + { url = "https://files.pythonhosted.org/packages/6e/06/2b175e2ef812a3ab6ebab2dcd7aa0d03aea24852b73c6c4a30882800a01c/prek-0.4.0-py3-none-musllinux_1_1_i686.whl", hash = "sha256:0d9483404e5b8bf65111ee6b821e6cf7a5ba9c3a2065e7a0b7a39a17daaffcc9", size = 5685656, upload-time = "2026-05-14T10:50:40.781Z" }, + { url = "https://files.pythonhosted.org/packages/39/00/aee25867a6e88bb5cadee41dd83745876a273d3c149612424cf068239a8e/prek-0.4.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:01d69684306c67917ed69497d0f523216f13f2306723b62e1ace454209477b45", size = 6217097, upload-time = "2026-05-14T10:50:52.903Z" }, + { url = "https://files.pythonhosted.org/packages/f2/d2/e0cca68af94b7639badd3967fc37ae6159979baf6cc3c7f1d68f0a966bdf/prek-0.4.0-py3-none-win32.whl", hash = "sha256:ce2a8feb5dc1f1e748879d0e14c2145353059b830ac5e3f64d92127ab16efc78", size = 5204721, upload-time = "2026-05-14T10:50:49.269Z" }, + { url = "https://files.pythonhosted.org/packages/2d/5b/462c907502e7b9f634c73de4e6d36fa44b5d652a52a9ddd51811b2030ca7/prek-0.4.0-py3-none-win_amd64.whl", hash = "sha256:b218d92ad5d2ff0b59240d7d837fa9edc61849894958acb3a225efff7a2faa65", size = 5595119, upload-time = "2026-05-14T10:50:47.482Z" }, + { url = "https://files.pythonhosted.org/packages/0d/a0/a0be2f73cbc8fd65a5e1de576aa5a5324339c7cc9f7dd328009a5e1a3ae1/prek-0.4.0-py3-none-win_arm64.whl", hash = "sha256:ff1f1fa311023418d40a443bca6928a9f5ce073d0b9130b641954183aa31736d", size = 5423774, upload-time = "2026-05-14T10:50:44.19Z" }, +] + +[[package]] +name = "prettier" +version = "0.0.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/0e/a3375ca8f83f03662f22cbfee801badc4e4da19e4c06ade5ccfab4297678/prettier-0.0.7.tar.gz", hash = "sha256:6c34b8cd09fd9c8956c05d6395ea3f575e0122dce494ba57685c07065abed427", size = 16124, upload-time = "2022-04-27T09:06:51.62Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/cf/c401d2af75c2c271375bbe3654e408af2c296650f88b8b3d3942e3c8b63e/prettier-0.0.7-py3-none-any.whl", hash = "sha256:20e76791de41cafe481328dd49552303f29ca192151cee1b120c26f66cae9bfc", size = 16260, upload-time = "2022-04-27T09:06:50.104Z" }, +] + +[[package]] +name = "propcache" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9e/da/e9fc233cf63743258bff22b3dfa7ea5baef7b5bc324af47a0ad89b8ffc6f/propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d", size = 46442, upload-time = "2025-10-08T19:49:02.291Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/5c/bca52d654a896f831b8256683457ceddd490ec18d9ec50e97dfd8fc726a8/propcache-0.4.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3f7124c9d820ba5548d431afb4632301acf965db49e666aa21c305cbe8c6de12", size = 78152, upload-time = "2025-10-08T19:47:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/65/9b/03b04e7d82a5f54fb16113d839f5ea1ede58a61e90edf515f6577c66fa8f/propcache-0.4.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c0d4b719b7da33599dfe3b22d3db1ef789210a0597bc650b7cee9c77c2be8c5c", size = 44869, upload-time = "2025-10-08T19:47:52.594Z" }, + { url = "https://files.pythonhosted.org/packages/b2/fa/89a8ef0468d5833a23fff277b143d0573897cf75bd56670a6d28126c7d68/propcache-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f302f4783709a78240ebc311b793f123328716a60911d667e0c036bc5dcbded", size = 46596, upload-time = "2025-10-08T19:47:54.073Z" }, + { url = "https://files.pythonhosted.org/packages/86/bd/47816020d337f4a746edc42fe8d53669965138f39ee117414c7d7a340cfe/propcache-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c80ee5802e3fb9ea37938e7eecc307fb984837091d5fd262bb37238b1ae97641", size = 206981, upload-time = "2025-10-08T19:47:55.715Z" }, + { url = "https://files.pythonhosted.org/packages/df/f6/c5fa1357cc9748510ee55f37173eb31bfde6d94e98ccd9e6f033f2fc06e1/propcache-0.4.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ed5a841e8bb29a55fb8159ed526b26adc5bdd7e8bd7bf793ce647cb08656cdf4", size = 211490, upload-time = "2025-10-08T19:47:57.499Z" }, + { url = "https://files.pythonhosted.org/packages/80/1e/e5889652a7c4a3846683401a48f0f2e5083ce0ec1a8a5221d8058fbd1adf/propcache-0.4.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:55c72fd6ea2da4c318e74ffdf93c4fe4e926051133657459131a95c846d16d44", size = 215371, upload-time = "2025-10-08T19:47:59.317Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f2/889ad4b2408f72fe1a4f6a19491177b30ea7bf1a0fd5f17050ca08cfc882/propcache-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8326e144341460402713f91df60ade3c999d601e7eb5ff8f6f7862d54de0610d", size = 201424, upload-time = "2025-10-08T19:48:00.67Z" }, + { url = "https://files.pythonhosted.org/packages/27/73/033d63069b57b0812c8bd19f311faebeceb6ba31b8f32b73432d12a0b826/propcache-0.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:060b16ae65bc098da7f6d25bf359f1f31f688384858204fe5d652979e0015e5b", size = 197566, upload-time = "2025-10-08T19:48:02.604Z" }, + { url = "https://files.pythonhosted.org/packages/dc/89/ce24f3dc182630b4e07aa6d15f0ff4b14ed4b9955fae95a0b54c58d66c05/propcache-0.4.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:89eb3fa9524f7bec9de6e83cf3faed9d79bffa560672c118a96a171a6f55831e", size = 193130, upload-time = "2025-10-08T19:48:04.499Z" }, + { url = "https://files.pythonhosted.org/packages/a9/24/ef0d5fd1a811fb5c609278d0209c9f10c35f20581fcc16f818da959fc5b4/propcache-0.4.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dee69d7015dc235f526fe80a9c90d65eb0039103fe565776250881731f06349f", size = 202625, upload-time = "2025-10-08T19:48:06.213Z" }, + { url = "https://files.pythonhosted.org/packages/f5/02/98ec20ff5546f68d673df2f7a69e8c0d076b5abd05ca882dc7ee3a83653d/propcache-0.4.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5558992a00dfd54ccbc64a32726a3357ec93825a418a401f5cc67df0ac5d9e49", size = 204209, upload-time = "2025-10-08T19:48:08.432Z" }, + { url = "https://files.pythonhosted.org/packages/a0/87/492694f76759b15f0467a2a93ab68d32859672b646aa8a04ce4864e7932d/propcache-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c9b822a577f560fbd9554812526831712c1436d2c046cedee4c3796d3543b144", size = 197797, upload-time = "2025-10-08T19:48:09.968Z" }, + { url = "https://files.pythonhosted.org/packages/ee/36/66367de3575db1d2d3f3d177432bd14ee577a39d3f5d1b3d5df8afe3b6e2/propcache-0.4.1-cp314-cp314-win32.whl", hash = "sha256:ab4c29b49d560fe48b696cdcb127dd36e0bc2472548f3bf56cc5cb3da2b2984f", size = 38140, upload-time = "2025-10-08T19:48:11.232Z" }, + { url = "https://files.pythonhosted.org/packages/0c/2a/a758b47de253636e1b8aef181c0b4f4f204bf0dd964914fb2af90a95b49b/propcache-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:5a103c3eb905fcea0ab98be99c3a9a5ab2de60228aa5aceedc614c0281cf6153", size = 41257, upload-time = "2025-10-08T19:48:12.707Z" }, + { url = "https://files.pythonhosted.org/packages/34/5e/63bd5896c3fec12edcbd6f12508d4890d23c265df28c74b175e1ef9f4f3b/propcache-0.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:74c1fb26515153e482e00177a1ad654721bf9207da8a494a0c05e797ad27b992", size = 38097, upload-time = "2025-10-08T19:48:13.923Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/9ff785d787ccf9bbb3f3106f79884a130951436f58392000231b4c737c80/propcache-0.4.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:824e908bce90fb2743bd6b59db36eb4f45cd350a39637c9f73b1c1ea66f5b75f", size = 81455, upload-time = "2025-10-08T19:48:15.16Z" }, + { url = "https://files.pythonhosted.org/packages/90/85/2431c10c8e7ddb1445c1f7c4b54d886e8ad20e3c6307e7218f05922cad67/propcache-0.4.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2b5e7db5328427c57c8e8831abda175421b709672f6cfc3d630c3b7e2146393", size = 46372, upload-time = "2025-10-08T19:48:16.424Z" }, + { url = "https://files.pythonhosted.org/packages/01/20/b0972d902472da9bcb683fa595099911f4d2e86e5683bcc45de60dd05dc3/propcache-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6f6ff873ed40292cd4969ef5310179afd5db59fdf055897e282485043fc80ad0", size = 48411, upload-time = "2025-10-08T19:48:17.577Z" }, + { url = "https://files.pythonhosted.org/packages/e2/e3/7dc89f4f21e8f99bad3d5ddb3a3389afcf9da4ac69e3deb2dcdc96e74169/propcache-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49a2dc67c154db2c1463013594c458881a069fcf98940e61a0569016a583020a", size = 275712, upload-time = "2025-10-08T19:48:18.901Z" }, + { url = "https://files.pythonhosted.org/packages/20/67/89800c8352489b21a8047c773067644e3897f02ecbbd610f4d46b7f08612/propcache-0.4.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:005f08e6a0529984491e37d8dbc3dd86f84bd78a8ceb5fa9a021f4c48d4984be", size = 273557, upload-time = "2025-10-08T19:48:20.762Z" }, + { url = "https://files.pythonhosted.org/packages/e2/a1/b52b055c766a54ce6d9c16d9aca0cad8059acd9637cdf8aa0222f4a026ef/propcache-0.4.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5c3310452e0d31390da9035c348633b43d7e7feb2e37be252be6da45abd1abcc", size = 280015, upload-time = "2025-10-08T19:48:22.592Z" }, + { url = "https://files.pythonhosted.org/packages/48/c8/33cee30bd890672c63743049f3c9e4be087e6780906bfc3ec58528be59c1/propcache-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c3c70630930447f9ef1caac7728c8ad1c56bc5015338b20fed0d08ea2480b3a", size = 262880, upload-time = "2025-10-08T19:48:23.947Z" }, + { url = "https://files.pythonhosted.org/packages/0c/b1/8f08a143b204b418285c88b83d00edbd61afbc2c6415ffafc8905da7038b/propcache-0.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e57061305815dfc910a3634dcf584f08168a8836e6999983569f51a8544cd89", size = 260938, upload-time = "2025-10-08T19:48:25.656Z" }, + { url = "https://files.pythonhosted.org/packages/cf/12/96e4664c82ca2f31e1c8dff86afb867348979eb78d3cb8546a680287a1e9/propcache-0.4.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:521a463429ef54143092c11a77e04056dd00636f72e8c45b70aaa3140d639726", size = 247641, upload-time = "2025-10-08T19:48:27.207Z" }, + { url = "https://files.pythonhosted.org/packages/18/ed/e7a9cfca28133386ba52278136d42209d3125db08d0a6395f0cba0c0285c/propcache-0.4.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:120c964da3fdc75e3731aa392527136d4ad35868cc556fd09bb6d09172d9a367", size = 262510, upload-time = "2025-10-08T19:48:28.65Z" }, + { url = "https://files.pythonhosted.org/packages/f5/76/16d8bf65e8845dd62b4e2b57444ab81f07f40caa5652b8969b87ddcf2ef6/propcache-0.4.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d8f353eb14ee3441ee844ade4277d560cdd68288838673273b978e3d6d2c8f36", size = 263161, upload-time = "2025-10-08T19:48:30.133Z" }, + { url = "https://files.pythonhosted.org/packages/e7/70/c99e9edb5d91d5ad8a49fa3c1e8285ba64f1476782fed10ab251ff413ba1/propcache-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ab2943be7c652f09638800905ee1bab2c544e537edb57d527997a24c13dc1455", size = 257393, upload-time = "2025-10-08T19:48:31.567Z" }, + { url = "https://files.pythonhosted.org/packages/08/02/87b25304249a35c0915d236575bc3574a323f60b47939a2262b77632a3ee/propcache-0.4.1-cp314-cp314t-win32.whl", hash = "sha256:05674a162469f31358c30bcaa8883cb7829fa3110bf9c0991fe27d7896c42d85", size = 42546, upload-time = "2025-10-08T19:48:32.872Z" }, + { url = "https://files.pythonhosted.org/packages/cb/ef/3c6ecf8b317aa982f309835e8f96987466123c6e596646d4e6a1dfcd080f/propcache-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:990f6b3e2a27d683cb7602ed6c86f15ee6b43b1194736f9baaeb93d0016633b1", size = 46259, upload-time = "2025-10-08T19:48:34.226Z" }, + { url = "https://files.pythonhosted.org/packages/c4/2d/346e946d4951f37eca1e4f55be0f0174c52cd70720f84029b02f296f4a38/propcache-0.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ecef2343af4cc68e05131e45024ba34f6095821988a9d0a02aa7c73fcc448aa9", size = 40428, upload-time = "2025-10-08T19:48:35.441Z" }, + { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, +] + +[[package]] +name = "psutil" +version = "7.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" }, + { url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" }, + { url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" }, + { url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" }, + { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" }, + { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" }, + { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" }, + { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" }, + { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" }, + { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, +] + +[[package]] +name = "psutil-home-assistant" +version = "0.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "psutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/01/4f/32a51f53d645044740d0513a6a029d782b35bdc51a55ea171ce85034f5b7/psutil-home-assistant-0.0.1.tar.gz", hash = "sha256:ebe4f3a98d76d93a3140da2823e9ef59ca50a59761fdc453b30b4407c4c1bdb8", size = 6045, upload-time = "2022-08-25T14:28:39.926Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/48/8a0acb683d1fee78b966b15e78143b673154abb921061515254fb573aacd/psutil_home_assistant-0.0.1-py3-none-any.whl", hash = "sha256:35a782e93e23db845fc4a57b05df9c52c2d5c24f5b233bd63b01bae4efae3c41", size = 6300, upload-time = "2022-08-25T14:28:38.083Z" }, +] + +[[package]] +name = "pycares" +version = "5.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/a0/9c823651872e6a0face3f0311de2a40c8bbcb9c8dcb15680bd019ac56ac7/pycares-5.0.1.tar.gz", hash = "sha256:5a3c249c830432631439815f9a818463416f2a8cbdb1e988e78757de9ae75081", size = 652222, upload-time = "2026-01-01T12:37:00.604Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/b2/4af99ff17acb81377c971831520540d1859bf401dc85712eb4abc2e6751f/pycares-5.0.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e330e3561be259ad7a1b7b0ce282c872938625f76587fae7ac8d6bc5af1d0c3d", size = 136635, upload-time = "2026-01-01T12:35:53.365Z" }, + { url = "https://files.pythonhosted.org/packages/42/da/e2e1683811c427492ee0e86e8fae8d55eb5cca032220438599991fdad866/pycares-5.0.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:82bd37fec2a3fa62add30d4a3854720f7b051386e2f18e6e8f4ee94b89b5a7b0", size = 131093, upload-time = "2026-01-01T12:35:54.28Z" }, + { url = "https://files.pythonhosted.org/packages/cd/2a/9cf2120cafc19e5c589d5252a9ddd3108cc87e9db09938d16317807de03b/pycares-5.0.1-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:258c38aaa82ad1d565b4591cdb93d2c191be8e0a2c70926999c8e0b717a01f2a", size = 221096, upload-time = "2026-01-01T12:35:57.096Z" }, + { url = "https://files.pythonhosted.org/packages/2c/cc/c5fbf6377e2d6b1f1618f147ad898e5d8ae1585fc726d6301f07aeda6cac/pycares-5.0.1-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ccc1b2df8a09ca20eefbe20b9f7a484d376525c0fb173cfadd692320013c6bc5", size = 252330, upload-time = "2026-01-01T12:35:58.182Z" }, + { url = "https://files.pythonhosted.org/packages/3b/df/17a7c518c45bb994f76d9064d2519674e2a3950f895abbe6af123ead04ac/pycares-5.0.1-cp314-cp314-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3c4dfc80cc8b43dc79e02a15486c58eead5cae0a40906d6be64e2522285b5b39", size = 239799, upload-time = "2026-01-01T12:36:00.378Z" }, + { url = "https://files.pythonhosted.org/packages/3f/6c/d79c94809742b56b9180a9a9ec2937607db0b8eb34b8ca75d86d3114d6dd/pycares-5.0.1-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f498a6606247bfe896c2a4d837db711eb7b0ba23e409e16e4b23def4bada4b9d", size = 223501, upload-time = "2026-01-01T12:36:02.695Z" }, + { url = "https://files.pythonhosted.org/packages/69/08/83084b67cbce08f44fd803b88816fc80d2fe2fb3d483d5432925df44371b/pycares-5.0.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a7d197835cdb4b202a3b12562b32799e27bb132262d4aa1ac3ee9d440e8ec22c", size = 223708, upload-time = "2026-01-01T12:36:04.357Z" }, + { url = "https://files.pythonhosted.org/packages/15/57/63a6e9ef356c5149b8ec72a694e02207fd8ae643895aeb78a9f0c07f1502/pycares-5.0.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f78ab823732b050d658eb735d553726663c9bccdeeee0653247533a23eb2e255", size = 251816, upload-time = "2026-01-01T12:36:05.618Z" }, + { url = "https://files.pythonhosted.org/packages/43/1c/1c85c6355cf7bc3ae86a1024d60f9cabdc12af63306a5f59370ac8718a41/pycares-5.0.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f444ab7f318e9b2c209b45496fb07bff5e7ada606e15d5253a162964aa078527", size = 238259, upload-time = "2026-01-01T12:36:07.609Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7f/bd5ff5a460e50433f993560e4e5d229559a8bf271dbdf6be832faf1973b5/pycares-5.0.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9de80997de7538619b7dd28ec4371e5172e3f9480e4fc648726d3d5ba661ca05", size = 223732, upload-time = "2026-01-01T12:36:09.893Z" }, + { url = "https://files.pythonhosted.org/packages/b5/fe/e77738366e00dc0918bbeb0c8fc63579e5d9cec748a2b838e207e548b5d9/pycares-5.0.1-cp314-cp314-win_amd64.whl", hash = "sha256:206ce9f3cb9d51f5065c81b23c22996230fbc2cf58ae22834c623631b2b473aa", size = 120847, upload-time = "2026-01-01T12:36:11.494Z" }, + { url = "https://files.pythonhosted.org/packages/81/17/758e9af7ee8589ac6deddf7ea56d75b982f155bc2052ef61c45d5f371389/pycares-5.0.1-cp314-cp314-win_arm64.whl", hash = "sha256:45fb3b07231120e8cb5b75be7f15f16115003e9251991dc37a3e5c63733d63b5", size = 112595, upload-time = "2026-01-01T12:36:12.973Z" }, + { url = "https://files.pythonhosted.org/packages/56/12/4f1d418fed957fc96089c69d9ec82314b3b91c48c7f9463385842acad9c4/pycares-5.0.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:602f3eac4b880a2527d21f52b2319cb10fde9225d103d338c4d0b2b07f136849", size = 137061, upload-time = "2026-01-01T12:36:15.027Z" }, + { url = "https://files.pythonhosted.org/packages/29/8c/559cea98a8a5d0f38b50b4b812a07fdbcdb1a961bed9e2e9d5d343e53c6f/pycares-5.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1c3736deef003f0c57bc4e7f94d54270d0824350a8f5ceaba3a20b2ce8fb427", size = 131551, upload-time = "2026-01-01T12:36:16.74Z" }, + { url = "https://files.pythonhosted.org/packages/34/cd/aee5d8070888d7be509d4f32a348e2821309ec67980498e5a974cd9e4990/pycares-5.0.1-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e63328df86d37150ce697fb5d9313d1d468dd4dddee1d09342cb2ed241ce6ad9", size = 230409, upload-time = "2026-01-01T12:36:18.909Z" }, + { url = "https://files.pythonhosted.org/packages/5e/94/15d5cf7d8e7af4b4ce3e19ea117dfe565c08d60d82f043ad23843703a135/pycares-5.0.1-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:57f6fd696213329d9a69b9664a68b1ff2a71ccbdc1fc928a42c9a92858c1ec5d", size = 261297, upload-time = "2026-01-01T12:36:20.771Z" }, + { url = "https://files.pythonhosted.org/packages/af/46/24f6ddc7a37ec6eaa1c38f617f39624211d8e7cdca49b644bfc5f467f275/pycares-5.0.1-cp314-cp314t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9d0878edabfbecb48a29e8769284003d8dbc05936122fe361849cd5fa52722e0", size = 248071, upload-time = "2026-01-01T12:36:22.925Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f0/7eb7fe44f0db55b9083725ab7a084874c2dc02806d9613e07e719838c2ab/pycares-5.0.1-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50e21f27a91be122e066ddd78c2d0d2769e547561481d8342a9d652a345b89f7", size = 232073, upload-time = "2026-01-01T12:36:25.773Z" }, + { url = "https://files.pythonhosted.org/packages/1d/cd/993b17e0c049a56b5af4df3fd053acc57b37e17e0dcd709b2d337c22d57d/pycares-5.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:97ceda969f5a5d5c6b15558b658c29e4301b3a2c4615523797b5f9d4ac74772e", size = 232815, upload-time = "2026-01-01T12:36:27.798Z" }, + { url = "https://files.pythonhosted.org/packages/7a/ff/170177bcc5dff31e735f209f5de63362f513ac18846c83d50e4e68f57866/pycares-5.0.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4d1713e602ab09882c3e65499b2cc763bff0371117327cad704cf524268c2604", size = 261111, upload-time = "2026-01-01T12:36:29.94Z" }, + { url = "https://files.pythonhosted.org/packages/4d/4a/4c6497b8ca9279b4038ee8c7e2c49504008d594d06a044e00678b30c10fe/pycares-5.0.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:954a379055d6c66b2e878b52235b382168d1a3230793ff44454019394aecac5e", size = 246311, upload-time = "2026-01-01T12:36:31.352Z" }, + { url = "https://files.pythonhosted.org/packages/06/19/1603f51f0d73bf34017a9e6967540c2bc138f9541aa7cc1ef38990b3ce9d/pycares-5.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:145d8a20f7fd1d58a2e49b7ef4309ec9bdcab479ac65c2e49480e20d3f890c23", size = 232027, upload-time = "2026-01-01T12:36:34.374Z" }, + { url = "https://files.pythonhosted.org/packages/7a/de/c000a682757b84688722ac232a24a86b6f195f1f4732432ecf35d0a768a5/pycares-5.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:ebc9daba03c7ff3f62616c84c6cb37517445d15df00e1754852d6006039eb4a4", size = 121267, upload-time = "2026-01-01T12:36:35.741Z" }, + { url = "https://files.pythonhosted.org/packages/b2/c4/8bfffecd08b9b198113fcff5f0ab84bbe696f07dec46dd1ccae0e7b28c23/pycares-5.0.1-cp314-cp314t-win_arm64.whl", hash = "sha256:e0a86eff6bf9e91d5dd8876b1b82ee45704f46b1104c24291d3dea2c1fc8ebcb", size = 113043, upload-time = "2026-01-01T12:36:37.895Z" }, +] + +[[package]] +name = "pycognito" +version = "2024.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "boto3" }, + { name = "envs" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/26/67/3975cf257fcc04903686ef87d39be386d894a0d8182f43d37e9cbfc9609f/pycognito-2024.5.1.tar.gz", hash = "sha256:e211c66698c2c3dc8680e95107c2b4a922f504c3f7c179c27b8ee1ab0fc23ae4", size = 31182, upload-time = "2024-05-16T10:02:28.766Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/7a/f38dd351f47596b22ddbde1b8906e7f43d14be391dcdbd0c2daba886f26c/pycognito-2024.5.1-py3-none-any.whl", hash = "sha256:c821895dc62b7aea410fdccae4f96d8be7cab374182339f50a03de0fcb93f9ea", size = 26607, upload-time = "2024-05-16T10:02:27.3Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8d/35/d319ed522433215526689bad428a94058b6dd12190ce7ddd78618ac14b28/pydantic-2.12.2.tar.gz", hash = "sha256:7b8fa15b831a4bbde9d5b84028641ac3080a4ca2cbd4a621a661687e741624fd", size = 816358, upload-time = "2025-10-14T15:02:21.842Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/98/468cb649f208a6f1279448e6e5247b37ae79cf5e4041186f1e2ef3d16345/pydantic-2.12.2-py3-none-any.whl", hash = "sha256:25ff718ee909acd82f1ff9b1a4acfd781bb23ab3739adaa7144f19a6a4e231ae", size = 460628, upload-time = "2025-10-14T15:02:19.623Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/18/d0944e8eaaa3efd0a91b0f1fc537d3be55ad35091b6a87638211ba691964/pydantic_core-2.41.4.tar.gz", hash = "sha256:70e47929a9d4a1905a67e4b687d5946026390568a8e952b92824118063cee4d5", size = 457557, upload-time = "2025-10-14T10:23:47.909Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/28/d3325da57d413b9819365546eb9a6e8b7cbd9373d9380efd5f74326143e6/pydantic_core-2.41.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:e9205d97ed08a82ebb9a307e92914bb30e18cdf6f6b12ca4bedadb1588a0bfe1", size = 2102022, upload-time = "2025-10-14T10:21:32.809Z" }, + { url = "https://files.pythonhosted.org/packages/9e/24/b58a1bc0d834bf1acc4361e61233ee217169a42efbdc15a60296e13ce438/pydantic_core-2.41.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:82df1f432b37d832709fbcc0e24394bba04a01b6ecf1ee87578145c19cde12ac", size = 1905495, upload-time = "2025-10-14T10:21:34.812Z" }, + { url = "https://files.pythonhosted.org/packages/fb/a4/71f759cc41b7043e8ecdaab81b985a9b6cad7cec077e0b92cff8b71ecf6b/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fc3b4cc4539e055cfa39a3763c939f9d409eb40e85813257dcd761985a108554", size = 1956131, upload-time = "2025-10-14T10:21:36.924Z" }, + { url = "https://files.pythonhosted.org/packages/b0/64/1e79ac7aa51f1eec7c4cda8cbe456d5d09f05fdd68b32776d72168d54275/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b1eb1754fce47c63d2ff57fdb88c351a6c0150995890088b33767a10218eaa4e", size = 2052236, upload-time = "2025-10-14T10:21:38.927Z" }, + { url = "https://files.pythonhosted.org/packages/e9/e3/a3ffc363bd4287b80f1d43dc1c28ba64831f8dfc237d6fec8f2661138d48/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e6ab5ab30ef325b443f379ddb575a34969c333004fca5a1daa0133a6ffaad616", size = 2223573, upload-time = "2025-10-14T10:21:41.574Z" }, + { url = "https://files.pythonhosted.org/packages/28/27/78814089b4d2e684a9088ede3790763c64693c3d1408ddc0a248bc789126/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:31a41030b1d9ca497634092b46481b937ff9397a86f9f51bd41c4767b6fc04af", size = 2342467, upload-time = "2025-10-14T10:21:44.018Z" }, + { url = "https://files.pythonhosted.org/packages/92/97/4de0e2a1159cb85ad737e03306717637842c88c7fd6d97973172fb183149/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a44ac1738591472c3d020f61c6df1e4015180d6262ebd39bf2aeb52571b60f12", size = 2063754, upload-time = "2025-10-14T10:21:46.466Z" }, + { url = "https://files.pythonhosted.org/packages/0f/50/8cb90ce4b9efcf7ae78130afeb99fd1c86125ccdf9906ef64b9d42f37c25/pydantic_core-2.41.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d72f2b5e6e82ab8f94ea7d0d42f83c487dc159c5240d8f83beae684472864e2d", size = 2196754, upload-time = "2025-10-14T10:21:48.486Z" }, + { url = "https://files.pythonhosted.org/packages/34/3b/ccdc77af9cd5082723574a1cc1bcae7a6acacc829d7c0a06201f7886a109/pydantic_core-2.41.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:c4d1e854aaf044487d31143f541f7aafe7b482ae72a022c664b2de2e466ed0ad", size = 2137115, upload-time = "2025-10-14T10:21:50.63Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ba/e7c7a02651a8f7c52dc2cff2b64a30c313e3b57c7d93703cecea76c09b71/pydantic_core-2.41.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b568af94267729d76e6ee5ececda4e283d07bbb28e8148bb17adad93d025d25a", size = 2317400, upload-time = "2025-10-14T10:21:52.959Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ba/6c533a4ee8aec6b812c643c49bb3bd88d3f01e3cebe451bb85512d37f00f/pydantic_core-2.41.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:6d55fb8b1e8929b341cc313a81a26e0d48aa3b519c1dbaadec3a6a2b4fcad025", size = 2312070, upload-time = "2025-10-14T10:21:55.419Z" }, + { url = "https://files.pythonhosted.org/packages/22/ae/f10524fcc0ab8d7f96cf9a74c880243576fd3e72bd8ce4f81e43d22bcab7/pydantic_core-2.41.4-cp314-cp314-win32.whl", hash = "sha256:5b66584e549e2e32a1398df11da2e0a7eff45d5c2d9db9d5667c5e6ac764d77e", size = 1982277, upload-time = "2025-10-14T10:21:57.474Z" }, + { url = "https://files.pythonhosted.org/packages/b4/dc/e5aa27aea1ad4638f0c3fb41132f7eb583bd7420ee63204e2d4333a3bbf9/pydantic_core-2.41.4-cp314-cp314-win_amd64.whl", hash = "sha256:557a0aab88664cc552285316809cab897716a372afaf8efdbef756f8b890e894", size = 2024608, upload-time = "2025-10-14T10:21:59.557Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/51d89cc2612bd147198e120a13f150afbf0bcb4615cddb049ab10b81b79e/pydantic_core-2.41.4-cp314-cp314-win_arm64.whl", hash = "sha256:3f1ea6f48a045745d0d9f325989d8abd3f1eaf47dd00485912d1a3a63c623a8d", size = 1967614, upload-time = "2025-10-14T10:22:01.847Z" }, + { url = "https://files.pythonhosted.org/packages/0d/c2/472f2e31b95eff099961fa050c376ab7156a81da194f9edb9f710f68787b/pydantic_core-2.41.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6c1fe4c5404c448b13188dd8bd2ebc2bdd7e6727fa61ff481bcc2cca894018da", size = 1876904, upload-time = "2025-10-14T10:22:04.062Z" }, + { url = "https://files.pythonhosted.org/packages/4a/07/ea8eeb91173807ecdae4f4a5f4b150a520085b35454350fc219ba79e66a3/pydantic_core-2.41.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:523e7da4d43b113bf8e7b49fa4ec0c35bf4fe66b2230bfc5c13cc498f12c6c3e", size = 1882538, upload-time = "2025-10-14T10:22:06.39Z" }, + { url = "https://files.pythonhosted.org/packages/1e/29/b53a9ca6cd366bfc928823679c6a76c7a4c69f8201c0ba7903ad18ebae2f/pydantic_core-2.41.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5729225de81fb65b70fdb1907fcf08c75d498f4a6f15af005aabb1fdadc19dfa", size = 2041183, upload-time = "2025-10-14T10:22:08.812Z" }, + { url = "https://files.pythonhosted.org/packages/c7/3d/f8c1a371ceebcaf94d6dd2d77c6cf4b1c078e13a5837aee83f760b4f7cfd/pydantic_core-2.41.4-cp314-cp314t-win_amd64.whl", hash = "sha256:de2cfbb09e88f0f795fd90cf955858fc2c691df65b1f21f0aa00b99f3fbc661d", size = 1993542, upload-time = "2025-10-14T10:22:11.332Z" }, + { url = "https://files.pythonhosted.org/packages/8a/ac/9fc61b4f9d079482a290afe8d206b8f490e9fd32d4fc03ed4fc698214e01/pydantic_core-2.41.4-cp314-cp314t-win_arm64.whl", hash = "sha256:d34f950ae05a83e0ede899c595f312ca976023ea1db100cd5aa188f7005e3ab0", size = 1973897, upload-time = "2025-10-14T10:22:13.444Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pyjwt" +version = "2.10.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785, upload-time = "2024-11-28T03:43:29.933Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + +[[package]] +name = "pylint" +version = "4.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "astroid" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "dill" }, + { name = "isort" }, + { name = "mccabe" }, + { name = "platformdirs" }, + { name = "tomlkit" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/b6/74d9a8a68b8067efce8d07707fe6a236324ee1e7808d2eb3646ec8517c7d/pylint-4.0.5.tar.gz", hash = "sha256:8cd6a618df75deb013bd7eb98327a95f02a6fb839205a6bbf5456ef96afb317c", size = 1572474, upload-time = "2026-02-20T09:07:33.621Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/6f/9ac2548e290764781f9e7e2aaf0685b086379dabfb29ca38536985471eaf/pylint-4.0.5-py3-none-any.whl", hash = "sha256:00f51c9b14a3b3ae08cff6b2cdd43f28165c78b165b628692e428fb1f8dc2cf2", size = 536694, upload-time = "2026-02-20T09:07:31.028Z" }, +] + +[[package]] +name = "pylint-per-file-ignores" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a5/3d/21bec2f2f432519616c34a64ba0766ef972fdfb6234a86bb1b8baf4b0c7c/pylint_per_file_ignores-1.4.0.tar.gz", hash = "sha256:c0de7b3d0169571aefaa1ac3a82a265641b8825b54a0b6f5ef27c3b76b988609", size = 4419, upload-time = "2025-01-17T21:35:02.383Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/0e/bf3473d86648a17e6dd6ee9e6abce526b077169031177f4f2031368f864a/pylint_per_file_ignores-1.4.0-py3-none-any.whl", hash = "sha256:0cd82d22551738b4e63a0aa1dab2a1fc4016e8f27f1429159616483711e122fd", size = 4888, upload-time = "2025-01-17T21:35:00.371Z" }, +] + +[[package]] +name = "pynacl" +version = "1.6.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/9a/4019b524b03a13438637b11538c82781a5eda427394380381af8f04f467a/pynacl-1.6.2.tar.gz", hash = "sha256:018494d6d696ae03c7e656e5e74cdfd8ea1326962cc401bcf018f1ed8436811c", size = 3511692, upload-time = "2026-01-01T17:48:10.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/79/0e3c34dc3c4671f67d251c07aa8eb100916f250ee470df230b0ab89551b4/pynacl-1.6.2-cp314-cp314t-macosx_10_10_universal2.whl", hash = "sha256:622d7b07cc5c02c666795792931b50c91f3ce3c2649762efb1ef0d5684c81594", size = 390064, upload-time = "2026-01-01T17:31:57.264Z" }, + { url = "https://files.pythonhosted.org/packages/eb/1c/23a26e931736e13b16483795c8a6b2f641bf6a3d5238c22b070a5112722c/pynacl-1.6.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d071c6a9a4c94d79eb665db4ce5cedc537faf74f2355e4d502591d850d3913c0", size = 809370, upload-time = "2026-01-01T17:31:59.198Z" }, + { url = "https://files.pythonhosted.org/packages/87/74/8d4b718f8a22aea9e8dcc8b95deb76d4aae380e2f5b570cc70b5fd0a852d/pynacl-1.6.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe9847ca47d287af41e82be1dd5e23023d3c31a951da134121ab02e42ac218c9", size = 1408304, upload-time = "2026-01-01T17:32:01.162Z" }, + { url = "https://files.pythonhosted.org/packages/fd/73/be4fdd3a6a87fe8a4553380c2b47fbd1f7f58292eb820902f5c8ac7de7b0/pynacl-1.6.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04316d1fc625d860b6c162fff704eb8426b1a8bcd3abacea11142cbd99a6b574", size = 844871, upload-time = "2026-01-01T17:32:02.824Z" }, + { url = "https://files.pythonhosted.org/packages/55/ad/6efc57ab75ee4422e96b5f2697d51bbcf6cdcc091e66310df91fbdc144a8/pynacl-1.6.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44081faff368d6c5553ccf55322ef2819abb40e25afaec7e740f159f74813634", size = 1446356, upload-time = "2026-01-01T17:32:04.452Z" }, + { url = "https://files.pythonhosted.org/packages/78/b7/928ee9c4779caa0a915844311ab9fb5f99585621c5d6e4574538a17dca07/pynacl-1.6.2-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:a9f9932d8d2811ce1a8ffa79dcbdf3970e7355b5c8eb0c1a881a57e7f7d96e88", size = 826814, upload-time = "2026-01-01T17:32:06.078Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a9/1bdba746a2be20f8809fee75c10e3159d75864ef69c6b0dd168fc60e485d/pynacl-1.6.2-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:bc4a36b28dd72fb4845e5d8f9760610588a96d5a51f01d84d8c6ff9849968c14", size = 1411742, upload-time = "2026-01-01T17:32:07.651Z" }, + { url = "https://files.pythonhosted.org/packages/f3/2f/5e7ea8d85f9f3ea5b6b87db1d8388daa3587eed181bdeb0306816fdbbe79/pynacl-1.6.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bffb6d0f6becacb6526f8f42adfb5efb26337056ee0831fb9a7044d1a964444", size = 801714, upload-time = "2026-01-01T17:32:09.558Z" }, + { url = "https://files.pythonhosted.org/packages/06/ea/43fe2f7eab5f200e40fb10d305bf6f87ea31b3bbc83443eac37cd34a9e1e/pynacl-1.6.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2fef529ef3ee487ad8113d287a593fa26f48ee3620d92ecc6f1d09ea38e0709b", size = 1372257, upload-time = "2026-01-01T17:32:11.026Z" }, + { url = "https://files.pythonhosted.org/packages/4d/54/c9ea116412788629b1347e415f72195c25eb2f3809b2d3e7b25f5c79f13a/pynacl-1.6.2-cp314-cp314t-win32.whl", hash = "sha256:a84bf1c20339d06dc0c85d9aea9637a24f718f375d861b2668b2f9f96fa51145", size = 231319, upload-time = "2026-01-01T17:32:12.46Z" }, + { url = "https://files.pythonhosted.org/packages/ce/04/64e9d76646abac2dccf904fccba352a86e7d172647557f35b9fe2a5ee4a1/pynacl-1.6.2-cp314-cp314t-win_amd64.whl", hash = "sha256:320ef68a41c87547c91a8b58903c9caa641ab01e8512ce291085b5fe2fcb7590", size = 244044, upload-time = "2026-01-01T17:32:13.781Z" }, + { url = "https://files.pythonhosted.org/packages/33/33/7873dc161c6a06f43cda13dec67b6fe152cb2f982581151956fa5e5cdb47/pynacl-1.6.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d29bfe37e20e015a7d8b23cfc8bd6aa7909c92a1b8f41ee416bbb3e79ef182b2", size = 188740, upload-time = "2026-01-01T17:32:15.083Z" }, + { url = "https://files.pythonhosted.org/packages/be/7b/4845bbf88e94586ec47a432da4e9107e3fc3ce37eb412b1398630a37f7dd/pynacl-1.6.2-cp38-abi3-macosx_10_10_universal2.whl", hash = "sha256:c949ea47e4206af7c8f604b8278093b674f7c79ed0d4719cc836902bf4517465", size = 388458, upload-time = "2026-01-01T17:32:16.829Z" }, + { url = "https://files.pythonhosted.org/packages/1e/b4/e927e0653ba63b02a4ca5b4d852a8d1d678afbf69b3dbf9c4d0785ac905c/pynacl-1.6.2-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8845c0631c0be43abdd865511c41eab235e0be69c81dc66a50911594198679b0", size = 800020, upload-time = "2026-01-01T17:32:18.34Z" }, + { url = "https://files.pythonhosted.org/packages/7f/81/d60984052df5c97b1d24365bc1e30024379b42c4edcd79d2436b1b9806f2/pynacl-1.6.2-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:22de65bb9010a725b0dac248f353bb072969c94fa8d6b1f34b87d7953cf7bbe4", size = 1399174, upload-time = "2026-01-01T17:32:20.239Z" }, + { url = "https://files.pythonhosted.org/packages/68/f7/322f2f9915c4ef27d140101dd0ed26b479f7e6f5f183590fd32dfc48c4d3/pynacl-1.6.2-cp38-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:46065496ab748469cdd999246d17e301b2c24ae2fdf739132e580a0e94c94a87", size = 835085, upload-time = "2026-01-01T17:32:22.24Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d0/f301f83ac8dbe53442c5a43f6a39016f94f754d7a9815a875b65e218a307/pynacl-1.6.2-cp38-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a66d6fb6ae7661c58995f9c6435bda2b1e68b54b598a6a10247bfcdadac996c", size = 1437614, upload-time = "2026-01-01T17:32:23.766Z" }, + { url = "https://files.pythonhosted.org/packages/c4/58/fc6e649762b029315325ace1a8c6be66125e42f67416d3dbd47b69563d61/pynacl-1.6.2-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:26bfcd00dcf2cf160f122186af731ae30ab120c18e8375684ec2670dccd28130", size = 818251, upload-time = "2026-01-01T17:32:25.69Z" }, + { url = "https://files.pythonhosted.org/packages/c9/a8/b917096b1accc9acd878819a49d3d84875731a41eb665f6ebc826b1af99e/pynacl-1.6.2-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c8a231e36ec2cab018c4ad4358c386e36eede0319a0c41fed24f840b1dac59f6", size = 1402859, upload-time = "2026-01-01T17:32:27.215Z" }, + { url = "https://files.pythonhosted.org/packages/85/42/fe60b5f4473e12c72f977548e4028156f4d340b884c635ec6b063fe7e9a5/pynacl-1.6.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:68be3a09455743ff9505491220b64440ced8973fe930f270c8e07ccfa25b1f9e", size = 791926, upload-time = "2026-01-01T17:32:29.314Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f9/e40e318c604259301cc091a2a63f237d9e7b424c4851cafaea4ea7c4834e/pynacl-1.6.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8b097553b380236d51ed11356c953bf8ce36a29a3e596e934ecabe76c985a577", size = 1363101, upload-time = "2026-01-01T17:32:31.263Z" }, + { url = "https://files.pythonhosted.org/packages/48/47/e761c254f410c023a469284a9bc210933e18588ca87706ae93002c05114c/pynacl-1.6.2-cp38-abi3-win32.whl", hash = "sha256:5811c72b473b2f38f7e2a3dc4f8642e3a3e9b5e7317266e4ced1fba85cae41aa", size = 227421, upload-time = "2026-01-01T17:32:33.076Z" }, + { url = "https://files.pythonhosted.org/packages/41/ad/334600e8cacc7d86587fe5f565480fde569dfb487389c8e1be56ac21d8ac/pynacl-1.6.2-cp38-abi3-win_amd64.whl", hash = "sha256:62985f233210dee6548c223301b6c25440852e13d59a8b81490203c3227c5ba0", size = 239754, upload-time = "2026-01-01T17:32:34.557Z" }, + { url = "https://files.pythonhosted.org/packages/29/7d/5945b5af29534641820d3bd7b00962abbbdfee84ec7e19f0d5b3175f9a31/pynacl-1.6.2-cp38-abi3-win_arm64.whl", hash = "sha256:834a43af110f743a754448463e8fd61259cd4ab5bbedcf70f9dabad1d28a394c", size = 184801, upload-time = "2026-01-01T17:32:36.309Z" }, +] + +[[package]] +name = "pyobjc-core" +version = "12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b8/b6/d5612eb40be4fd5ef88c259339e6313f46ba67577a95d86c3470b951fce0/pyobjc_core-12.1.tar.gz", hash = "sha256:2bb3903f5387f72422145e1466b3ac3f7f0ef2e9960afa9bcd8961c5cbf8bd21", size = 1000532, upload-time = "2025-11-14T10:08:28.292Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/98/9f4ed07162de69603144ff480be35cd021808faa7f730d082b92f7ebf2b5/pyobjc_core-12.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:844515f5d86395b979d02152576e7dee9cc679acc0b32dc626ef5bda315eaa43", size = 670164, upload-time = "2025-11-14T09:34:37.458Z" }, + { url = "https://files.pythonhosted.org/packages/62/50/dc076965c96c7f0de25c0a32b7f8aa98133ed244deaeeacfc758783f1f30/pyobjc_core-12.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:453b191df1a4b80e756445b935491b974714456ae2cbae816840bd96f86db882", size = 712204, upload-time = "2025-11-14T09:35:24.148Z" }, +] + +[[package]] +name = "pyobjc-framework-cocoa" +version = "12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/a3/16ca9a15e77c061a9250afbae2eae26f2e1579eb8ca9462ae2d2c71e1169/pyobjc_framework_cocoa-12.1.tar.gz", hash = "sha256:5556c87db95711b985d5efdaaf01c917ddd41d148b1e52a0c66b1a2e2c5c1640", size = 2772191, upload-time = "2025-11-14T10:13:02.069Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/bb/f777cc9e775fc7dae77b569254570fe46eb842516b3e4fe383ab49eab598/pyobjc_framework_cocoa-12.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:03342a60fc0015bcdf9b93ac0b4f457d3938e9ef761b28df9564c91a14f0129a", size = 384932, upload-time = "2025-11-14T09:42:29.771Z" }, + { url = "https://files.pythonhosted.org/packages/58/27/b457b7b37089cad692c8aada90119162dfb4c4a16f513b79a8b2b022b33b/pyobjc_framework_cocoa-12.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:6ba1dc1bfa4da42d04e93d2363491275fb2e2be5c20790e561c8a9e09b8cf2cc", size = 388970, upload-time = "2025-11-14T09:42:53.964Z" }, +] + +[[package]] +name = "pyobjc-framework-corebluetooth" +version = "12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core" }, + { name = "pyobjc-framework-cocoa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4b/25/d21d6cb3fd249c2c2aa96ee54279f40876a0c93e7161b3304bf21cbd0bfe/pyobjc_framework_corebluetooth-12.1.tar.gz", hash = "sha256:8060c1466d90bbb9100741a1091bb79975d9ba43911c9841599879fc45c2bbe0", size = 33157, upload-time = "2025-11-14T10:13:28.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/41/90640a4db62f0bf0611cf8a161129c798242116e2a6a44995668b017b106/pyobjc_framework_corebluetooth-12.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:15ba5207ca626dffe57ccb7c1beaf01f93930159564211cb97d744eaf0d812aa", size = 13222, upload-time = "2025-11-14T09:44:14.345Z" }, + { url = "https://files.pythonhosted.org/packages/86/99/8ed2f0ca02b9abe204966142bd8c4501cf6da94234cc320c4c0562c467e8/pyobjc_framework_corebluetooth-12.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:e5385195bd365a49ce70e2fb29953681eefbe68a7b15ecc2493981d2fb4a02b1", size = 13408, upload-time = "2025-11-14T09:44:16.558Z" }, +] + +[[package]] +name = "pyobjc-framework-libdispatch" +version = "12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core" }, + { name = "pyobjc-framework-cocoa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/26/e8/75b6b9b3c88b37723c237e5a7600384ea2d84874548671139db02e76652b/pyobjc_framework_libdispatch-12.1.tar.gz", hash = "sha256:4035535b4fae1b5e976f3e0e38b6e3442ffea1b8aa178d0ca89faa9b8ecdea41", size = 38277, upload-time = "2025-11-14T10:16:46.235Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/d8/7d60a70fc1a546c6cb482fe0595cb4bd1368d75c48d49e76d0bc6c0a2d0f/pyobjc_framework_libdispatch-12.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0ebfd9e4446ab6528126bff25cfb09e4213ddf992b3208978911cfd3152e45f5", size = 15693, upload-time = "2025-11-14T09:53:05.531Z" }, + { url = "https://files.pythonhosted.org/packages/99/32/15e08a0c4bb536303e1568e2ba5cae1ce39a2e026a03aea46173af4c7a2d/pyobjc_framework_libdispatch-12.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:23fc9915cba328216b6a736c7a48438a16213f16dfb467f69506300b95938cc7", size = 15976, upload-time = "2025-11-14T09:53:07.936Z" }, +] + +[[package]] +name = "pyopenssl" +version = "26.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8e/11/a62e1d33b373da2b2c2cd9eb508147871c80f12b1cacde3c5d314922afdd/pyopenssl-26.0.0.tar.gz", hash = "sha256:f293934e52936f2e3413b89c6ce36df66a0b34ae1ea3a053b8c5020ff2f513fc", size = 185534, upload-time = "2026-03-15T14:28:26.353Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/7d/d4f7d908fa8415571771b30669251d57c3cf313b36a856e6d7548ae01619/pyopenssl-26.0.0-py3-none-any.whl", hash = "sha256:df94d28498848b98cc1c0ffb8ef1e71e40210d3b0a8064c9d29571ed2904bf81", size = 57969, upload-time = "2026-03-15T14:28:24.864Z" }, +] + +[[package]] +name = "pyrfc3339" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b4/7f/3c194647ecb80ada6937c38a162ab3edba85a8b6a58fa2919405f4de2509/pyrfc3339-2.1.0.tar.gz", hash = "sha256:c569a9714faf115cdb20b51e830e798c1f4de8dabb07f6ff25d221b5d09d8d7f", size = 12589, upload-time = "2025-08-23T16:40:31.889Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/90/0200184d2124484f918054751ef997ed6409cb05b7e8dcbf5a22da4c4748/pyrfc3339-2.1.0-py3-none-any.whl", hash = "sha256:560f3f972e339f579513fe1396974352fd575ef27caff160a38b312252fcddf3", size = 6758, upload-time = "2025-08-23T16:40:30.49Z" }, +] + +[[package]] +name = "pyric" +version = "0.1.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/08/64/a99f27d3b4347486c7bfc0aa516016c46dc4c0f380ffccbd742a61af1eda/PyRIC-0.1.6.3.tar.gz", hash = "sha256:b539b01cafebd2406c00097f94525ea0f8ecd1dd92f7731f43eac0ef16c2ccc9", size = 870401, upload-time = "2016-12-04T07:54:48.374Z" } + +[[package]] +name = "pyright" +version = "1.1.409" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodeenv" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/51/4e/3aa27f74211522dba7e9cbc3e74de779c6d4b654c54e50a4840623be8014/pyright-1.1.409.tar.gz", hash = "sha256:986ee05beca9e077c165758ad123667c679e050059a2546aa02473930394bc93", size = 4430434, upload-time = "2026-04-23T11:02:03.799Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/6b/330d8ebae582b30c2959a1ef4c3bc344ebde48c2ff0c3f113c4710735e11/pyright-1.1.409-py3-none-any.whl", hash = "sha256:aa3ea228cab90c845c7a60d28db7a844c04315356392aa09fafcee98c8c22fb3", size = 6438161, upload-time = "2026-04-23T11:02:01.309Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/1d/eb34f286b164c5e431a810a38697409cca1112cee04b287bb56ac486730b/pytest-9.0.0.tar.gz", hash = "sha256:8f44522eafe4137b0f35c9ce3072931a788a21ee40a2ed279e817d3cc16ed21e", size = 1562764, upload-time = "2025-11-08T17:25:33.34Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/72/99/cafef234114a3b6d9f3aaed0723b437c40c57bdb7b3e4c3a575bc4890052/pytest-9.0.0-py3-none-any.whl", hash = "sha256:e5ccdf10b0bac554970ee88fc1a4ad0ee5d221f8ef22321f9b7e4584e19d7f96", size = 373364, upload-time = "2025-11-08T17:25:31.811Z" }, +] + +[[package]] +name = "pytest-aiohttp" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/4b/d326890c153f2c4ce1bf45d07683c08c10a1766058a22934620bc6ac6592/pytest_aiohttp-1.1.0.tar.gz", hash = "sha256:147de8cb164f3fc9d7196967f109ab3c0b93ea3463ab50631e56438eab7b5adc", size = 12842, upload-time = "2025-01-23T12:44:04.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/0f/e6af71c02e0f1098eaf7d2dbf3ffdf0a69fc1e0ef174f96af05cef161f1b/pytest_aiohttp-1.1.0-py3-none-any.whl", hash = "sha256:f39a11693a0dce08dd6c542d241e199dd8047a6e6596b2bcfa60d373f143456d", size = 8932, upload-time = "2025-01-23T12:44:03.27Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, +] + +[[package]] +name = "pytest-freezer" +version = "0.4.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "freezegun" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/f0/98dcbc5324064360b19850b14c84cea9ca50785d921741dbfc442346e925/pytest_freezer-0.4.9.tar.gz", hash = "sha256:21bf16bc9cc46bf98f94382c4b5c3c389be7056ff0be33029111ae11b3f1c82a", size = 3177, upload-time = "2024-12-12T08:53:08.684Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/e9/30252bc05bcf67200a17f4f0b4cc7598f0a68df4fa9fa356193aa899f145/pytest_freezer-0.4.9-py3-none-any.whl", hash = "sha256:8b6c50523b7d4aec4590b52bfa5ff766d772ce506e2bf4846c88041ea9ccae59", size = 3192, upload-time = "2024-12-12T08:53:07.641Z" }, +] + +[[package]] +name = "pytest-github-actions-annotate-failures" +version = "0.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/39/d4/c54ee6a871eee4a7468e3a8c0dead28e634c0bc2110c694309dcb7563a66/pytest_github_actions_annotate_failures-0.3.0.tar.gz", hash = "sha256:d4c3177c98046c3900a7f8ddebb22ea54b9f6822201b5d3ab8fcdea51e010db7", size = 11248, upload-time = "2025-01-17T22:39:32.722Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/73/7b0b15cb8605ee967b34aa1d949737ab664f94e6b0f1534e8339d9e64ab2/pytest_github_actions_annotate_failures-0.3.0-py3-none-any.whl", hash = "sha256:41ea558ba10c332c0bfc053daeee0c85187507b2034e990f21e4f7e5fef044cf", size = 6030, upload-time = "2025-01-17T22:39:31.701Z" }, +] + +[[package]] +name = "pytest-homeassistant-custom-component" +version = "0.13.325" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohasupervisor" }, + { name = "coverage" }, + { name = "freezegun" }, + { name = "homeassistant" }, + { name = "license-expression" }, + { name = "mock-open" }, + { name = "numpy" }, + { name = "paho-mqtt" }, + { name = "pipdeptree" }, + { name = "pydantic" }, + { name = "pylint-per-file-ignores" }, + { name = "pytest" }, + { name = "pytest-aiohttp" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "pytest-freezer" }, + { name = "pytest-github-actions-annotate-failures" }, + { name = "pytest-picked" }, + { name = "pytest-socket" }, + { name = "pytest-timeout" }, + { name = "pytest-unordered" }, + { name = "pytest-xdist" }, + { name = "requests-mock" }, + { name = "respx" }, + { name = "sqlalchemy" }, + { name = "syrupy" }, + { name = "tqdm" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f2/40/e14846938decc2073b78d2c88a08444973aae82b7fb263ba0073aca89793/pytest_homeassistant_custom_component-0.13.325.tar.gz", hash = "sha256:12924ad407b6601748d3a3c7883423fd0d34b1f782f14b615d1b61d994f371fc", size = 69950, upload-time = "2026-04-25T05:37:23.337Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/c7/51cac710f1b6679550d04e162899dbdf4e7289ba715e5c4a9bff6bec7cfa/pytest_homeassistant_custom_component-0.13.325-py3-none-any.whl", hash = "sha256:9b758a092f74722e060d252ac80d71b6ae1ec2a712d55d0c9d378266e74e2c05", size = 75792, upload-time = "2026-04-25T05:37:21.529Z" }, +] + +[[package]] +name = "pytest-picked" +version = "0.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ba/e4/51a54dd6638fd4a7c45bb20a737235fd92cbb4d24b5ff681d64ace5d02e9/pytest_picked-0.5.1.tar.gz", hash = "sha256:6634c4356a560a5dc3dba35471865e6eb06bbd356b56b69c540593e9d5620ded", size = 8401, upload-time = "2024-11-06T23:19:52.538Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/81/450c017746caab376c4b6700439de9f1cc7d8e1f22dec3c1eb235cd9ad3e/pytest_picked-0.5.1-py3-none-any.whl", hash = "sha256:af65c4763b51dc095ae4bc5073a962406902422ad9629c26d8b01122b677d998", size = 6608, upload-time = "2024-11-06T23:19:51.284Z" }, +] + +[[package]] +name = "pytest-socket" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/05/ff/90c7e1e746baf3d62ce864c479fd53410b534818b9437413903596f81580/pytest_socket-0.7.0.tar.gz", hash = "sha256:71ab048cbbcb085c15a4423b73b619a8b35d6a307f46f78ea46be51b1b7e11b3", size = 12389, upload-time = "2024-01-28T20:17:23.177Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/58/5d14cb5cb59409e491ebe816c47bf81423cd03098ea92281336320ae5681/pytest_socket-0.7.0-py3-none-any.whl", hash = "sha256:7e0f4642177d55d317bbd58fc68c6bd9048d6eadb2d46a89307fa9221336ce45", size = 6754, upload-time = "2024-01-28T20:17:22.105Z" }, +] + +[[package]] +name = "pytest-timeout" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/82/4c9ecabab13363e72d880f2fb504c5f750433b2b6f16e99f4ec21ada284c/pytest_timeout-2.4.0.tar.gz", hash = "sha256:7e68e90b01f9eff71332b25001f85c75495fc4e3a836701876183c4bcfd0540a", size = 17973, upload-time = "2025-05-05T19:44:34.99Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/b6/3127540ecdf1464a00e5a01ee60a1b09175f6913f0644ac748494d9c4b21/pytest_timeout-2.4.0-py3-none-any.whl", hash = "sha256:c42667e5cdadb151aeb5b26d114aff6bdf5a907f176a007a30b940d3d865b5c2", size = 14382, upload-time = "2025-05-05T19:44:33.502Z" }, +] + +[[package]] +name = "pytest-unordered" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bd/3e/6ec9ec74551804c9e005d5b3cbe1fd663f03ed3bd4bdb1ce764c3d334d8e/pytest_unordered-0.7.0.tar.gz", hash = "sha256:0f953a438db00a9f6f99a0f4727f2d75e72dd93319b3d548a97ec9db4903a44f", size = 7930, upload-time = "2025-06-03T12:56:04.289Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/95/ae2875e19472797e9672b65412858ab6639d8e55defd9859241e5ff80d02/pytest_unordered-0.7.0-py3-none-any.whl", hash = "sha256:486b26d24a2d3b879a275c3d16d14eda1bd9c32aafddbb17b98ac755daba7584", size = 6210, upload-time = "2025-06-03T12:36:06.66Z" }, +] + +[[package]] +name = "pytest-xdist" +version = "3.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "execnet" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload-time = "2025-07-01T13:30:59.346Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "python-direnv" +version = "0.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0c/64/7903608ed54e13bddcf40944f71fbedf6ccb422975713ba37077a424e855/python_direnv-0.2.2.tar.gz", hash = "sha256:0fe2fb834c901d675edcacc688689cfcf55cf06d9cf27dc7d3768a6c38c35f00", size = 6960, upload-time = "2024-09-06T01:21:49.709Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/ce/09d7491019a54f3614af00dadd3a0caf0514f417b57390195059dcb7c35c/python_direnv-0.2.2-py3-none-any.whl", hash = "sha256:a617d14f093f13dd9a858e88c2914bdb16edee992b5148efd8c23c10ca1b50d9", size = 7339, upload-time = "2024-09-06T01:21:48.468Z" }, +] + +[[package]] +name = "python-slugify" +version = "8.0.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "text-unidecode" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/87/c7/5e1547c44e31da50a460df93af11a535ace568ef89d7a811069ead340c4a/python-slugify-8.0.4.tar.gz", hash = "sha256:59202371d1d05b54a9e7720c5e038f928f45daaffe41dd10822f3907b937c856", size = 10921, upload-time = "2024-02-08T18:32:45.488Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/62/02da182e544a51a5c3ccf4b03ab79df279f9c60c5e82d5e8bec7ca26ac11/python_slugify-8.0.4-py2.py3-none-any.whl", hash = "sha256:276540b79961052b66b7d116620b36518847f52d5fd9e3a70164fc8c50faa6b8", size = 10051, upload-time = "2024-02-08T18:32:43.911Z" }, +] + +[[package]] +name = "pytz" +version = "2026.1.post1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/56/db/b8721d71d945e6a8ac63c0fc900b2067181dbb50805958d4d4661cf7d277/pytz-2026.1.post1.tar.gz", hash = "sha256:3378dde6a0c3d26719182142c56e60c7f9af7e968076f31aae569d72a0358ee1", size = 321088, upload-time = "2026-03-03T07:47:50.683Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/99/781fe0c827be2742bcc775efefccb3b048a3a9c6ce9aec0cbf4a101677e5/pytz-2026.1.post1-py2.py3-none-any.whl", hash = "sha256:f2fd16142fda348286a75e1a524be810bb05d444e5a081f37f7affc635035f7a", size = 510489, upload-time = "2026-03-03T07:47:49.167Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "radon" +version = "6.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama" }, + { name = "mando" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/6d/98e61600febf6bd929cf04154537c39dc577ce414bafbfc24a286c4fa76d/radon-6.0.1.tar.gz", hash = "sha256:d1ac0053943a893878940fedc8b19ace70386fc9c9bf0a09229a44125ebf45b5", size = 1874992, upload-time = "2023-03-26T06:24:38.868Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/f7/d00d9b4a0313a6be3a3e0818e6375e15da6d7076f4ae47d1324e7ca986a1/radon-6.0.1-py2.py3-none-any.whl", hash = "sha256:632cc032364a6f8bb1010a2f6a12d0f14bc7e5ede76585ef29dc0cecf4cd8859", size = 52784, upload-time = "2023-03-26T06:24:33.949Z" }, +] + +[[package]] +name = "regex" +version = "2026.2.28" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8b/71/41455aa99a5a5ac1eaf311f5d8efd9ce6433c03ac1e0962de163350d0d97/regex-2026.2.28.tar.gz", hash = "sha256:a729e47d418ea11d03469f321aaf67cdee8954cde3ff2cf8403ab87951ad10f2", size = 415184, upload-time = "2026-02-28T02:19:42.792Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cf/03/691015f7a7cb1ed6dacb2ea5de5682e4858e05a4c5506b2839cd533bbcd6/regex-2026.2.28-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:78454178c7df31372ea737996fb7f36b3c2c92cccc641d251e072478afb4babc", size = 489497, upload-time = "2026-02-28T02:18:30.889Z" }, + { url = "https://files.pythonhosted.org/packages/c6/ba/8db8fd19afcbfa0e1036eaa70c05f20ca8405817d4ad7a38a6b4c2f031ac/regex-2026.2.28-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:5d10303dd18cedfd4d095543998404df656088240bcfd3cd20a8f95b861f74bd", size = 291295, upload-time = "2026-02-28T02:18:33.426Z" }, + { url = "https://files.pythonhosted.org/packages/5a/79/9aa0caf089e8defef9b857b52fc53801f62ff868e19e5c83d4a96612eba1/regex-2026.2.28-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:19a9c9e0a8f24f39d575a6a854d516b48ffe4cbdcb9de55cb0570a032556ecff", size = 289275, upload-time = "2026-02-28T02:18:35.247Z" }, + { url = "https://files.pythonhosted.org/packages/eb/26/ee53117066a30ef9c883bf1127eece08308ccf8ccd45c45a966e7a665385/regex-2026.2.28-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09500be324f49b470d907b3ef8af9afe857f5cca486f853853f7945ddbf75911", size = 797176, upload-time = "2026-02-28T02:18:37.15Z" }, + { url = "https://files.pythonhosted.org/packages/05/1b/67fb0495a97259925f343ae78b5d24d4a6624356ae138b57f18bd43006e4/regex-2026.2.28-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fb1c4ff62277d87a7335f2c1ea4e0387b8f2b3ad88a64efd9943906aafad4f33", size = 863813, upload-time = "2026-02-28T02:18:39.478Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/93ac9bbafc53618091c685c7ed40239a90bf9f2a82c983f0baa97cb7ae07/regex-2026.2.28-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b8b3f1be1738feadc69f62daa250c933e85c6f34fa378f54a7ff43807c1b9117", size = 908678, upload-time = "2026-02-28T02:18:41.619Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7a/a8f5e0561702b25239846a16349feece59712ae20598ebb205580332a471/regex-2026.2.28-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc8ed8c3f41c27acb83f7b6a9eb727a73fc6663441890c5cb3426a5f6a91ce7d", size = 801528, upload-time = "2026-02-28T02:18:43.624Z" }, + { url = "https://files.pythonhosted.org/packages/96/5d/ed6d4cbde80309854b1b9f42d9062fee38ade15f7eb4909f6ef2440403b5/regex-2026.2.28-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa539be029844c0ce1114762d2952ab6cfdd7c7c9bd72e0db26b94c3c36dcc5a", size = 775373, upload-time = "2026-02-28T02:18:46.102Z" }, + { url = "https://files.pythonhosted.org/packages/6a/e9/6e53c34e8068b9deec3e87210086ecb5b9efebdefca6b0d3fa43d66dcecb/regex-2026.2.28-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7900157786428a79615a8264dac1f12c9b02957c473c8110c6b1f972dcecaddf", size = 784859, upload-time = "2026-02-28T02:18:48.269Z" }, + { url = "https://files.pythonhosted.org/packages/48/3c/736e1c7ca7f0dcd2ae33819888fdc69058a349b7e5e84bc3e2f296bbf794/regex-2026.2.28-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:0b1d2b07614d95fa2bf8a63fd1e98bd8fa2b4848dc91b1efbc8ba219fdd73952", size = 857813, upload-time = "2026-02-28T02:18:50.576Z" }, + { url = "https://files.pythonhosted.org/packages/6e/7c/48c4659ad9da61f58e79dbe8c05223e0006696b603c16eb6b5cbfbb52c27/regex-2026.2.28-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b389c61aa28a79c2e0527ac36da579869c2e235a5b208a12c5b5318cda2501d8", size = 763705, upload-time = "2026-02-28T02:18:52.59Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a1/bc1c261789283128165f71b71b4b221dd1b79c77023752a6074c102f18d8/regex-2026.2.28-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f467cb602f03fbd1ab1908f68b53c649ce393fde056628dc8c7e634dab6bfc07", size = 848734, upload-time = "2026-02-28T02:18:54.595Z" }, + { url = "https://files.pythonhosted.org/packages/10/d8/979407faf1397036e25a5ae778157366a911c0f382c62501009f4957cf86/regex-2026.2.28-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e8c8cb2deba42f5ec1ede46374e990f8adc5e6456a57ac1a261b19be6f28e4e6", size = 789871, upload-time = "2026-02-28T02:18:57.34Z" }, + { url = "https://files.pythonhosted.org/packages/03/23/da716821277115fcb1f4e3de1e5dc5023a1e6533598c486abf5448612579/regex-2026.2.28-cp314-cp314-win32.whl", hash = "sha256:9036b400b20e4858d56d117108d7813ed07bb7803e3eed766675862131135ca6", size = 271825, upload-time = "2026-02-28T02:18:59.202Z" }, + { url = "https://files.pythonhosted.org/packages/91/ff/90696f535d978d5f16a52a419be2770a8d8a0e7e0cfecdbfc31313df7fab/regex-2026.2.28-cp314-cp314-win_amd64.whl", hash = "sha256:1d367257cd86c1cbb97ea94e77b373a0bbc2224976e247f173d19e8f18b4afa7", size = 280548, upload-time = "2026-02-28T02:19:01.049Z" }, + { url = "https://files.pythonhosted.org/packages/69/f9/5e1b5652fc0af3fcdf7677e7df3ad2a0d47d669b34ac29a63bb177bb731b/regex-2026.2.28-cp314-cp314-win_arm64.whl", hash = "sha256:5e68192bb3a1d6fb2836da24aa494e413ea65853a21505e142e5b1064a595f3d", size = 273444, upload-time = "2026-02-28T02:19:03.255Z" }, + { url = "https://files.pythonhosted.org/packages/d3/eb/8389f9e940ac89bcf58d185e230a677b4fd07c5f9b917603ad5c0f8fa8fe/regex-2026.2.28-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:a5dac14d0872eeb35260a8e30bac07ddf22adc1e3a0635b52b02e180d17c9c7e", size = 492546, upload-time = "2026-02-28T02:19:05.378Z" }, + { url = "https://files.pythonhosted.org/packages/7b/c7/09441d27ce2a6fa6a61ea3150ea4639c1dcda9b31b2ea07b80d6937b24dd/regex-2026.2.28-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:ec0c608b7a7465ffadb344ed7c987ff2f11ee03f6a130b569aa74d8a70e8333c", size = 292986, upload-time = "2026-02-28T02:19:07.24Z" }, + { url = "https://files.pythonhosted.org/packages/fb/69/4144b60ed7760a6bd235e4087041f487aa4aa62b45618ce018b0c14833ea/regex-2026.2.28-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c7815afb0ca45456613fdaf60ea9c993715511c8d53a83bc468305cbc0ee23c7", size = 291518, upload-time = "2026-02-28T02:19:09.698Z" }, + { url = "https://files.pythonhosted.org/packages/2d/be/77e5426cf5948c82f98c53582009ca9e94938c71f73a8918474f2e2990bb/regex-2026.2.28-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b059e71ec363968671693a78c5053bd9cb2fe410f9b8e4657e88377ebd603a2e", size = 809464, upload-time = "2026-02-28T02:19:12.494Z" }, + { url = "https://files.pythonhosted.org/packages/45/99/2c8c5ac90dc7d05c6e7d8e72c6a3599dc08cd577ac476898e91ca787d7f1/regex-2026.2.28-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b8cf76f1a29f0e99dcfd7aef1551a9827588aae5a737fe31442021165f1920dc", size = 869553, upload-time = "2026-02-28T02:19:15.151Z" }, + { url = "https://files.pythonhosted.org/packages/53/34/daa66a342f0271e7737003abf6c3097aa0498d58c668dbd88362ef94eb5d/regex-2026.2.28-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:180e08a435a0319e6a4821c3468da18dc7001987e1c17ae1335488dfe7518dd8", size = 915289, upload-time = "2026-02-28T02:19:17.331Z" }, + { url = "https://files.pythonhosted.org/packages/c5/c7/e22c2aaf0a12e7e22ab19b004bb78d32ca1ecc7ef245949935463c5567de/regex-2026.2.28-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1e496956106fd59ba6322a8ea17141a27c5040e5ee8f9433ae92d4e5204462a0", size = 812156, upload-time = "2026-02-28T02:19:20.011Z" }, + { url = "https://files.pythonhosted.org/packages/7f/bb/2dc18c1efd9051cf389cd0d7a3a4d90f6804b9fff3a51b5dc3c85b935f71/regex-2026.2.28-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bba2b18d70eeb7b79950f12f633beeecd923f7c9ad6f6bae28e59b4cb3ab046b", size = 782215, upload-time = "2026-02-28T02:19:22.047Z" }, + { url = "https://files.pythonhosted.org/packages/17/1e/9e4ec9b9013931faa32226ec4aa3c71fe664a6d8a2b91ac56442128b332f/regex-2026.2.28-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6db7bfae0f8a2793ff1f7021468ea55e2699d0790eb58ee6ab36ae43aa00bc5b", size = 798925, upload-time = "2026-02-28T02:19:24.173Z" }, + { url = "https://files.pythonhosted.org/packages/71/57/a505927e449a9ccb41e2cc8d735e2abe3444b0213d1cf9cb364a8c1f2524/regex-2026.2.28-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:d0b02e8b7e5874b48ae0f077ecca61c1a6a9f9895e9c6dfb191b55b242862033", size = 864701, upload-time = "2026-02-28T02:19:26.376Z" }, + { url = "https://files.pythonhosted.org/packages/a6/ad/c62cb60cdd93e13eac5b3d9d6bd5d284225ed0e3329426f94d2552dd7cca/regex-2026.2.28-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:25b6eb660c5cf4b8c3407a1ed462abba26a926cc9965e164268a3267bcc06a43", size = 770899, upload-time = "2026-02-28T02:19:29.38Z" }, + { url = "https://files.pythonhosted.org/packages/3c/5a/874f861f5c3d5ab99633e8030dee1bc113db8e0be299d1f4b07f5b5ec349/regex-2026.2.28-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:5a932ea8ad5d0430351ff9c76c8db34db0d9f53c1d78f06022a21f4e290c5c18", size = 854727, upload-time = "2026-02-28T02:19:31.494Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ca/d2c03b0efde47e13db895b975b2be6a73ed90b8ba963677927283d43bf74/regex-2026.2.28-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1c2c95e1a2b0f89d01e821ff4de1be4b5d73d1f4b0bf679fa27c1ad8d2327f1a", size = 800366, upload-time = "2026-02-28T02:19:34.248Z" }, + { url = "https://files.pythonhosted.org/packages/14/bd/ee13b20b763b8989f7c75d592bfd5de37dc1181814a2a2747fedcf97e3ba/regex-2026.2.28-cp314-cp314t-win32.whl", hash = "sha256:bbb882061f742eb5d46f2f1bd5304055be0a66b783576de3d7eef1bed4778a6e", size = 274936, upload-time = "2026-02-28T02:19:36.313Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e7/d8020e39414c93af7f0d8688eabcecece44abfd5ce314b21dfda0eebd3d8/regex-2026.2.28-cp314-cp314t-win_amd64.whl", hash = "sha256:6591f281cb44dc13de9585b552cec6fc6cf47fb2fe7a48892295ee9bc4a612f9", size = 284779, upload-time = "2026-02-28T02:19:38.625Z" }, + { url = "https://files.pythonhosted.org/packages/13/c0/ad225f4a405827486f1955283407cf758b6d2fb966712644c5f5aef33d1b/regex-2026.2.28-cp314-cp314t-win_arm64.whl", hash = "sha256:dee50f1be42222f89767b64b283283ef963189da0dda4a515aa54a5563c62dec", size = 275010, upload-time = "2026-02-28T02:19:40.65Z" }, +] + +[[package]] +name = "requests" +version = "2.33.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" }, +] + +[[package]] +name = "requests-mock" +version = "1.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/92/32/587625f91f9a0a3d84688bf9cfc4b2480a7e8ec327cefd0ff2ac891fd2cf/requests-mock-1.12.1.tar.gz", hash = "sha256:e9e12e333b525156e82a3c852f22016b9158220d2f47454de9cae8a77d371401", size = 60901, upload-time = "2024-03-29T03:54:29.446Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/97/ec/889fbc557727da0c34a33850950310240f2040f3b1955175fdb2b36a8910/requests_mock-1.12.1-py2.py3-none-any.whl", hash = "sha256:b1e37054004cdd5e56c84454cc7df12b25f90f382159087f4b6915aaeef39563", size = 27695, upload-time = "2024-03-29T03:54:27.64Z" }, +] + +[[package]] +name = "respx" +version = "0.22.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f4/7c/96bd0bc759cf009675ad1ee1f96535edcb11e9666b985717eb8c87192a95/respx-0.22.0.tar.gz", hash = "sha256:3c8924caa2a50bd71aefc07aa812f2466ff489f1848c96e954a5362d17095d91", size = 28439, upload-time = "2024-12-19T22:33:59.374Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/67/afbb0978d5399bc9ea200f1d4489a23c9a1dad4eee6376242b8182389c79/respx-0.22.0-py2.py3-none-any.whl", hash = "sha256:631128d4c9aba15e56903fb5f66fb1eff412ce28dd387ca3a81339e52dbd3ad0", size = 25127, upload-time = "2024-12-19T22:33:57.837Z" }, +] + +[[package]] +name = "rich" +version = "14.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/99/43/3291f1cc9106f4c63bdce7a8d0df5047fe8422a75b091c16b5e9355e0b11/ruff-0.15.12.tar.gz", hash = "sha256:ecea26adb26b4232c0c2ca19ccbc0083a68344180bba2a600605538ce51a40a6", size = 4643852, upload-time = "2026-04-24T18:17:14.305Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/6e/e78ffb61d4686f3d96ba3df2c801161843746dcbcbb17a1e927d4829312b/ruff-0.15.12-py3-none-linux_armv6l.whl", hash = "sha256:f86f176e188e94d6bdbc09f09bfd9dc729059ad93d0e7390b5a73efe19f8861c", size = 10640713, upload-time = "2026-04-24T18:17:22.841Z" }, + { url = "https://files.pythonhosted.org/packages/ae/08/a317bc231fb9e7b93e4ef3089501e51922ff88d6936ce5cf870c4fe55419/ruff-0.15.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e3bcd123364c3770b8e1b7baaf343cc99a35f197c5c6e8af79015c666c423a6c", size = 11069267, upload-time = "2026-04-24T18:17:30.105Z" }, + { url = "https://files.pythonhosted.org/packages/aa/a4/f828e9718d3dce1f5f11c39c4f65afd32783c8b2aebb2e3d259e492c47bd/ruff-0.15.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fe87510d000220aa1ed530d4448a7c696a0cae1213e5ec30e5874287b66557b5", size = 10397182, upload-time = "2026-04-24T18:17:07.177Z" }, + { url = "https://files.pythonhosted.org/packages/71/e0/3310fc6d1b5e1fdea22bf3b1b807c7e187b581021b0d7d4514cccdb5fb71/ruff-0.15.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84a1630093121375a3e2a95b4a6dc7b59e2b4ee76216e32d81aae550a832d002", size = 10758012, upload-time = "2026-04-24T18:16:55.759Z" }, + { url = "https://files.pythonhosted.org/packages/11/c1/a606911aee04c324ddaa883ae418f3569792fd3c4a10c50e0dd0a2311e1e/ruff-0.15.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fb129f40f114f089ebe0ca56c0d251cf2061b17651d464bb6478dc01e69f11f5", size = 10447479, upload-time = "2026-04-24T18:16:51.677Z" }, + { url = "https://files.pythonhosted.org/packages/9d/68/4201e8444f0894f21ab4aeeaee68aa4f10b51613514a20d80bd628d57e88/ruff-0.15.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0c862b172d695db7598426b8af465e7e9ac00a3ea2a3630ee67eb82e366aaa6", size = 11234040, upload-time = "2026-04-24T18:17:16.529Z" }, + { url = "https://files.pythonhosted.org/packages/34/ff/8a6d6cf4ccc23fd67060874e832c18919d1557a0611ebef03fdb01fff11e/ruff-0.15.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2849ea9f3484c3aca43a82f484210370319e7170df4dfe4843395ddf6c57bc33", size = 12087377, upload-time = "2026-04-24T18:17:04.944Z" }, + { url = "https://files.pythonhosted.org/packages/85/f6/c669cf73f5152f623d34e69866a46d5e6185816b19fcd5b6dd8a2d299922/ruff-0.15.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e77c7e51c07fe396826d5969a5b846d9cd4c402535835fb6e21ce8b28fef847", size = 11367784, upload-time = "2026-04-24T18:17:25.409Z" }, + { url = "https://files.pythonhosted.org/packages/e8/39/c61d193b8a1daaa8977f7dea9e8d8ba866e02ea7b65d32f6861693aa4c12/ruff-0.15.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83b2f4f2f3b1026b5fb449b467d9264bf22067b600f7b6f41fc5958909f449d0", size = 11344088, upload-time = "2026-04-24T18:17:12.258Z" }, + { url = "https://files.pythonhosted.org/packages/c2/8d/49afab3645e31e12c590acb6d3b5b69d7aab5b81926dbaf7461f9441f37a/ruff-0.15.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9ba3b8f1afd7e2e43d8943e55f249e13f9682fde09711644a6e7290eb4f3e339", size = 11271770, upload-time = "2026-04-24T18:17:02.457Z" }, + { url = "https://files.pythonhosted.org/packages/46/06/33f41fe94403e2b755481cdfb9b7ef3e4e0ed031c4581124658d935d52b4/ruff-0.15.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e852ba9fdc890655e1d78f2df1499efbe0e54126bd405362154a75e2bde159c5", size = 10719355, upload-time = "2026-04-24T18:17:27.648Z" }, + { url = "https://files.pythonhosted.org/packages/0d/59/18aa4e014debbf559670e4048e39260a85c7fcee84acfd761ac01e7b8d35/ruff-0.15.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dd8aed930da53780d22fc70bdf84452c843cf64f8cb4eb38984319c24c5cd5fd", size = 10462758, upload-time = "2026-04-24T18:17:32.347Z" }, + { url = "https://files.pythonhosted.org/packages/25/e7/cc9f16fd0f3b5fddcbd7ec3d6ae30c8f3fde1047f32a4093a98d633c6570/ruff-0.15.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:01da3988d225628b709493d7dc67c3b9b12c0210016b08690ef9bd27970b262b", size = 10953498, upload-time = "2026-04-24T18:17:20.674Z" }, + { url = "https://files.pythonhosted.org/packages/72/7a/a9ba7f98c7a575978698f4230c5e8cc54bbc761af34f560818f933dafa0c/ruff-0.15.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9cae0f92bd5700d1213188b31cd3bdd2b315361296d10b96b8e2337d3d11f53e", size = 11447765, upload-time = "2026-04-24T18:17:09.755Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f9/0ae446942c846b8266059ad8a30702a35afae55f5cdc54c5adf8d7afdc27/ruff-0.15.12-py3-none-win32.whl", hash = "sha256:d0185894e038d7043ba8fd6aee7499ece6462dc0ea9f1e260c7451807c714c20", size = 10657277, upload-time = "2026-04-24T18:17:18.591Z" }, + { url = "https://files.pythonhosted.org/packages/33/f1/9614e03e1cdcbf9437570b5400ced8a720b5db22b28d8e0f1bda429f660d/ruff-0.15.12-py3-none-win_amd64.whl", hash = "sha256:c87a162d61ab3adca47c03f7f717c68672edec7d1b5499e652331780fe74950d", size = 11837758, upload-time = "2026-04-24T18:17:00.113Z" }, + { url = "https://files.pythonhosted.org/packages/c0/98/6beb4b351e472e5f4c4613f7c35a5290b8be2497e183825310c4c3a3984b/ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", size = 11120821, upload-time = "2026-04-24T18:16:57.979Z" }, +] + +[[package]] +name = "s3transfer" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/05/04/74127fc843314818edfa81b5540e26dd537353b123a4edc563109d8f17dd/s3transfer-0.16.0.tar.gz", hash = "sha256:8e990f13268025792229cd52fa10cb7163744bf56e719e0b9cb925ab79abf920", size = 153827, upload-time = "2025-12-01T02:30:59.114Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl", hash = "sha256:18e25d66fed509e3868dc1572b3f427ff947dd2c56f844a5bf09481ad3f3b2fe", size = 86830, upload-time = "2025-12-01T02:30:57.729Z" }, +] + +[[package]] +name = "securetar" +version = "2026.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "pynacl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cb/25/138e19209d8f04765e6e51f94997d7e1141448103bae8ba30af98a755595/securetar-2026.4.0.tar.gz", hash = "sha256:d6e8d8c747655ac4564543f35f7fc70a2ebc7426f23c77f16238e7cb763691e8", size = 28254, upload-time = "2026-04-07T13:25:20.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/16/47a417d1264ad238191185ddd1a1d6585c6f33462e60408be09aee74147b/securetar-2026.4.0-py3-none-any.whl", hash = "sha256:72e09a864282ec96b05b5a61cc4febca7a1a7978b4083687369b6eb4835abe27", size = 19176, upload-time = "2026-04-07T13:25:19.803Z" }, +] + +[[package]] +name = "sentence-stream" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "regex" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a8/69/f3d048692aac843f41102507f6257138392ec841c16718f0618d27051caf/sentence_stream-1.3.0.tar.gz", hash = "sha256:b06261d35729de97df9002a1cc708f9a888f662b80d5d6d008ee69c51f36041b", size = 10049, upload-time = "2026-01-08T16:25:06.873Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/b6/48339c109bab6f54ff608800773b9425464c6cbf7fd3f2ba01294d78be3d/sentence_stream-1.3.0-py3-none-any.whl", hash = "sha256:7448d131315b85eefdf238e5edd9caa62899acf609145d5e0e10c09812eb8a1d", size = 8707, upload-time = "2026-01-08T16:25:05.918Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "snitun" +version = "0.45.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/48/e2/b5bbf04971d1c3e07a3e16a706ea3c1a4b711c6d8c9566e8012772d3351a/snitun-0.45.1.tar.gz", hash = "sha256:d76d48cf4190ea59e8f63892da9c18499bfc6ca796220a463c6f3b32099d661c", size = 43335, upload-time = "2025-09-25T05:24:07.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/1b/83ff83003994bc8b56483c75a710de588896c167c7c42d66d059a2eb48dc/snitun-0.45.1-py3-none-any.whl", hash = "sha256:c1fa4536320ec3126926ade775c429e20664db1bc61d8fec0e181dc393d36ab4", size = 51236, upload-time = "2025-09-25T05:24:06.412Z" }, +] + +[[package]] +name = "span" +version = "2.0.7" +source = { virtual = "." } +dependencies = [ + { name = "homeassistant" }, + { name = "span-panel-api" }, +] + +[package.dev-dependencies] +dev = [ + { name = "bandit" }, + { name = "homeassistant-stubs" }, + { name = "isort" }, + { name = "mypy" }, + { name = "prek" }, + { name = "prettier" }, + { name = "pylint" }, + { name = "pyright" }, + { name = "pytest-homeassistant-custom-component" }, + { name = "python-direnv" }, + { name = "radon" }, + { name = "ruff" }, + { name = "types-pyyaml" }, + { name = "types-requests" }, + { name = "voluptuous-stubs" }, + { name = "vulture" }, +] + +[package.metadata] +requires-dist = [ + { name = "homeassistant", specifier = "==2026.4.4" }, + { name = "span-panel-api", editable = "../span-panel-api" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "bandit", extras = ["toml"], specifier = "==1.9.4" }, + { name = "homeassistant-stubs", specifier = "==2026.4.4" }, + { name = "isort" }, + { name = "mypy", specifier = "==1.20.2" }, + { name = "prek", specifier = ">=0.3.11" }, + { name = "prettier" }, + { name = "pylint", specifier = "==4.0.5" }, + { name = "pyright", specifier = "==1.1.409" }, + { name = "pytest-homeassistant-custom-component", specifier = ">=0.13.325" }, + { name = "python-direnv" }, + { name = "radon", specifier = "==6.0.1" }, + { name = "ruff", specifier = "==0.15.12" }, + { name = "types-pyyaml" }, + { name = "types-requests" }, + { name = "voluptuous-stubs" }, + { name = "vulture", specifier = ">=2.14" }, +] + +[[package]] +name = "span-panel-api" +version = "2.6.4" +source = { editable = "../span-panel-api" } +dependencies = [ + { name = "httpx" }, + { name = "paho-mqtt" }, + { name = "pyyaml" }, +] + +[package.metadata] +requires-dist = [ + { name = "httpx", specifier = ">=0.28.1" }, + { name = "paho-mqtt", specifier = ">=2.0.0,<3.0.0" }, + { name = "pyyaml", specifier = ">=6.0.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "bandit", specifier = ">=1.9.4" }, + { name = "black" }, + { name = "coverage" }, + { name = "mypy" }, + { name = "pre-commit" }, + { name = "pylint" }, + { name = "pytest", specifier = ">=9.0.2" }, + { name = "pytest-asyncio", specifier = ">=1.3.0" }, + { name = "pytest-cov" }, + { name = "radon" }, + { name = "ruff", specifier = ">=0.15.5" }, + { name = "twine" }, + { name = "types-pyyaml", specifier = ">=6.0.12.20250915" }, + { name = "vulture", specifier = ">=2.14" }, +] + +[[package]] +name = "sqlalchemy" +version = "2.0.41" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/66/45b165c595ec89aa7dcc2c1cd222ab269bc753f1fc7a1e68f8481bd957bf/sqlalchemy-2.0.41.tar.gz", hash = "sha256:edba70118c4be3c2b1f90754d308d0b79c6fe2c0fdc52d8ddf603916f83f4db9", size = 9689424, upload-time = "2025-05-14T17:10:32.339Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1c/fc/9ba22f01b5cdacc8f5ed0d22304718d2c758fce3fd49a5372b886a86f37c/sqlalchemy-2.0.41-py3-none-any.whl", hash = "sha256:57df5dc6fdb5ed1a88a1ed2195fd31927e705cad62dedd86b46972752a80f576", size = 1911224, upload-time = "2025-05-14T17:39:42.154Z" }, +] + +[[package]] +name = "standard-aifc" +version = "3.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "audioop-lts" }, + { name = "standard-chunk" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/53/6050dc3dde1671eb3db592c13b55a8005e5040131f7509cef0215212cb84/standard_aifc-3.13.0.tar.gz", hash = "sha256:64e249c7cb4b3daf2fdba4e95721f811bde8bdfc43ad9f936589b7bb2fae2e43", size = 15240, upload-time = "2024-10-30T16:01:31.772Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/52/5fbb203394cc852334d1575cc020f6bcec768d2265355984dfd361968f36/standard_aifc-3.13.0-py3-none-any.whl", hash = "sha256:f7ae09cc57de1224a0dd8e3eb8f73830be7c3d0bc485de4c1f82b4a7f645ac66", size = 10492, upload-time = "2024-10-30T16:01:07.071Z" }, +] + +[[package]] +name = "standard-chunk" +version = "3.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/06/ce1bb165c1f111c7d23a1ad17204d67224baa69725bb6857a264db61beaf/standard_chunk-3.13.0.tar.gz", hash = "sha256:4ac345d37d7e686d2755e01836b8d98eda0d1a3ee90375e597ae43aaf064d654", size = 4672, upload-time = "2024-10-30T16:18:28.326Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/90/a5c1084d87767d787a6caba615aa50dc587229646308d9420c960cb5e4c0/standard_chunk-3.13.0-py3-none-any.whl", hash = "sha256:17880a26c285189c644bd5bd8f8ed2bdb795d216e3293e6dbe55bbd848e2982c", size = 4944, upload-time = "2024-10-30T16:18:26.694Z" }, +] + +[[package]] +name = "standard-telnetlib" +version = "3.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8d/06/7bf7c0ec16574aeb1f6602d6a7bdb020084362fb4a9b177c5465b0aae0b6/standard_telnetlib-3.13.0.tar.gz", hash = "sha256:243333696bf1659a558eb999c23add82c41ffc2f2d04a56fae13b61b536fb173", size = 12636, upload-time = "2024-10-30T16:01:42.257Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/85/a1808451ac0b36c61dffe8aea21e45c64ba7da28f6cb0d269171298c6281/standard_telnetlib-3.13.0-py3-none-any.whl", hash = "sha256:b268060a3220c80c7887f2ad9df91cd81e865f0c5052332b81d80ffda8677691", size = 9995, upload-time = "2024-10-30T16:01:29.289Z" }, +] + +[[package]] +name = "stevedore" +version = "5.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6d/90764092216fa560f6587f83bb70113a8ba510ba436c6476a2b47359057c/stevedore-5.7.0.tar.gz", hash = "sha256:31dd6fe6b3cbe921e21dcefabc9a5f1cf848cf538a1f27543721b8ca09948aa3", size = 516200, upload-time = "2026-02-20T13:27:06.765Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/06/36d260a695f383345ab5bbc3fd447249594ae2fa8dfd19c533d5ae23f46b/stevedore-5.7.0-py3-none-any.whl", hash = "sha256:fd25efbb32f1abb4c9e502f385f0018632baac11f9ee5d1b70f88cc5e22ad4ed", size = 54483, upload-time = "2026-02-20T13:27:05.561Z" }, +] + +[[package]] +name = "syrupy" +version = "5.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c1/90/1a442d21527009d4b40f37fe50b606ebb68a6407142c2b5cc508c34b696b/syrupy-5.0.0.tar.gz", hash = "sha256:3282fe963fa5d4d3e47231b16d1d4d0f4523705e8199eeb99a22a1bc9f5942f2", size = 48881, upload-time = "2025-09-28T21:15:12.783Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/9a/6c68aad2ccfce6e2eeebbf5bb709d0240592eb51ff142ec4c8fbf3c2460a/syrupy-5.0.0-py3-none-any.whl", hash = "sha256:c848e1a980ca52a28715cd2d2b4d434db424699c05653bd1158fb31cf56e9546", size = 49087, upload-time = "2025-09-28T21:15:11.639Z" }, +] + +[[package]] +name = "text-unidecode" +version = "1.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ab/e2/e9a00f0ccb71718418230718b3d900e71a5d16e701a3dae079a21e9cd8f8/text-unidecode-1.3.tar.gz", hash = "sha256:bad6603bb14d279193107714b288be206cac565dfa49aa5b105294dd5c4aab93", size = 76885, upload-time = "2019-08-30T21:36:45.405Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/a5/c0b6468d3824fe3fde30dbb5e1f687b291608f9473681bbf7dabbf5a87d7/text_unidecode-1.3-py2.py3-none-any.whl", hash = "sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8", size = 78154, upload-time = "2019-08-30T21:37:03.543Z" }, +] + +[[package]] +name = "tomlkit" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/af/14b24e41977adb296d6bd1fb59402cf7d60ce364f90c890bd2ec65c43b5a/tomlkit-0.14.0.tar.gz", hash = "sha256:cf00efca415dbd57575befb1f6634c4f42d2d87dbba376128adb42c121b87064", size = 187167, upload-time = "2026-01-13T01:14:53.304Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/11/87d6d29fb5d237229d67973a6c9e06e048f01cf4994dee194ab0ea841814/tomlkit-0.14.0-py3-none-any.whl", hash = "sha256:592064ed85b40fa213469f81ac584f67a4f2992509a7c3ea2d632208623a3680", size = 39310, upload-time = "2026-01-13T01:14:51.965Z" }, +] + +[[package]] +name = "tqdm" +version = "4.67.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a8/4b/29b4ef32e036bb34e4ab51796dd745cdba7ed47ad142a9f4a1eb8e0c744d/tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2", size = 169737, upload-time = "2024-11-24T20:12:22.481Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540, upload-time = "2024-11-24T20:12:19.698Z" }, +] + +[[package]] +name = "types-pyyaml" +version = "6.0.12.20250915" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/69/3c51b36d04da19b92f9e815be12753125bd8bc247ba0470a982e6979e71c/types_pyyaml-6.0.12.20250915.tar.gz", hash = "sha256:0f8b54a528c303f0e6f7165687dd33fafa81c807fcac23f632b63aa624ced1d3", size = 17522, upload-time = "2025-09-15T03:01:00.728Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/e0/1eed384f02555dde685fff1a1ac805c1c7dcb6dd019c916fe659b1c1f9ec/types_pyyaml-6.0.12.20250915-py3-none-any.whl", hash = "sha256:e7d4d9e064e89a3b3cae120b4990cd370874d2bf12fa5f46c97018dd5d3c9ab6", size = 20338, upload-time = "2025-09-15T03:00:59.218Z" }, +] + +[[package]] +name = "types-requests" +version = "2.32.4.20260107" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0f/f3/a0663907082280664d745929205a89d41dffb29e89a50f753af7d57d0a96/types_requests-2.32.4.20260107.tar.gz", hash = "sha256:018a11ac158f801bfa84857ddec1650750e393df8a004a8a9ae2a9bec6fcb24f", size = 23165, upload-time = "2026-01-07T03:20:54.091Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1c/12/709ea261f2bf91ef0a26a9eed20f2623227a8ed85610c1e54c5805692ecb/types_requests-2.32.4.20260107-py3-none-any.whl", hash = "sha256:b703fe72f8ce5b31ef031264fe9395cac8f46a04661a79f7ed31a80fb308730d", size = 20676, upload-time = "2026-01-07T03:20:52.929Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "tzdata" +version = "2025.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" }, +] + +[[package]] +name = "uart-devices" +version = "0.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dd/08/a8fd6b3dd2cb92344fb4239d4e81ee121767430d7ce71f3f41282f7334e0/uart_devices-0.1.1.tar.gz", hash = "sha256:3a52c4ae0f5f7400ebe1ae5f6e2a2d40cc0b7f18a50e895236535c4e53c6ed34", size = 5167, upload-time = "2025-02-22T16:47:05.609Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/64/edf33c2d7fba7d6bf057c9dc4235bfc699517ea4c996240a1a9c2bf51c29/uart_devices-0.1.1-py3-none-any.whl", hash = "sha256:55bc8cce66465e90b298f0910e5c496bc7be021341c5455954cf61c6253dc123", size = 4827, upload-time = "2025-02-22T16:47:04.286Z" }, +] + +[[package]] +name = "ulid-transform" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9f/c2/8548ec850a03fdfbc84ba4728bed5d3b5570ec913502954b40830e84f5aa/ulid_transform-2.2.0.tar.gz", hash = "sha256:cef07d688483e7573b835423f242d0d6700d92429abc438c0941de61eacdf215", size = 15159, upload-time = "2026-03-18T18:18:14.101Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/47/f2d1b8c04a5b7276198ff4f0dadfea463fd6f73aa4e1ddc3589aff073ca3/ulid_transform-2.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ae98a8510ea6498c67d7810d9030c36dfd3e56d79232144fdd9e9430db8827a9", size = 23114, upload-time = "2026-03-18T18:25:15.783Z" }, + { url = "https://files.pythonhosted.org/packages/91/d3/479dddda0e7446a1f444c5df72941dd88611dcfc8fb3f8a1056d54be68fd/ulid_transform-2.2.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d44baef447136f0d3216dc63066f4999c4e81fe10d08620300685d59155c9a17", size = 25359, upload-time = "2026-03-18T18:25:17.719Z" }, + { url = "https://files.pythonhosted.org/packages/63/e0/3ffd17d17d78aceb903d3a11fcc494b97f0e313e3692b7bdac43fcb18972/ulid_transform-2.2.0-cp314-cp314-manylinux_2_24_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:dc6b2c01b6922f8c7c75d6504d428d66d0caabbcafbb4c5083d42aec23bf3a93", size = 25745, upload-time = "2026-03-18T18:25:19.179Z" }, + { url = "https://files.pythonhosted.org/packages/06/1c/60c4cb168710bc51f896d532a37e6d2c291d2eba7e5997ffaf532abfdf2a/ulid_transform-2.2.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4e96f23690491397218ecff013d44efa5b868ca393c497c83cd1d6844d29e14", size = 24344, upload-time = "2026-03-18T18:25:20.65Z" }, + { url = "https://files.pythonhosted.org/packages/2a/7a/8b6b7216aca6102fbede41249b6dbd203d17eccaef221e36440307948bb9/ulid_transform-2.2.0-cp314-cp314-manylinux_2_41_x86_64.whl", hash = "sha256:0faf6937d2a36392af323292819aeaca3ce12161ba414a265fd1f5ea13f5efef", size = 15101, upload-time = "2026-03-18T18:18:12.987Z" }, + { url = "https://files.pythonhosted.org/packages/f0/27/54063f2df35713bc0f5e70dad04ff609e788ef8109583a6666e08a48149d/ulid_transform-2.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:41d05fd5191c01a751d222586a88945806573b036a4a31b14952834c3e085654", size = 1005342, upload-time = "2026-03-18T18:25:22.02Z" }, + { url = "https://files.pythonhosted.org/packages/a8/2b/6909e1f875eb88612718eaad0259831d1d17908c38047e710d451b0a52b7/ulid_transform-2.2.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:e01a2168b702277c78b313bd126c34165ba8f6b4def3db389de3a125d1348521", size = 875203, upload-time = "2026-03-18T18:25:23.461Z" }, + { url = "https://files.pythonhosted.org/packages/9f/60/a1217d58e8591f6d76d6ef05d8296abc734acf61e1a8aef3ae09a2d1f933/ulid_transform-2.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8779b38f6a0c788087990c4c54ae9034beef4117936a5dd1a2d43b8cbb4f3203", size = 1056867, upload-time = "2026-03-18T18:25:24.955Z" }, + { url = "https://files.pythonhosted.org/packages/68/ab/cc26752afdf2e8cc643f1aa6b4514715d249d9ebf5c57185c6f68ba1dc51/ulid_transform-2.2.0-cp314-cp314-win32.whl", hash = "sha256:b0782a5a399b44d65ad2fc9c8f8b61f455af5d4cca335eafb2927e3dac6e92e6", size = 24255, upload-time = "2026-03-18T18:25:26.35Z" }, + { url = "https://files.pythonhosted.org/packages/a3/36/0cd5c74a77e72f80e2ffbf4d08fb89fe641b1f6e683421a335774731db6f/ulid_transform-2.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:b904d40eab4f3efced841cfb5b80777dd36c0db99a66261fc1b37e0b98d68167", size = 24280, upload-time = "2026-03-18T18:25:27.519Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ac/44abd959a306c84a8917a65e731e2b25d4aaeafa29a2b15deaa1a62041d2/ulid_transform-2.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e737a0b06be45585806bd71f91a63c846b0aa3bf90dd787544d5d8482f324c61", size = 30697, upload-time = "2026-03-18T18:25:28.632Z" }, + { url = "https://files.pythonhosted.org/packages/88/cd/f4625a6c4be6f6b217c9116ede638fd969a1a27fc11be76997c0381e3f20/ulid_transform-2.2.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f61b636694420afd6e5a2050e4fecca4f1ca17ef0673a9d4653522900b2d7132", size = 35171, upload-time = "2026-03-18T18:25:29.919Z" }, + { url = "https://files.pythonhosted.org/packages/e0/3e/cddcccf159527d028c49b2cd88abc9d323fd9fd6ef8fcc1960f9c57bb417/ulid_transform-2.2.0-cp314-cp314t-manylinux_2_24_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a815c51ef310c4594792864fbd8845cfe935005b631ae1c099fd3b2d1404062c", size = 35956, upload-time = "2026-03-18T18:25:31.03Z" }, + { url = "https://files.pythonhosted.org/packages/02/ef/f67e94817dcfea4699cf764db8ba427675c879bcc53610a490fcc5ab38b8/ulid_transform-2.2.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1628ca45e5ea9ec79bf90f9f7e3a2f036faca7bb081db143f7a36e54c1ef184", size = 33127, upload-time = "2026-03-18T18:25:32.162Z" }, + { url = "https://files.pythonhosted.org/packages/29/64/8c94866f6cf635437c6c106548f7b70023e0d4e82f0c95503551c3c3c27b/ulid_transform-2.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0c56296f82be1e272d58e53dfe231a789458758ce01addeca47a1671d73bfaa9", size = 1015637, upload-time = "2026-03-18T18:25:33.908Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3a/6beaccdf3acb27a2197cbb1fff44391ff92100ffe0ad0ae3273cb827cdf5/ulid_transform-2.2.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:9fd78e7002a10a47ceb2ede42f0aaca428d0ae7ed712fd6bacf46406f806c3f4", size = 885015, upload-time = "2026-03-18T18:25:36.043Z" }, + { url = "https://files.pythonhosted.org/packages/55/b1/258765b8d209187a19228c89733400b751db223b7b68c7b79e4c924962a9/ulid_transform-2.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3adf5a417a2250264c6b6b290f1393d41efff0ac692ea19d4fa677bf29a67137", size = 1066233, upload-time = "2026-03-18T18:25:38.194Z" }, + { url = "https://files.pythonhosted.org/packages/21/f5/c29d4e164f5a6302b3f2025d16dfde9a6a8704efaf01c7d3c70ee69a4a12/ulid_transform-2.2.0-cp314-cp314t-win32.whl", hash = "sha256:66b66189322bccb54491b91a9c0e25c9240156d5d5671ca59860d8db1ce4ff9f", size = 33249, upload-time = "2026-03-18T18:25:39.673Z" }, + { url = "https://files.pythonhosted.org/packages/c6/45/f9c7400646f89d71dd5c04693ed259feb459e13b036c98193ecc8467cb4d/ulid_transform-2.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:212ba7218ea61cafb06772cd3967f60f16d4b028869a470cddcffad3ca5fac1c", size = 33277, upload-time = "2026-03-18T18:25:41.235Z" }, +] + +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] + +[[package]] +name = "usb-devices" +version = "0.4.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/25/48/dbe6c4c559950ebebd413e8c40a8a60bfd47ddd79cb61b598a5987e03aad/usb_devices-0.4.5.tar.gz", hash = "sha256:9b5c7606df2bc791c6c45b7f76244a0cbed83cb6fa4c68791a143c03345e195d", size = 5421, upload-time = "2023-12-16T19:59:53.295Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/c9/26171ae5b78d72dd006bbc51ca9baa2cbb889ae8e91608910207482108fd/usb_devices-0.4.5-py3-none-any.whl", hash = "sha256:8a415219ef1395e25aa0bddcad484c88edf9673acdeae8a07223ca7222a01dcf", size = 5349, upload-time = "2023-12-16T19:59:51.604Z" }, +] + +[[package]] +name = "uv" +version = "0.11.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dd/f3/8aceeab67ea69805293ab290e7ca8cc1b61a064d28b8a35c76d8eba063dd/uv-0.11.6.tar.gz", hash = "sha256:e3b21b7e80024c95ff339fcd147ac6fc3dd98d3613c9d45d3a1f4fd1057f127b", size = 4073298, upload-time = "2026-04-09T12:09:01.738Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/fe/4b61a3d5ad9d02e8a4405026ccd43593d7044598e0fa47d892d4dafe44c9/uv-0.11.6-py3-none-linux_armv6l.whl", hash = "sha256:ada04dcf89ddea5b69d27ac9cdc5ef575a82f90a209a1392e930de504b2321d6", size = 23780079, upload-time = "2026-04-09T12:08:56.609Z" }, + { url = "https://files.pythonhosted.org/packages/52/db/d27519a9e1a5ffee9d71af1a811ad0e19ce7ab9ae815453bef39dd479389/uv-0.11.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5be013888420f96879c6e0d3081e7bcf51b539b034a01777041934457dfbedf3", size = 23214721, upload-time = "2026-04-09T12:09:32.228Z" }, + { url = "https://files.pythonhosted.org/packages/a6/8f/4399fa8b882bd7e0efffc829f73ab24d117d490a93e6bc7104a50282b854/uv-0.11.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:ffa5dc1cbb52bdce3b8447e83d1601a57ad4da6b523d77d4b47366db8b1ceb18", size = 21750109, upload-time = "2026-04-09T12:09:24.357Z" }, + { url = "https://files.pythonhosted.org/packages/32/07/5a12944c31c3dda253632da7a363edddb869ed47839d4d92a2dc5f546c93/uv-0.11.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:bfb107b4dade1d2c9e572992b06992d51dd5f2136eb8ceee9e62dd124289e825", size = 23551146, upload-time = "2026-04-09T12:09:10.439Z" }, + { url = "https://files.pythonhosted.org/packages/79/5b/2ec8b0af80acd1016ed596baf205ddc77b19ece288473b01926c4a9cf6db/uv-0.11.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:9e2fe7ce12161d8016b7deb1eaad7905a76ff7afec13383333ca75e0c4b5425d", size = 23331192, upload-time = "2026-04-09T12:09:34.792Z" }, + { url = "https://files.pythonhosted.org/packages/62/7d/eea35935f2112b21c296a3e42645f3e4b1aa8bcd34dcf13345fbd55134b7/uv-0.11.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7ed9c6f70c25e8dfeedddf4eddaf14d353f5e6b0eb43da9a14d3a1033d51d915", size = 23337686, upload-time = "2026-04-09T12:09:18.522Z" }, + { url = "https://files.pythonhosted.org/packages/21/47/2584f5ab618f6ebe9bdefb2f765f2ca8540e9d739667606a916b35449eec/uv-0.11.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d68a013e609cebf82077cbeeb0809ed5e205257814273bfd31e02fc0353bbfc2", size = 25008139, upload-time = "2026-04-09T12:09:03.983Z" }, + { url = "https://files.pythonhosted.org/packages/95/81/497ae5c1d36355b56b97dc59f550c7e89d0291c163a3f203c6f341dff195/uv-0.11.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:93f736dddca03dae732c6fdea177328d3bc4bf137c75248f3d433c57416a4311", size = 25712458, upload-time = "2026-04-09T12:09:07.598Z" }, + { url = "https://files.pythonhosted.org/packages/3c/1c/74083238e4fab2672b63575b9008f1ea418b02a714bcfcf017f4f6a309b6/uv-0.11.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e96a66abe53fced0e3389008b8d2eff8278cfa8bb545d75631ae8ceb9c929aba", size = 24915507, upload-time = "2026-04-09T12:08:50.892Z" }, + { url = "https://files.pythonhosted.org/packages/5a/ee/e14fe10ba455a823ed18233f12de6699a601890905420b5c504abf115116/uv-0.11.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b096311b2743b228df911a19532b3f18fa420bf9530547aecd6a8e04bbfaccd", size = 24971011, upload-time = "2026-04-09T12:08:54.016Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/7b9c83eaadf98e343317ff6384a7227a4855afd02cdaf9696bcc71ee6155/uv-0.11.6-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:904d537b4a6e798015b4a64ff5622023bd4601b43b6cd1e5f423d63471f5e948", size = 23640234, upload-time = "2026-04-09T12:09:15.735Z" }, + { url = "https://files.pythonhosted.org/packages/d6/51/75ccdd23e76ff1703b70eb82881cd5b4d2a954c9679f8ef7e0136ef2cfab/uv-0.11.6-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:4ed8150c26b5e319381d75ae2ce6aba1e9c65888f4850f4e3b3fa839953c90a5", size = 24452664, upload-time = "2026-04-09T12:09:26.875Z" }, + { url = "https://files.pythonhosted.org/packages/4d/86/ace80fe47d8d48b5e3b5aee0b6eb1a49deaacc2313782870250b3faa36f5/uv-0.11.6-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1c9218c8d4ac35ca6e617fb0951cc0ab2d907c91a6aea2617de0a5494cf162c0", size = 24494599, upload-time = "2026-04-09T12:09:37.368Z" }, + { url = "https://files.pythonhosted.org/packages/05/2d/4b642669b56648194f026de79bc992cbfc3ac2318b0a8d435f3c284934e8/uv-0.11.6-py3-none-musllinux_1_1_i686.whl", hash = "sha256:9e211c83cc890c569b86a4183fcf5f8b6f0c7adc33a839b699a98d30f1310d3a", size = 24159150, upload-time = "2026-04-09T12:09:13.17Z" }, + { url = "https://files.pythonhosted.org/packages/ae/24/7eecd76fe983a74fed1fc700a14882e70c4e857f1d562a9f2303d4286c12/uv-0.11.6-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:d2a1d2089afdf117ad19a4c1dd36b8189c00ae1ad4135d3bfbfced82342595cf", size = 25164324, upload-time = "2026-04-09T12:08:59.56Z" }, + { url = "https://files.pythonhosted.org/packages/27/e0/bbd4ba7c2e5067bbba617d87d306ec146889edaeeaa2081d3e122178ca08/uv-0.11.6-py3-none-win32.whl", hash = "sha256:6e8344f38fa29f85dcfd3e62dc35a700d2448f8e90381077ef393438dcd5012e", size = 22865693, upload-time = "2026-04-09T12:09:21.415Z" }, + { url = "https://files.pythonhosted.org/packages/a5/33/1983ce113c538a856f2d620d16e39691962ecceef091a84086c5785e32e5/uv-0.11.6-py3-none-win_amd64.whl", hash = "sha256:a28bea69c1186303d1200f155c7a28c449f8a4431e458fcf89360cc7ef546e40", size = 25371258, upload-time = "2026-04-09T12:09:40.52Z" }, + { url = "https://files.pythonhosted.org/packages/35/01/be0873f44b9c9bc250fcbf263367fcfc1f59feab996355bcb6b52fff080d/uv-0.11.6-py3-none-win_arm64.whl", hash = "sha256:a78f6d64b9950e24061bc7ec7f15ff8089ad7f5a976e7b65fcadce58fe02f613", size = 23869585, upload-time = "2026-04-09T12:09:29.425Z" }, +] + +[[package]] +name = "voluptuous" +version = "0.15.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/91/af/a54ce0fb6f1d867e0b9f0efe5f082a691f51ccf705188fca67a3ecefd7f4/voluptuous-0.15.2.tar.gz", hash = "sha256:6ffcab32c4d3230b4d2af3a577c87e1908a714a11f6f95570456b1849b0279aa", size = 51651, upload-time = "2024-07-02T19:10:00.528Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/a8/8f9cc6749331186e6a513bfe3745454f81d25f6e34c6024f88f80c71ed28/voluptuous-0.15.2-py3-none-any.whl", hash = "sha256:016348bc7788a9af9520b1764ebd4de0df41fe2138ebe9e06fa036bf86a65566", size = 31349, upload-time = "2024-07-02T19:09:58.125Z" }, +] + +[[package]] +name = "voluptuous-openapi" +version = "0.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "voluptuous" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/15/ac7a98afd478e9afc804354fe9d9715e0e560a590fdd425b22b65a152bb3/voluptuous_openapi-0.2.0.tar.gz", hash = "sha256:2366be934c37bb5fd8ed6bd5a2a46b1079b57dfbdf8c6c02e88f4ca13e975073", size = 15789, upload-time = "2025-08-21T04:49:16.755Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/72/eb/2ae58431a078318f03267f196137282ea6f01ea7f7e0fcba2b25a30b0bf2/voluptuous_openapi-0.2.0-py3-none-any.whl", hash = "sha256:d51f07be8af44b11570b7366785d90daa716b7fd11ea2845803763ae551f35cf", size = 10180, upload-time = "2025-08-21T04:49:15.885Z" }, +] + +[[package]] +name = "voluptuous-serialize" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "voluptuous" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/70/03a9b61324e1bb8b16682455b8b953bccd1001a28e43478c86f539e26285/voluptuous_serialize-2.7.0.tar.gz", hash = "sha256:d0da959f2fd93c8f1eb779c5d116231940493b51020c2c1026bab76eb56cd09e", size = 9202, upload-time = "2025-08-17T10:43:04.31Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/41/d536d9cf39821c35cc13aff403728e60e32b2fd711c240b6b9980af1c03f/voluptuous_serialize-2.7.0-py3-none-any.whl", hash = "sha256:ee3ebecace6136f38d0bf8c20ee97155db2486c6b2d0795563fafd04a519e76f", size = 7850, upload-time = "2025-08-17T10:43:03.498Z" }, +] + +[[package]] +name = "voluptuous-stubs" +version = "0.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mypy" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/95/18/9a4c1be6336a4f403ba3237c594fc2c68eb3cbf3774f0c79d8c84968ce9f/voluptuous-stubs-0.1.1.tar.gz", hash = "sha256:70fb1c088242f20e11023252b5648cd77f831f692cd910c8f9713cc135cf8cc8", size = 3654, upload-time = "2020-04-24T04:16:30.028Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/fc/aaf18e1cc066277df80aff1d988284216ca3881f1e6601f2362189b683c5/voluptuous_stubs-0.1.1-py3-none-any.whl", hash = "sha256:f216c427ed7e190b8413e26cf4f67e1bda692ea8225ed0d875f7724d10b7cb10", size = 4971, upload-time = "2020-04-24T04:16:28.667Z" }, +] + +[[package]] +name = "vulture" +version = "2.16" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/3e/4d08c5903b2c0c70cad583c170cc4a663fc6a61e2ad00b711fcda61358cd/vulture-2.16.tar.gz", hash = "sha256:f8d9f6e2af03011664a3c6c240c9765b3f392917d3135fddca6d6a68d359f717", size = 52680, upload-time = "2026-03-25T14:41:27.141Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/be/f935130312330614811dae2ea9df3f395f6d63889eb6c2e68c14507152ee/vulture-2.16-py3-none-any.whl", hash = "sha256:6e0f1c312cef1c87856957e5c2ca9608834a7c794c2180477f30bf0e4cc58eee", size = 26993, upload-time = "2026-03-25T14:41:26.21Z" }, +] + +[[package]] +name = "webrtc-models" +version = "0.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mashumaro" }, + { name = "orjson" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/80/e8/050ffe3b71ff44d3885eee2bed763ca937e2a30bc950d866f22ba657776b/webrtc_models-0.3.0.tar.gz", hash = "sha256:559c743e5cc3bcc8133be1b6fb5e8492a9ddb17151129c21cbb2e3f2a1166526", size = 9411, upload-time = "2024-11-18T17:43:45.682Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/e7/62f29980c9e8d75af93b642a0c37aa8e201fd5268ba3a7179c172549bac3/webrtc_models-0.3.0-py3-none-any.whl", hash = "sha256:8fddded3ffd7ca837de878033501927580799a2c1b7829f7ae8a0f43b49004ea", size = 7476, upload-time = "2024-11-18T17:43:44.165Z" }, +] + +[[package]] +name = "winrt-runtime" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/16/dd/acdd527c1d890c8f852cc2af644aa6c160974e66631289420aa871b05e65/winrt_runtime-3.2.1.tar.gz", hash = "sha256:c8dca19e12b234ae6c3dadf1a4d0761b51e708457492c13beb666556958801ea", size = 21721, upload-time = "2025-06-06T14:40:27.593Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/87/88bd98419a9da77a68e030593fee41702925a7ad8a8aec366945258cbb31/winrt_runtime-3.2.1-cp314-cp314-win32.whl", hash = "sha256:9b6298375468ac2f6815d0c008a059fc16508c8f587e824c7936ed9216480dad", size = 210257, upload-time = "2025-09-20T07:06:41.054Z" }, + { url = "https://files.pythonhosted.org/packages/87/85/e5c2a10d287edd9d3ee8dc24bf7d7f335636b92bf47119768b7dd2fd1669/winrt_runtime-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:e36e587ab5fd681ee472cd9a5995743f75107a1a84d749c64f7e490bc86bc814", size = 241873, upload-time = "2025-09-20T07:06:42.059Z" }, + { url = "https://files.pythonhosted.org/packages/52/2a/eb9e78397132175f70dd51dfa4f93e489c17d6b313ae9dce60369b8d84a7/winrt_runtime-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:35d6241a2ebd5598e4788e69768b8890ee1eee401a819865767a1fbdd3e9a650", size = 416222, upload-time = "2025-09-20T07:06:43.376Z" }, +] + +[[package]] +name = "winrt-windows-devices-bluetooth" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "winrt-runtime" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b2/a0/1c8a0c469abba7112265c6cb52f0090d08a67c103639aee71fc690e614b8/winrt_windows_devices_bluetooth-3.2.1.tar.gz", hash = "sha256:db496d2d92742006d5a052468fc355bf7bb49e795341d695c374746113d74505", size = 23732, upload-time = "2025-06-06T14:41:20.489Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/95/91cfdf941a1ba791708ab3477fc4e46793c8fe9117fc3e0a8c5ac5d7a09c/winrt_windows_devices_bluetooth-3.2.1-cp314-cp314-win32.whl", hash = "sha256:de36ded53ca3ba12fc6dd4deb14b779acc391447726543815df4800348aad63a", size = 109015, upload-time = "2025-09-20T07:09:51.067Z" }, + { url = "https://files.pythonhosted.org/packages/61/fa/7460655628d0f340a93524f5236bb9f8514eb0e1d334b38cba8a89f6c1a6/winrt_windows_devices_bluetooth-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:3295d932cc93259d5ccb23a41e3a3af4c78ce5d6a6223b2b7638985f604fa34c", size = 115931, upload-time = "2025-09-20T07:09:51.922Z" }, + { url = "https://files.pythonhosted.org/packages/de/70/e1248dea2ab881eb76b61ff1ad6cb9c07ac005faf99349e4af0b29bc3f1b/winrt_windows_devices_bluetooth-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:1f61c178766a1bbce0669f44790c6161ff4669404c477b4aedaa576348f9e102", size = 109561, upload-time = "2025-09-20T07:09:52.733Z" }, +] + +[[package]] +name = "winrt-windows-devices-bluetooth-advertisement" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "winrt-runtime" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/fc/7ffe66ca4109b9e994b27c00f3d2d506e6e549e268791f755287ad9106d8/winrt_windows_devices_bluetooth_advertisement-3.2.1.tar.gz", hash = "sha256:0223852a7b7fa5c8dea3c6a93473bd783df4439b1ed938d9871f947933e574cc", size = 16906, upload-time = "2025-06-06T14:41:21.448Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/3d/421d04a20037370baf13de929bc1dc5438b306a76fe17275ec5d893aae6c/winrt_windows_devices_bluetooth_advertisement-3.2.1-cp314-cp314-win32.whl", hash = "sha256:2985565c265b3f9eab625361b0e40e88c94b03d89f5171f36146f2e88b3ee214", size = 92264, upload-time = "2025-09-20T07:09:53.563Z" }, + { url = "https://files.pythonhosted.org/packages/07/c7/43601ab82fe42bcff430b8466d84d92b31be06cc45c7fd64e9aac40f7851/winrt_windows_devices_bluetooth_advertisement-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:d102f3fac64fde32332e370969dfbc6f37b405d8cc055d9da30d14d07449a3c2", size = 97517, upload-time = "2025-09-20T07:09:54.411Z" }, + { url = "https://files.pythonhosted.org/packages/91/17/e3303f6a25a2d98e424b06580fc85bbfd068f383424c67fa47cb1b357a46/winrt_windows_devices_bluetooth_advertisement-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:ffeb5e946cd42c32c6999a62e240d6730c653cdfb7b49c7839afba375e20a62a", size = 94122, upload-time = "2025-09-20T07:09:55.187Z" }, +] + +[[package]] +name = "winrt-windows-devices-bluetooth-genericattributeprofile" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "winrt-runtime" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/44/21/aeeddc0eccdfbd25e543360b5cc093233e2eab3cdfb53ad3cabae1b5d04d/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1.tar.gz", hash = "sha256:cdf6ddc375e9150d040aca67f5a17c41ceaf13a63f3668f96608bc1d045dde71", size = 38896, upload-time = "2025-06-06T14:41:22.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/32/cb447ca7730a1e05730272309b074da6a04af29a8c0f5121014db8a2fc02/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp314-cp314-win32.whl", hash = "sha256:d5f83739ca370f0baf52b0400aebd6240ab80150081fbfba60fd6e7b2e7b4c5f", size = 185249, upload-time = "2025-09-20T07:09:58.639Z" }, + { url = "https://files.pythonhosted.org/packages/bb/fa/f465d5d44dda166bf7ec64b7a950f57eca61f165bfe18345e9a5ea542def/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:13786a5853a933de140d456cd818696e1121c7c296ae7b7af262fc5d2cffb851", size = 193739, upload-time = "2025-09-20T07:09:59.893Z" }, + { url = "https://files.pythonhosted.org/packages/78/08/51c53ac3c704cd92da5ed7e7b9b57159052f6e46744e4f7e447ed708aa22/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:5140682da2860f6a55eb6faf9e980724dc457c2e4b4b35a10e1cebd8fc97d892", size = 194836, upload-time = "2025-09-20T07:10:00.87Z" }, +] + +[[package]] +name = "winrt-windows-devices-enumeration" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "winrt-runtime" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/dd/75835bfbd063dffa152109727dedbd80f6e92ea284855f7855d48cdf31c9/winrt_windows_devices_enumeration-3.2.1.tar.gz", hash = "sha256:df316899e39bfc0ffc1f3cb0f5ee54d04e1d167fbbcc1484d2d5121449a935cf", size = 23538, upload-time = "2025-06-06T14:41:26.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/31/5785cd1ec54dc0f0e6f3e6a466d07a62b8014a6e2b782e80444ef87e83ab/winrt_windows_devices_enumeration-3.2.1-cp314-cp314-win32.whl", hash = "sha256:e087364273ed7c717cd0191fed4be9def6fdf229fe9b536a4b8d0228f7814106", size = 134252, upload-time = "2025-09-20T07:10:12.935Z" }, + { url = "https://files.pythonhosted.org/packages/cb/f6/68d91068048410f49794c0b19c45759c63ca559607068cfe5affba2f211b/winrt_windows_devices_enumeration-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:0da1ddb8285d97a6775c36265d7157acf1bbcb88bcc9a7ce9a4549906c822472", size = 145509, upload-time = "2025-09-20T07:10:13.797Z" }, + { url = "https://files.pythonhosted.org/packages/5c/a4/898951d5bfc474aa9c7d133fe30870f0f2184f4ba3027eafb779d30eb7bc/winrt_windows_devices_enumeration-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:09bf07e74e897e97a49a9275d0a647819254ddb74142806bbbcf4777ed240a22", size = 141334, upload-time = "2025-09-20T07:10:14.637Z" }, +] + +[[package]] +name = "winrt-windows-devices-radios" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "winrt-runtime" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/02/9704ea359ad8b0d6faa1011f98fb477e8fb6eac5201f39d19e73c2407e7b/winrt_windows_devices_radios-3.2.1.tar.gz", hash = "sha256:4dc9b9d1501846049eb79428d64ec698d6476c27a357999b78a8331072e18a0b", size = 5908, upload-time = "2025-06-06T14:41:44.868Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/79/4627afae6b389ddd1e5f1d691663c6b14d6c8f98959082aed1217cc57ef9/winrt_windows_devices_radios-3.2.1-cp314-cp314-win32.whl", hash = "sha256:21452e1cae50e44cd1d5e78159e1b9986ac3389b66458ad89caa196ce5eca2d6", size = 39521, upload-time = "2025-09-20T07:11:17.992Z" }, + { url = "https://files.pythonhosted.org/packages/a7/7c/c6aea91908ee7279ed51d12157bc8aeecb8850af2441073c3c91b261ad31/winrt_windows_devices_radios-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:6a8413e586fe597c6849607885cca7e0549da33ae5699165d11f7911534c6eaf", size = 41121, upload-time = "2025-09-20T07:11:18.747Z" }, + { url = "https://files.pythonhosted.org/packages/86/c5/652f14e3c501452ad8e0723518d9bbd729219b47f4a4dbe2966c2f82dca8/winrt_windows_devices_radios-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:39129fd9d09103adb003575f59881c1a5a70a43310547850150b46c6f4020312", size = 38114, upload-time = "2025-09-20T07:11:19.599Z" }, +] + +[[package]] +name = "winrt-windows-foundation" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "winrt-runtime" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0c/55/098ce7ea0679efcc1298b269c48768f010b6c68f90c588f654ec874c8a74/winrt_windows_foundation-3.2.1.tar.gz", hash = "sha256:ad2f1fcaa6c34672df45527d7c533731fdf65b67c4638c2b4aca949f6eec0656", size = 30485, upload-time = "2025-06-06T14:41:53.344Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/0a/d77346e39fe0c81f718cde49f83fe77c368c0e14c6418f72dfa1e7ef22d0/winrt_windows_foundation-3.2.1-cp314-cp314-win32.whl", hash = "sha256:35e973ab3c77c2a943e139302256c040e017fd6ff1a75911c102964603bba1da", size = 114590, upload-time = "2025-09-20T07:11:49.97Z" }, + { url = "https://files.pythonhosted.org/packages/a1/56/4d2b545bea0f34f68df6d4d4ca22950ff8a935497811dccdc0ca58737a05/winrt_windows_foundation-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:a22a7ebcec0d262e60119cff728f32962a02df60471ded8b2735a655eccc0ef5", size = 122148, upload-time = "2025-09-20T07:11:50.826Z" }, + { url = "https://files.pythonhosted.org/packages/ed/ed/b9d3a11cac73444c0a3703200161cd7267dab5ab85fd00e1f965526e74a8/winrt_windows_foundation-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:3be7fbae829b98a6a946db4fbaf356b11db1fbcbb5d4f37e7a73ac6b25de8b87", size = 114360, upload-time = "2025-09-20T07:11:51.626Z" }, +] + +[[package]] +name = "winrt-windows-foundation-collections" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "winrt-runtime" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ef/62/d21e3f1eeb8d47077887bbf0c3882c49277a84d8f98f7c12bda64d498a07/winrt_windows_foundation_collections-3.2.1.tar.gz", hash = "sha256:0eff1ad0d8d763ad17e9e7bbd0c26a62b27215016393c05b09b046d6503ae6d5", size = 16043, upload-time = "2025-06-06T14:41:53.983Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/47/b3301d964422d4611c181348149a7c5956a2a76e6339de451a000d4ae8e7/winrt_windows_foundation_collections-3.2.1-cp314-cp314-win32.whl", hash = "sha256:33188ed2d63e844c8adfbb82d1d3d461d64aaf78d225ce9c5930421b413c45ab", size = 62211, upload-time = "2025-09-20T07:11:52.411Z" }, + { url = "https://files.pythonhosted.org/packages/20/59/5f2c940ff606297129e93ebd6030c813e6a43a786de7fc33ccb268e0b06b/winrt_windows_foundation_collections-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:d4cfece7e9c0ead2941e55a1da82f20d2b9c8003bb7a8853bb7f999b539f80a4", size = 70399, upload-time = "2025-09-20T07:11:53.254Z" }, + { url = "https://files.pythonhosted.org/packages/f8/2d/2c8eb89062c71d4be73d618457ed68e7e2ba29a660ac26349d44fc121cbf/winrt_windows_foundation_collections-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:3884146fea13727510458f6a14040b7632d5d90127028b9bfd503c6c655d0c01", size = 61392, upload-time = "2025-09-20T07:11:53.993Z" }, +] + +[[package]] +name = "winrt-windows-storage-streams" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "winrt-runtime" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/50/f4488b07281566e3850fcae1021f0285c9653992f60a915e15567047db63/winrt_windows_storage_streams-3.2.1.tar.gz", hash = "sha256:476f522722751eb0b571bc7802d85a82a3cae8b1cce66061e6e758f525e7b80f", size = 34335, upload-time = "2025-06-06T14:43:23.905Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/70/2869ea2112c565caace73c9301afd1d7afcc49bdd37fac058f0178ba95d4/winrt_windows_storage_streams-3.2.1-cp314-cp314-win32.whl", hash = "sha256:5cd0dbad86fcc860366f6515fce97177b7eaa7069da261057be4813819ba37ee", size = 131701, upload-time = "2025-09-20T07:17:16.849Z" }, + { url = "https://files.pythonhosted.org/packages/f4/3d/aae50b1d0e37b5a61055759aedd42c6c99d7c17ab8c3e568ab33c0288938/winrt_windows_storage_streams-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:3c5bf41d725369b9986e6d64bad7079372b95c329897d684f955d7028c7f27a0", size = 135566, upload-time = "2025-09-20T07:17:17.69Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c3/6d3ce7a58e6c828e0795c9db8790d0593dd7fdf296e513c999150deb98d4/winrt_windows_storage_streams-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:293e09825559d0929bbe5de01e1e115f7a6283d8996ab55652e5af365f032987", size = 134393, upload-time = "2025-09-20T07:17:18.802Z" }, +] + +[[package]] +name = "yarl" +version = "1.23.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/23/6e/beb1beec874a72f23815c1434518bfc4ed2175065173fb138c3705f658d4/yarl-1.23.0.tar.gz", hash = "sha256:53b1ea6ca88ebd4420379c330aea57e258408dd0df9af0992e5de2078dc9f5d5", size = 194676, upload-time = "2026-03-01T22:07:53.373Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/98/b85a038d65d1b92c3903ab89444f48d3cee490a883477b716d7a24b1a78c/yarl-1.23.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:21d1b7305a71a15b4794b5ff22e8eef96ff4a6d7f9657155e5aa419444b28912", size = 124455, upload-time = "2026-03-01T22:06:43.615Z" }, + { url = "https://files.pythonhosted.org/packages/39/54/bc2b45559f86543d163b6e294417a107bb87557609007c007ad889afec18/yarl-1.23.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:85610b4f27f69984932a7abbe52703688de3724d9f72bceb1cca667deff27474", size = 86752, upload-time = "2026-03-01T22:06:45.425Z" }, + { url = "https://files.pythonhosted.org/packages/24/f9/e8242b68362bffe6fb536c8db5076861466fc780f0f1b479fc4ffbebb128/yarl-1.23.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23f371bd662cf44a7630d4d113101eafc0cfa7518a2760d20760b26021454719", size = 86291, upload-time = "2026-03-01T22:06:46.974Z" }, + { url = "https://files.pythonhosted.org/packages/ea/d8/d1cb2378c81dd729e98c716582b1ccb08357e8488e4c24714658cc6630e8/yarl-1.23.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4a80f77dc1acaaa61f0934176fccca7096d9b1ff08c8ba9cddf5ae034a24319", size = 99026, upload-time = "2026-03-01T22:06:48.459Z" }, + { url = "https://files.pythonhosted.org/packages/0a/ff/7196790538f31debe3341283b5b0707e7feb947620fc5e8236ef28d44f72/yarl-1.23.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:bd654fad46d8d9e823afbb4f87c79160b5a374ed1ff5bde24e542e6ba8f41434", size = 92355, upload-time = "2026-03-01T22:06:50.306Z" }, + { url = "https://files.pythonhosted.org/packages/c1/56/25d58c3eddde825890a5fe6aa1866228377354a3c39262235234ab5f616b/yarl-1.23.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:682bae25f0a0dd23a056739f23a134db9f52a63e2afd6bfb37ddc76292bbd723", size = 106417, upload-time = "2026-03-01T22:06:52.1Z" }, + { url = "https://files.pythonhosted.org/packages/51/8a/882c0e7bc8277eb895b31bce0138f51a1ba551fc2e1ec6753ffc1e7c1377/yarl-1.23.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a82836cab5f197a0514235aaf7ffccdc886ccdaa2324bc0aafdd4ae898103039", size = 106422, upload-time = "2026-03-01T22:06:54.424Z" }, + { url = "https://files.pythonhosted.org/packages/42/2b/fef67d616931055bf3d6764885990a3ac647d68734a2d6a9e1d13de437a2/yarl-1.23.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c57676bdedc94cd3bc37724cf6f8cd2779f02f6aba48de45feca073e714fe52", size = 101915, upload-time = "2026-03-01T22:06:55.895Z" }, + { url = "https://files.pythonhosted.org/packages/18/6a/530e16aebce27c5937920f3431c628a29a4b6b430fab3fd1c117b26ff3f6/yarl-1.23.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c7f8dc16c498ff06497c015642333219871effba93e4a2e8604a06264aca5c5c", size = 100690, upload-time = "2026-03-01T22:06:58.21Z" }, + { url = "https://files.pythonhosted.org/packages/88/08/93749219179a45e27b036e03260fda05190b911de8e18225c294ac95bbc9/yarl-1.23.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5ee586fb17ff8f90c91cf73c6108a434b02d69925f44f5f8e0d7f2f260607eae", size = 98750, upload-time = "2026-03-01T22:06:59.794Z" }, + { url = "https://files.pythonhosted.org/packages/d9/cf/ea424a004969f5d81a362110a6ac1496d79efdc6d50c2c4b2e3ea0fc2519/yarl-1.23.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:17235362f580149742739cc3828b80e24029d08cbb9c4bda0242c7b5bc610a8e", size = 94685, upload-time = "2026-03-01T22:07:01.375Z" }, + { url = "https://files.pythonhosted.org/packages/e2/b7/14341481fe568e2b0408bcf1484c652accafe06a0ade9387b5d3fd9df446/yarl-1.23.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:0793e2bd0cf14234983bbb371591e6bea9e876ddf6896cdcc93450996b0b5c85", size = 106009, upload-time = "2026-03-01T22:07:03.151Z" }, + { url = "https://files.pythonhosted.org/packages/0a/e6/5c744a9b54f4e8007ad35bce96fbc9218338e84812d36f3390cea616881a/yarl-1.23.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3650dc2480f94f7116c364096bc84b1d602f44224ef7d5c7208425915c0475dd", size = 100033, upload-time = "2026-03-01T22:07:04.701Z" }, + { url = "https://files.pythonhosted.org/packages/0c/23/e3bfc188d0b400f025bc49d99793d02c9abe15752138dcc27e4eaf0c4a9e/yarl-1.23.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f40e782d49630ad384db66d4d8b73ff4f1b8955dc12e26b09a3e3af064b3b9d6", size = 106483, upload-time = "2026-03-01T22:07:06.231Z" }, + { url = "https://files.pythonhosted.org/packages/72/42/f0505f949a90b3f8b7a363d6cbdf398f6e6c58946d85c6d3a3bc70595b26/yarl-1.23.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94f8575fbdf81749008d980c17796097e645574a3b8c28ee313931068dad14fe", size = 102175, upload-time = "2026-03-01T22:07:08.4Z" }, + { url = "https://files.pythonhosted.org/packages/aa/65/b39290f1d892a9dd671d1c722014ca062a9c35d60885d57e5375db0404b5/yarl-1.23.0-cp314-cp314-win32.whl", hash = "sha256:c8aa34a5c864db1087d911a0b902d60d203ea3607d91f615acd3f3108ac32169", size = 83871, upload-time = "2026-03-01T22:07:09.968Z" }, + { url = "https://files.pythonhosted.org/packages/a9/5b/9b92f54c784c26e2a422e55a8d2607ab15b7ea3349e28359282f84f01d43/yarl-1.23.0-cp314-cp314-win_amd64.whl", hash = "sha256:63e92247f383c85ab00dd0091e8c3fa331a96e865459f5ee80353c70a4a42d70", size = 89093, upload-time = "2026-03-01T22:07:11.501Z" }, + { url = "https://files.pythonhosted.org/packages/e0/7d/8a84dc9381fd4412d5e7ff04926f9865f6372b4c2fd91e10092e65d29eb8/yarl-1.23.0-cp314-cp314-win_arm64.whl", hash = "sha256:70efd20be968c76ece7baa8dafe04c5be06abc57f754d6f36f3741f7aa7a208e", size = 83384, upload-time = "2026-03-01T22:07:13.069Z" }, + { url = "https://files.pythonhosted.org/packages/dd/8d/d2fad34b1c08aa161b74394183daa7d800141aaaee207317e82c790b418d/yarl-1.23.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:9a18d6f9359e45722c064c97464ec883eb0e0366d33eda61cb19a244bf222679", size = 131019, upload-time = "2026-03-01T22:07:14.903Z" }, + { url = "https://files.pythonhosted.org/packages/19/ff/33009a39d3ccf4b94d7d7880dfe17fb5816c5a4fe0096d9b56abceea9ac7/yarl-1.23.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2803ed8b21ca47a43da80a6fd1ed3019d30061f7061daa35ac54f63933409412", size = 89894, upload-time = "2026-03-01T22:07:17.372Z" }, + { url = "https://files.pythonhosted.org/packages/0c/f1/dab7ac5e7306fb79c0190766a3c00b4cb8d09a1f390ded68c85a5934faf5/yarl-1.23.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:394906945aa8b19fc14a61cf69743a868bb8c465efe85eee687109cc540b98f4", size = 89979, upload-time = "2026-03-01T22:07:19.361Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b1/08e95f3caee1fad6e65017b9f26c1d79877b502622d60e517de01e72f95d/yarl-1.23.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:71d006bee8397a4a89f469b8deb22469fe7508132d3c17fa6ed871e79832691c", size = 95943, upload-time = "2026-03-01T22:07:21.266Z" }, + { url = "https://files.pythonhosted.org/packages/c0/cc/6409f9018864a6aa186c61175b977131f373f1988e198e031236916e87e4/yarl-1.23.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:62694e275c93d54f7ccedcfef57d42761b2aad5234b6be1f3e3026cae4001cd4", size = 88786, upload-time = "2026-03-01T22:07:23.129Z" }, + { url = "https://files.pythonhosted.org/packages/76/40/cc22d1d7714b717fde2006fad2ced5efe5580606cb059ae42117542122f3/yarl-1.23.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a31de1613658308efdb21ada98cbc86a97c181aa050ba22a808120bb5be3ab94", size = 101307, upload-time = "2026-03-01T22:07:24.689Z" }, + { url = "https://files.pythonhosted.org/packages/8f/0d/476c38e85ddb4c6ec6b20b815bdd779aa386a013f3d8b85516feee55c8dc/yarl-1.23.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fb1e8b8d66c278b21d13b0a7ca22c41dd757a7c209c6b12c313e445c31dd3b28", size = 100904, upload-time = "2026-03-01T22:07:26.287Z" }, + { url = "https://files.pythonhosted.org/packages/72/32/0abe4a76d59adf2081dcb0397168553ece4616ada1c54d1c49d8936c74f8/yarl-1.23.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50f9d8d531dfb767c565f348f33dd5139a6c43f5cbdf3f67da40d54241df93f6", size = 97728, upload-time = "2026-03-01T22:07:27.906Z" }, + { url = "https://files.pythonhosted.org/packages/b7/35/7b30f4810fba112f60f5a43237545867504e15b1c7647a785fbaf588fac2/yarl-1.23.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:575aa4405a656e61a540f4a80eaa5260f2a38fff7bfdc4b5f611840d76e9e277", size = 95964, upload-time = "2026-03-01T22:07:30.198Z" }, + { url = "https://files.pythonhosted.org/packages/2d/86/ed7a73ab85ef00e8bb70b0cb5421d8a2a625b81a333941a469a6f4022828/yarl-1.23.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:041b1a4cefacf65840b4e295c6985f334ba83c30607441ae3cf206a0eed1a2e4", size = 95882, upload-time = "2026-03-01T22:07:32.132Z" }, + { url = "https://files.pythonhosted.org/packages/19/90/d56967f61a29d8498efb7afb651e0b2b422a1e9b47b0ab5f4e40a19b699b/yarl-1.23.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:d38c1e8231722c4ce40d7593f28d92b5fc72f3e9774fe73d7e800ec32299f63a", size = 90797, upload-time = "2026-03-01T22:07:34.404Z" }, + { url = "https://files.pythonhosted.org/packages/72/00/8b8f76909259f56647adb1011d7ed8b321bcf97e464515c65016a47ecdf0/yarl-1.23.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:d53834e23c015ee83a99377db6e5e37d8484f333edb03bd15b4bc312cc7254fb", size = 101023, upload-time = "2026-03-01T22:07:35.953Z" }, + { url = "https://files.pythonhosted.org/packages/ac/e2/cab11b126fb7d440281b7df8e9ddbe4851e70a4dde47a202b6642586b8d9/yarl-1.23.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2e27c8841126e017dd2a054a95771569e6070b9ee1b133366d8b31beb5018a41", size = 96227, upload-time = "2026-03-01T22:07:37.594Z" }, + { url = "https://files.pythonhosted.org/packages/c2/9b/2c893e16bfc50e6b2edf76c1a9eb6cb0c744346197e74c65e99ad8d634d0/yarl-1.23.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:76855800ac56f878847a09ce6dba727c93ca2d89c9e9d63002d26b916810b0a2", size = 100302, upload-time = "2026-03-01T22:07:39.334Z" }, + { url = "https://files.pythonhosted.org/packages/28/ec/5498c4e3a6d5f1003beb23405671c2eb9cdbf3067d1c80f15eeafe301010/yarl-1.23.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e09fd068c2e169a7070d83d3bde728a4d48de0549f975290be3c108c02e499b4", size = 98202, upload-time = "2026-03-01T22:07:41.717Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c3/cd737e2d45e70717907f83e146f6949f20cc23cd4bf7b2688727763aa458/yarl-1.23.0-cp314-cp314t-win32.whl", hash = "sha256:73309162a6a571d4cbd3b6a1dcc703c7311843ae0d1578df6f09be4e98df38d4", size = 90558, upload-time = "2026-03-01T22:07:43.433Z" }, + { url = "https://files.pythonhosted.org/packages/e1/19/3774d162f6732d1cfb0b47b4140a942a35ca82bb19b6db1f80e9e7bdc8f8/yarl-1.23.0-cp314-cp314t-win_amd64.whl", hash = "sha256:4503053d296bc6e4cbd1fad61cf3b6e33b939886c4f249ba7c78b602214fabe2", size = 97610, upload-time = "2026-03-01T22:07:45.773Z" }, + { url = "https://files.pythonhosted.org/packages/51/47/3fa2286c3cb162c71cdb34c4224d5745a1ceceb391b2bd9b19b668a8d724/yarl-1.23.0-cp314-cp314t-win_arm64.whl", hash = "sha256:44bb7bef4ea409384e3f8bc36c063d77ea1b8d4a5b2706956c0d6695f07dcc25", size = 86041, upload-time = "2026-03-01T22:07:49.026Z" }, + { url = "https://files.pythonhosted.org/packages/69/68/c8739671f5699c7dc470580a4f821ef37c32c4cb0b047ce223a7f115757f/yarl-1.23.0-py3-none-any.whl", hash = "sha256:a2df6afe50dea8ae15fa34c9f824a3ee958d785fd5d089063d960bae1daa0a3f", size = 48288, upload-time = "2026-03-01T22:07:51.388Z" }, +] + +[[package]] +name = "zeroconf" +version = "0.148.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ifaddr" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/67/46/10db987799629d01930176ae523f70879b63577060d63e05ebf9214aba4b/zeroconf-0.148.0.tar.gz", hash = "sha256:03fcca123df3652e23d945112d683d2f605f313637611b7d4adf31056f681702", size = 164447, upload-time = "2025-10-05T00:21:19.199Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/46/ac86e3a3ff355058cd0818b01a3a97ca3f2abc0a034f1edb8eea27cea65c/zeroconf-0.148.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:2158d8bfefcdb90237937df65b2235870ccef04644497e4e29d3ab5a4b3199b6", size = 1714870, upload-time = "2025-10-05T01:08:47.624Z" }, + { url = "https://files.pythonhosted.org/packages/de/02/c5e8cd8dfda0ca16c7309c8d12c09a3114e5b50054bce3c93da65db8b8e4/zeroconf-0.148.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:695f6663bf8df30fe1826a2c4d5acd8213d9cbd9111f59d375bf1ad635790e98", size = 1697756, upload-time = "2025-10-05T01:08:49.472Z" }, + { url = "https://files.pythonhosted.org/packages/63/04/a66c1011d05d7bb8ae6a847d41ac818271a942390f3d8c83c776389ca094/zeroconf-0.148.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa65a24ec055be0a1cba2b986ac3e1c5d97a40abe164991aabc6a6416cc9df02", size = 2146784, upload-time = "2025-10-05T01:08:51.766Z" }, + { url = "https://files.pythonhosted.org/packages/7c/d4/2239d87c3f60f886bd2dd299e9c63b811efd58b8b6fc659d8fd0900db3bc/zeroconf-0.148.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:79890df4ff696a5cdc4a59152957be568bea1423ed13632fc09e2a196c6721d5", size = 1899394, upload-time = "2025-10-05T01:08:53.457Z" }, + { url = "https://files.pythonhosted.org/packages/fb/60/534a4b576a8f9f5edff648ac9a5417323bef3086a77397f2f2058125a3c8/zeroconf-0.148.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c0ca6e8e063eb5a385469bb8d8dec12381368031cb3a82c446225511863ede3", size = 2221319, upload-time = "2025-10-05T01:08:55.271Z" }, + { url = "https://files.pythonhosted.org/packages/b5/8c/1c8e9b7d604910830243ceb533d796dae98ed0c72902624a642487edfd61/zeroconf-0.148.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ece6f030cc7a771199760963c11ce4e77ed95011eedffb1ca5186247abfec24a", size = 2178586, upload-time = "2025-10-05T01:08:56.966Z" }, + { url = "https://files.pythonhosted.org/packages/16/55/178c4b95840dc687d45e413a74d2236a25395ab036f4813628271306ab9d/zeroconf-0.148.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:c3f860ad0003a8999736fa2ae4c2051dd3c2e5df1bc1eaea2f872f5fcbd1f1c1", size = 1972371, upload-time = "2025-10-05T01:08:59.103Z" }, + { url = "https://files.pythonhosted.org/packages/fb/86/b599421fe634d9f3a2799f69e6e7db9f13f77d326331fa2bb5982e936665/zeroconf-0.148.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ab8e687255cf54ebeae7ede6a8be0566aec752c570e16dbea84b3f9b149ba829", size = 2244286, upload-time = "2025-10-05T01:09:01.029Z" }, + { url = "https://files.pythonhosted.org/packages/3e/cb/a30c42057be5da6bb4cbe1ab53bc3a7d9a29cd59caae097d3072a9375c14/zeroconf-0.148.0-cp314-cp314-win32.whl", hash = "sha256:6b1a6ddba3328d741798c895cecff21481863eb945c3e5d30a679461f4435684", size = 1321693, upload-time = "2025-10-05T01:09:02.715Z" }, + { url = "https://files.pythonhosted.org/packages/2c/38/06873cdf769130af463ef5acadbaf4a50826a7274374bc3b9a4ec5d32678/zeroconf-0.148.0-cp314-cp314-win_amd64.whl", hash = "sha256:2588f1ca889f57cdc09b3da0e51175f1b6153ce0f060bf5eb2a8804c5953b135", size = 1563980, upload-time = "2025-10-05T01:09:04.857Z" }, + { url = "https://files.pythonhosted.org/packages/36/fb/53d749793689279bc9657d818615176577233ad556d62f76f719e86ead1d/zeroconf-0.148.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:40fe100381365c983a89e4b219a7ececcc2a789ac179cd26d4a6bbe00ae3e8fe", size = 3418152, upload-time = "2025-10-05T01:09:06.71Z" }, + { url = "https://files.pythonhosted.org/packages/b9/19/5eb647f7277378cbfdb6943dc8e60c3b17cdd1556f5082ccfdd6813e1ce8/zeroconf-0.148.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0b9c7bcae8af8e27593bad76ee0f0c21d43c6a2324cd1e34d06e6e08cb3fd922", size = 3389671, upload-time = "2025-10-05T01:09:08.903Z" }, + { url = "https://files.pythonhosted.org/packages/86/12/3134aa54d30a9ae2e2473212eab586fe1779f845bf241e68729eca63d2ab/zeroconf-0.148.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cf8ba75dacd58558769afb5da24d83da4fdc2a5c43a52f619aaa107fa55d3fdc", size = 4123125, upload-time = "2025-10-05T01:09:11.064Z" }, + { url = "https://files.pythonhosted.org/packages/12/23/4a0284254ebce373ff1aee7240932a0599ecf47e3c711f93242a861aa382/zeroconf-0.148.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:75f9a8212c541a4447c064433862fd4b23d75d47413912a28204d2f9c4929a59", size = 3651426, upload-time = "2025-10-05T01:09:13.725Z" }, + { url = "https://files.pythonhosted.org/packages/76/9a/7b79ef986b5467bb8f17b9a9e6eea887b0b56ecafc00515c81d118e681b4/zeroconf-0.148.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:be64c0eb48efa1972c13f7f17a7ac0ed7932ebb9672e57f55b17536412146206", size = 4263151, upload-time = "2025-10-05T01:09:15.732Z" }, + { url = "https://files.pythonhosted.org/packages/dd/0a/caa6d05548ca7cf28a0b8aa20a9dbb0f8176172f28799e53ea11f78692a3/zeroconf-0.148.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ac1d4ee1d5bac71c27aea6d1dc1e1485423a1631a81be1ea65fb45ac280ade96", size = 4191717, upload-time = "2025-10-05T01:09:18.071Z" }, + { url = "https://files.pythonhosted.org/packages/46/f6/dbafa3b0f2d7a09315ed3ad588d36de79776ce49e00ec945c6195cad3f18/zeroconf-0.148.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:8da9bdb39ead9d5971136046146cd5e11413cb979c011e19f717b098788b5c37", size = 3793490, upload-time = "2025-10-05T01:09:20.045Z" }, + { url = "https://files.pythonhosted.org/packages/c4/05/f8b88937659075116c122355bdd9ce52376cc46e2269d91d7d4f10c9a658/zeroconf-0.148.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f6e3dd22732df47a126aefb5ca4b267e828b47098a945d4468d38c72843dd6df", size = 4311455, upload-time = "2025-10-05T01:09:22.042Z" }, + { url = "https://files.pythonhosted.org/packages/58/c0/359bdb3b435d9c573aec1f877f8a63d5e81145deb6c160de89647b237363/zeroconf-0.148.0-cp314-cp314t-win32.whl", hash = "sha256:cdc8083f0b5efa908ab6c8e41687bcb75fd3d23f49ee0f34cbc58422437a456f", size = 2755961, upload-time = "2025-10-05T01:09:24.041Z" }, + { url = "https://files.pythonhosted.org/packages/d8/ab/7b487afd5d1fd053c5a018565be734ac6d5e554bce938c7cc126154adcfc/zeroconf-0.148.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f72c1f77a89638e87f243a63979f0fd921ce391f83e18e17ec88f9f453717701", size = 3309977, upload-time = "2025-10-05T01:09:26.039Z" }, +] diff --git a/v1-legacy.md b/v1-legacy.md new file mode 100644 index 00000000..d8485eb6 --- /dev/null +++ b/v1-legacy.md @@ -0,0 +1,144 @@ +# SPAN Panel Integration — v1 Legacy Documentation + +> **Deprecation Notice:** v1 REST API support is deprecated. SPAN is rolling out v2 firmware with eBus MQTT support across all panels. Users should upgrade to +> v2 firmware when available. The main [README](../README.md) documents the current v2 integration. + +## Overview + +The v1 integration communicates with the SPAN Panel via its OpenAPI REST interface, polling at a configurable interval. This document preserves the v1-specific +setup instructions for users still running pre-v2 firmware. + +## Authorization Methods + +### Method 1: Door Proximity Authentication + +1. Open your SPAN Panel door +2. Press the door sensor button at the top 3 times in succession +3. Wait for the frame lights to blink, indicating the panel is "unlocked" for 15 minutes +4. Complete the integration setup in Home Assistant + +### Method 2: Authentication Token (Optional) + +To acquire an authorization token, proceed as follows while the panel is in its unlocked period: + +1. Use a tool like the VS Code extension "Rest Client" or curl to make a POST to `{Span_Panel_IP}/api/v1/auth/register` with a JSON body of + `{"name": "home-assistant-UNIQUEID", "description": "Home Assistant Local SPAN Integration"}`. + - Replace UNIQUEID with your own random unique value. If the name conflicts with one that's already been created, the request will fail. + - Example via CLI: + + ```bash + curl -X POST https://192.168.1.2/api/v1/auth/register \ + -H 'Content-Type: application/json' \ + -d '{"name": "home-assistant-123456", "description": "Home Assistant Local SPAN Integration"}' + ``` + +2. If the panel is already "unlocked", you will get a 2xx response containing the `"accessToken"`. If not, you will be prompted to open and close the door of + the panel 3 times, once every two seconds, and then retry the query. +3. Store the value from the `"accessToken"` property of the response (a long random string). The token can be used with future SPAN integration configurations + of the same panel. +4. For direct API testing, load the HTTP header `"Authorization: Bearer "` + +### Multiple SPAN Panels + +If you have multiple SPAN Panels, repeat the authorization process for each panel — tokens are only accepted by the panel that generated them. + +If you have an auth token, enter it in the "Existing Auth Token" flow in the configuration menu. + +## Configuration Options (v1-specific) + +### Scan Frequency + +The v1 integration polls the SPAN REST API at a configurable interval (default: 15 seconds). This setting is not applicable to v2, which uses real-time MQTT +push. + +### Solar Inverter Leg Configuration + +The solar configuration is for solar that is directly connected to the panel tabs. If the inverter sensors are enabled, four sensors are created (power, +produced energy, consumed energy, and net energy). The entity naming pattern depends on your configured naming pattern: + +**Circuit Numbers Pattern:** + +```yaml +sensor.span_panel_circuit_30_32_instant_power # (watts) - dual circuit +sensor.span_panel_circuit_30_32_energy_produced # (Wh) - dual circuit +sensor.span_panel_circuit_30_32_energy_consumed # (Wh) - dual circuit +sensor.span_panel_circuit_30_32_energy_net # (Wh) - dual circuit +``` + +**Friendly Names Pattern:** + +```yaml +sensor.span_panel_solar_inverter_instant_power # (watts) +sensor.span_panel_solar_inverter_energy_produced # (Wh) +sensor.span_panel_solar_inverter_energy_consumed # (Wh) +sensor.span_panel_solar_inverter_energy_net # (Wh) +``` + +**Note:** For circuit numbers pattern, the numbers in the entity IDs (e.g., `30_32`) correspond to your configured inverter leg circuits. For single-circuit +configurations, only one number appears (e.g., `circuit_30_instant_power`). + +Disabling the inverter in the configuration removes these specific sensors. No reboot is required to add/remove these inverter sensors. + +Although the solar inverter configuration is primarily aimed at installations that don't have a way to monitor their solar directly from their inverter, one +could use this configuration to monitor any circuit(s) not provided directly by the underlying SPAN API. The two circuits are always added together to indicate +their combined power if both circuits are enabled. + +### Custom kWh Sensor + +Adding your own platform integration sensor converts Wh to kWh: + +```yaml +sensor: + - platform: integration + source: sensor.span_panel_solar_inverter_instant_power # Use appropriate entity ID + name: Solar Inverter Produced kWh + unique_id: sensor.solar_inverter_produced_kwh + unit_prefix: k + round: 2 +``` + +## OpenAPI Reference + +The v1 integration relies on the OpenAPI interface contract sourced from the SPAN Panel. The integration may break if SPAN changes the API in an incompatible +way. This contract is specific to the SPAN Panel MAIN 32 hardware. + +## Troubleshooting (v1) + +### Circuit Priority + +The v1 REST API does not support setting circuit priority. The dropdown remains visible but changes are not persisted by the panel. + +### Energy Dashboard Spikes After Firmware Updates + +When the SPAN panel undergoes a firmware update or reset, it may report decreased energy values in otherwise `TOTAL_INCREASING` sensors, losing some range of +data. This errant drop causes a massive spike in the Home Assistant Energy Dashboard. This issue is on the SPAN firmware/cloud side and the only remedy we have +is to adjust Home Assistant statistics for SPAN sensors upward to their previous value. That adjustment is carried forward for all future stat-reporting times. + +**Symptoms:** + +- Huge energy consumption spikes appearing after panel firmware updates +- Charts showing unrealistic untracked values unrelated to a single sensor that dwarf normal usage +- Negative energy values in statistics + +**Solution:** + +Use the Developer Tools to adjust individual statistics. This method allows you greater control. + +OR + +Use the cleanup service to adjust negative statistics in `TOTAL_INCREASING` entries (Beta): + +1. **Backup the system** - Create a backup of your Home Assistant instance before making any changes. +2. **Verify the spike** - Go to **Developer Tools > Statistics** and search for the main meter consumed energy sensor + (`sensor.span_panel_main_meter_consumed_energy`) to locate the errant spike caused by negative value and note the timestamp +3. Go to **Developer Tools > Actions** +4. Search for `span_panel.cleanup_energy_spikes` +5. Set `start_time` and `end_time` to cover the time range where the spike occurred (use the timestamp from step 2) +6. First run with `dry_run: true` to preview what will be adjusted (important, save the JSON in case you need to undo with undo_stats_adjustment) +7. Review the persistent notification showing affected timestamps in the JSON +8. Run again with `dry_run: false` to adjust the problematic entries (uses the negative delta in each sensor's sum to adjust stats upward) +9. Fine tune using the statistics adjustments if necessary + +The spike cleanup service looks at the energy usage just after the spike to extrapolate energy usage over any previous down time prior to the spike. + +**Note:** The integration monitors for decreases in the main meter consumed (TOTAL_INCREASING) sensor and will display a notification when one is detected. diff --git a/websocket-api.md b/websocket-api.md new file mode 100644 index 00000000..79e95fc7 --- /dev/null +++ b/websocket-api.md @@ -0,0 +1,191 @@ +# WebSocket API + +The integration exposes WebSocket commands for programmatic access to panel topology and entity mappings. These commands are available to custom cards, +AppDaemon scripts, or any WebSocket client connected to Home Assistant. + +## `span_panel/panel_topology` + +Returns the full physical layout of a SPAN panel in a single call — circuits with their breaker slot positions, entity IDs grouped by role (power, energy, +switch, select), and sub-devices (BESS, EVSE) with their entities. + +A custom card rendering the physical panel needs to know which breaker slot each circuit occupies, which entity provides its power reading, which switch +controls its relay, and so on. Without this command, the card would need to query the device registry, entity registry, and individual entity states in separate +calls, then infer which entities belong to the same circuit by parsing naming conventions. That correlation is fragile — entity naming patterns can differ +between installs, and EVSE feed circuit sensors live on the EVSE sub-device rather than the panel device. The topology command provides all of these +relationships explicitly, keyed by circuit UUID, so the card reads a single structured response instead of guessing. + +### Request + +```json +{ + "type": "span_panel/panel_topology", + "device_id": "" +} +``` + +| Field | Type | Description | +| ----------- | ------ | ------------------------------------------------------------------------------------------------------- | +| `device_id` | string | The Home Assistant device registry ID for the SPAN panel. Found in the URL when viewing the device page | + +### Response + +```json +{ + "serial": "nj-2316-005k6", + "firmware": "spanos2/r202603/05", + "panel_size": 32, + "device_id": "abc123def456", + "device_name": "SPAN Panel", + "circuits": { + "a1b2c3d4e5f6": { + "tabs": [5, 6], + "name": "Kitchen", + "voltage": 240, + "device_type": "circuit", + "relay_state": "CLOSED", + "is_user_controllable": true, + "breaker_rating_a": 30, + "entities": { + "power": "sensor.span_panel_kitchen_power", + "produced_energy": "sensor.span_panel_kitchen_produced_energy", + "consumed_energy": "sensor.span_panel_kitchen_consumed_energy", + "net_energy": "sensor.span_panel_kitchen_net_energy", + "current": "sensor.span_panel_kitchen_current", + "breaker_rating": "sensor.span_panel_kitchen_breaker_rating", + "switch": "switch.span_panel_kitchen_breaker", + "select": "select.span_panel_kitchen_circuit_priority" + } + }, + "f6e5d4c3b2a1": { + "tabs": [15], + "name": "Master Bedroom", + "voltage": 120, + "device_type": "circuit", + "relay_state": "CLOSED", + "is_user_controllable": true, + "breaker_rating_a": 15, + "entities": { + "power": "sensor.span_panel_master_bedroom_power", + "switch": "switch.span_panel_master_bedroom_breaker" + } + } + }, + "sub_devices": { + "device_id_bess": { + "name": "SPAN Panel Battery", + "type": "bess", + "manufacturer": "Enphase", + "model": "IQ Battery 10T", + "serial_number": "SN-BESS-001", + "sw_version": "1.2.3", + "entities": { + "sensor.span_panel_battery_level": { + "domain": "sensor", + "original_name": "Battery Level", + "unique_id": "..." + } + } + }, + "device_id_evse": { + "name": "SPAN Panel SPAN Drive (Garage)", + "type": "evse", + "manufacturer": "SPAN", + "model": "SPAN Drive", + "serial_number": "SN-EVSE-001", + "sw_version": "2.0.1", + "entities": { + "sensor.span_panel_span_drive_garage_charger_status": { + "domain": "sensor", + "original_name": "Charger Status", + "unique_id": "..." + } + } + } + } +} +``` + +### Response Fields + +#### Top Level + +| Field | Type | Description | +| ------------- | ----------- | ----------------------------------------------- | +| `serial` | string | Panel serial number | +| `firmware` | string | Panel firmware version | +| `panel_size` | int or null | Total breaker spaces (e.g., 32, 40) | +| `device_id` | string | HA device registry ID (echoed from request) | +| `device_name` | string | HA device display name | +| `circuits` | object | Circuit UUID keyed map (see below) | +| `sub_devices` | object | HA device ID keyed map of BESS/EVSE (see below) | + +#### Circuit Object + +| Field | Type | Description | +| ---------------------- | ----------- | ---------------------------------------------- | +| `tabs` | int[] | Sorted breaker slot positions (1-indexed) | +| `name` | string/null | Circuit name from the panel (null if unnamed) | +| `voltage` | int | 120 (single tab) or 240 (double tab) | +| `device_type` | string | `circuit`, `pv`, or `evse` | +| `relay_state` | string | `CLOSED`, `OPEN`, or `UNKNOWN` | +| `is_user_controllable` | bool | Whether the circuit relay can be toggled | +| `breaker_rating_a` | float/null | Breaker amperage rating (null if not reported) | +| `entities` | object | Role-keyed map of entity IDs (see below) | + +#### Circuit Entity Roles + +| Role | Domain | Description | +| ----------------- | ------ | ---------------------- | +| `power` | sensor | Instantaneous power | +| `produced_energy` | sensor | Cumulative produced Wh | +| `consumed_energy` | sensor | Cumulative consumed Wh | +| `net_energy` | sensor | Net energy Wh | +| `current` | sensor | Measured current | +| `breaker_rating` | sensor | Breaker amperage | +| `switch` | switch | Relay on/off control | +| `select` | select | Shed priority control | + +Not all roles are present on every circuit. Roles are omitted when the entity does not exist (e.g., `current` is absent if the panel does not report per-circuit +current, `switch` is absent for always-on circuits). + +#### Sub-Device Object + +| Field | Type | Description | +| --------------- | ----------- | ------------------------------------- | +| `name` | string | HA device display name | +| `type` | string | `bess`, `evse`, or `unknown` | +| `manufacturer` | string/null | Device manufacturer | +| `model` | string/null | Device model | +| `serial_number` | string/null | Device serial number | +| `sw_version` | string/null | Device firmware/software version | +| `entities` | object | Entity ID keyed map with domain, name | + +### Errors + +| Code | Description | +| ---------------- | --------------------------------------------- | +| device_not_found | The device_id does not exist in HA | +| not_span_panel | The device is not a SPAN Panel device | +| not_loaded | The integration or config entry is not loaded | +| no_data | The coordinator has no panel data yet | + +### Usage from a Custom Card + +```javascript +const topology = await this.hass.callWS({ + type: "span_panel/panel_topology", + device_id: this._config.device_id, +}); + +// topology.circuits is keyed by circuit UUID +for (const [circuitId, circuit] of Object.entries(topology.circuits)) { + // circuit.tabs => [5, 6] (breaker positions) + // circuit.entities.power => "sensor.span_panel_kitchen_power" + const powerState = this.hass.states[circuit.entities.power]; +} +``` + +### Multi-Panel Homes + +Each panel is a separate config entry with its own device ID. To render multiple panels, call `span_panel/panel_topology` once per panel device ID. The response +is scoped to a single panel — circuits, sub-devices, and entity mappings from other panels are never included.