├── .bandit ├── .codespellrc ├── .github ├── linters │ └── .jscpd.json └── workflows │ ├── .isort.cfg │ ├── hacs.yml │ ├── lint.yml │ └── main.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .prettierrc ├── .pylintrc ├── .python-version ├── .vscode ├── settings.json.example └── tasks.json ├── Feature Planning.md ├── LICENSE ├── README.md ├── custom_components └── span_panel │ ├── __init__.py │ ├── binary_sensor.py │ ├── config_flow.py │ ├── const.py │ ├── coordinator.py │ ├── entity_migration.py │ ├── entity_summary.py │ ├── exceptions.py │ ├── helpers.py │ ├── manifest.json │ ├── options.py │ ├── select.py │ ├── sensor.py │ ├── span_panel.py │ ├── span_panel_api.py │ ├── span_panel_circuit.py │ ├── span_panel_data.py │ ├── span_panel_hardware_status.py │ ├── span_panel_storage_battery.py │ ├── strings.json │ ├── switch.py │ ├── translations │ ├── en.json │ ├── es.json │ ├── fr.json │ ├── ja.json │ └── pt.json │ ├── util.py │ └── version.py ├── debug_options_flow.py ├── developer_attribute_readme.md ├── hacs.json ├── poetry.lock ├── pyproject.toml ├── pyrightconfig.json ├── pytest.ini ├── requirements_test.txt ├── scripts ├── run-in-env.sh ├── run-mypy.sh ├── run-pylint.sh ├── run_mypy.py └── setup_env.sh ├── setup-hooks.sh └── tests ├── common.py ├── conftest.py ├── factories.py ├── helpers.py ├── test_basic_features.py ├── test_circuit_control.py ├── test_config_flow_entity_naming.py ├── test_configuration_edge_cases.py ├── test_door_sensor_integration.py ├── test_entity_naming_patterns.py ├── test_error_handling.py ├── test_factories.py ├── test_pattern_detection.py ├── test_synthetic_detection.py └── test_synthetic_sensors_integration.py /.bandit: -------------------------------------------------------------------------------- 1 | [bandit] 2 | # Exclude test directories and files 3 | exclude_dirs = tests 4 | exclude = test_*.py,debug_*.py 5 | 6 | # Skip specific checks that are acceptable in test files 7 | skips = B101,B108 8 | 9 | # B101: assert_used - Assert statements are normal and expected in tests 10 | # B108: hardcoded_tmp_directory - Test fixtures often use hardcoded paths 11 | -------------------------------------------------------------------------------- /.codespellrc: -------------------------------------------------------------------------------- 1 | [codespell] 2 | ignore-words-list = hass,astroid 3 | skip = poetry.lock 4 | -------------------------------------------------------------------------------- /.github/linters/.jscpd.json: -------------------------------------------------------------------------------- 1 | { 2 | "path": ["custom_components/span_panel", "./*.{html,md}"], 3 | "format": ["python", "javascript", "json", "markup", "markdown"], 4 | "ignore": [ 5 | "custom_components/span_panel/translations/**", 6 | "**/translations/**", 7 | ".github/**", 8 | "env/**", 9 | "**/site-packages/**", 10 | "**/.direnv/**" 11 | ], 12 | "reporters": ["console"], 13 | "output": "./jscpdReport", 14 | "gitignore": true 15 | } 16 | -------------------------------------------------------------------------------- /.github/workflows/.isort.cfg: -------------------------------------------------------------------------------- 1 | [settings] 2 | profile = black 3 | -------------------------------------------------------------------------------- /.github/workflows/hacs.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: HACS Jobs 3 | 4 | on: 5 | workflow_dispatch: 6 | workflow_call: {} 7 | pull_request: 8 | branches: 9 | - main 10 | push: 11 | branches: 12 | - main 13 | 14 | permissions: 15 | contents: read 16 | packages: read 17 | statuses: write 18 | 19 | jobs: 20 | validate: 21 | name: Hassfest 22 | runs-on: "ubuntu-latest" 23 | steps: 24 | - uses: actions/checkout@v4 25 | - uses: "home-assistant/actions/hassfest@master" 26 | 27 | hacs: 28 | name: HACS Action 29 | runs-on: "ubuntu-latest" 30 | steps: 31 | - name: HACS Action 32 | uses: "hacs/action@main" 33 | with: 34 | category: integration 35 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint Code Base 2 | 3 | on: 4 | workflow_call: 5 | 6 | permissions: 7 | contents: read 8 | checks: write 9 | actions: read 10 | statuses: write 11 | 12 | jobs: 13 | lint: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout code 17 | uses: actions/checkout@v4 18 | - name: Set up Python 19 | uses: actions/setup-python@v5 20 | with: 21 | python-version: "3.12" 22 | - name: Install dependencies 23 | run: | 24 | python -m pip install --upgrade pip 25 | pip install ruff isort mypy bandit 26 | - name: Set up Node.js 27 | uses: actions/setup-node@v4 28 | with: 29 | node-version: "v20" 30 | - name: Install Prettier 31 | run: npm install -g prettier 32 | - name: Run ruff 33 | run: ruff check . 34 | - name: Run isort 35 | run: isort check . 36 | - name: Run bandit on custom_components/span_panel 37 | run: bandit -r custom_components/span_panel 38 | - name: Run prettier with autofix 39 | run: prettier --write "**/*.{js,jsx,ts,tsx,json,css,scss,md}" 40 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Main 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | push: 8 | branches: 9 | - main 10 | workflow_dispatch: 11 | 12 | permissions: 13 | contents: read 14 | packages: read 15 | statuses: write 16 | checks: write 17 | actions: read 18 | 19 | jobs: 20 | lint: 21 | name: Lint Code Base 22 | uses: ./.github/workflows/lint.yml 23 | 24 | hacs: 25 | name: HACS Action 26 | needs: lint 27 | uses: ./.github/workflows/hacs.yml 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .*sw* 2 | *.pyc 3 | /.project 4 | .DS_Store 5 | .vscode/* 6 | !.vscode/settings.json.example 7 | !.vscode/tasks.json 8 | .git/ 9 | .direnv/ 10 | env/ 11 | .envrc 12 | .env 13 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/psf/black 3 | rev: 24.10.0 4 | hooks: 5 | - id: black 6 | args: [--line-length=88] 7 | 8 | - repo: https://github.com/astral-sh/ruff-pre-commit 9 | rev: v0.9.10 10 | hooks: 11 | - id: ruff 12 | args: 13 | - --fix 14 | 15 | - repo: https://github.com/codespell-project/codespell 16 | rev: v2.4.1 17 | hooks: 18 | - id: codespell 19 | args: 20 | - --quiet-level=2 21 | exclude_types: [csv, json, html] 22 | 23 | - repo: https://github.com/pre-commit/pre-commit-hooks 24 | rev: v5.0.0 25 | hooks: 26 | - id: check-executables-have-shebangs 27 | - id: check-json 28 | - id: trailing-whitespace 29 | - id: end-of-file-fixer 30 | - id: check-yaml 31 | 32 | - repo: https://github.com/adrienverge/yamllint.git 33 | rev: v1.35.1 34 | hooks: 35 | - id: yamllint 36 | 37 | - repo: https://github.com/PyCQA/bandit 38 | rev: 1.7.10 39 | hooks: 40 | - id: bandit 41 | args: [--config, pyproject.toml] 42 | files: ^custom_components/span_panel/.+\.py$ 43 | 44 | - repo: local 45 | hooks: 46 | - id: mypy 47 | name: mypy 48 | entry: scripts/run-mypy.sh 49 | language: script 50 | require_serial: true 51 | types_or: [python, pyi] 52 | # Only type check the main integration code, not tests 53 | files: ^custom_components/span_panel/.+\.(py|pyi)$ 54 | pass_filenames: true 55 | 56 | - repo: https://github.com/pre-commit/mirrors-prettier 57 | rev: v3.1.0 # Use the appropriate version 58 | hooks: 59 | - id: prettier 60 | files: \.(js|ts|jsx|tsx|css|less|json|md|markdown|yaml|yml)$ 61 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "useTabs": false, 4 | "semi": true, 5 | "singleQuote": false, 6 | "trailingComma": "es5", 7 | "printWidth": 100, 8 | "bracketSpacing": true, 9 | "arrowParens": "avoid" 10 | } 11 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | ignore=tests 3 | ignore-patterns=test_.* 4 | extension-pkg-whitelist=ciso8601 5 | 6 | # Use a conservative default here; 2 should speed up most setups and not hurt 7 | # any too bad 8 | jobs=2 9 | 10 | # List of plugins (as comma separated values of python modules names) to load, 11 | # usually to register additional checkers. 12 | load-plugins= 13 | 14 | # Allow loading of arbitrary C extensions. Extensions are imported into the 15 | # active Python interpreter and may run arbitrary code. 16 | unsafe-load-any-extension=no 17 | 18 | [MESSAGES CONTROL] 19 | # Reasons disabled: 20 | # format - handled by black 21 | # locally-disabled - it spams too much 22 | # duplicate-code - unavoidable 23 | # cyclic-import - doesn't test if both import on load 24 | # unused-argument - generic callbacks and setup methods create a lot of warnings 25 | # global-statement - used for the on-demand requirement installation 26 | # redefined-variable-type - this is Python, we're duck typing! 27 | # too-many-* - are not enforced for the sake of readability 28 | # too-few-* - same as too-many-* 29 | # abstract-method - with intro of async there are always methods missing 30 | # inconsistent-return-statements - doesn't handle raise 31 | # unnecessary-pass - readability for functions which only contain pass 32 | # import-outside-toplevel - TODO 33 | # too-many-ancestors - it's too strict. 34 | # wrong-import-order - isort guards this 35 | disable= 36 | duplicate-code, 37 | locally-disabled, 38 | suppressed-message, 39 | too-many-ancestors, 40 | too-many-arguments, 41 | too-many-instance-attributes, 42 | too-many-lines, 43 | too-many-locals, 44 | too-many-public-methods, 45 | too-many-statements, 46 | wrong-import-order, 47 | too-many-positional-arguments 48 | 49 | [REPORTS] 50 | # Set the output format. Available formats are text, parseable, colorized, msvs 51 | output-format=parseable 52 | 53 | # Tells whether to display a full report or only the messages 54 | reports=no 55 | 56 | [VARIABLES] 57 | # Tells whether we should check for unused import in __init__ files. 58 | init-import=no 59 | 60 | # A regular expression matching the name of dummy variables (i.e. expectedly 61 | # not used). 62 | dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ 63 | 64 | [SIMILARITIES] 65 | # Minimum lines number of a similarity. 66 | min-similarity-lines=4 67 | 68 | # Ignore comments when computing similarities. 69 | ignore-comments=yes 70 | 71 | # Ignore docstrings when computing similarities. 72 | ignore-docstrings=yes 73 | 74 | # Ignore imports when computing similarities. 75 | ignore-imports=no 76 | 77 | [TYPECHECK] 78 | # List of decorators that produce context managers, such as 79 | # contextlib.contextmanager. Add to this list to register other decorators that 80 | # produce valid context managers. 81 | contextmanager-decorators=contextlib.contextmanager 82 | 83 | # Tells whether missing members accessed in mixin class should be ignored. A 84 | # mixin class is detected if its name ends with "mixin" (case insensitive). 85 | ignore-mixin-members=yes 86 | 87 | # List of module names for which member attributes should not be checked 88 | # (useful for modules/projects where namespaces are manipulated during runtime 89 | # and thus existing member attributes cannot be deduced by static analysis. It 90 | # supports qualified module names, as well as Unix pattern matching. 91 | ignored-modules=homeassistant.components,custom_components 92 | 93 | [BASIC] 94 | # Good variable names which should always be accepted, separated by a comma 95 | good-names=id,i,j,k,ex,Run,_,fp,T 96 | 97 | # Include a hint for the correct naming format with invalid-name 98 | include-naming-hint=no 99 | 100 | # Regular expression matching correct function names 101 | function-rgx=[a-z_][a-z0-9_]{2,30}$ 102 | 103 | [EXCEPTIONS] 104 | overgeneral-exceptions=builtins.Exception,homeassistant.exceptions.HomeAssistantError 105 | 106 | [FORMAT] 107 | # Maximum number of characters on a single line. 108 | max-line-length=88 109 | 110 | [DESIGN] 111 | # Maximum number of arguments for function / method 112 | max-args=5 113 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.13.2 2 | -------------------------------------------------------------------------------- /.vscode/settings.json.example: -------------------------------------------------------------------------------- 1 | { 2 | "files.associations": { 3 | "*.yaml": "home-assistant" 4 | }, 5 | "git.enabled": true, 6 | "git.autofetch": true, 7 | "git.enableSmartCommit": true, 8 | "git.postCommitCommand": "push", 9 | "git.showActionButton": { 10 | "commit": true, 11 | "publish": true, 12 | "sync": true 13 | }, 14 | "git.branchProtection": ["main", "master"], 15 | "git.branchProtectionPrompt": "alwaysPrompt", 16 | "git.fetchOnPull": true, 17 | "git.pruneOnFetch": true, 18 | "git.supportCancellation": true, 19 | "python.defaultInterpreterPath": "${workspaceFolder}/venv/bin/python", 20 | "python.terminal.activateEnvironment": true, 21 | "python.analysis.extraPaths": [ 22 | "${workspaceFolder}/venv/lib/python*/site-packages", 23 | "${workspaceFolder}" 24 | ], 25 | "python.analysis.autoSearchPaths": true, 26 | "python.analysis.useLibraryCodeForTypes": true, 27 | "python.analysis.autoImportCompletions": true, 28 | "python.envFile": "${workspaceFolder}/.env", 29 | "[python]": { 30 | "editor.defaultFormatter": "charliermarsh.ruff", 31 | "editor.codeActionsOnSave": { 32 | "source.organizeImports.ruff": "explicit", 33 | "source.fixAll": "explicit" 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "Setup Git Hooks", 6 | "type": "shell", 7 | "command": "${workspaceFolder}/setup-hooks.sh", 8 | "presentation": { 9 | "reveal": "silent", 10 | "clear": true 11 | }, 12 | "problemMatcher": [], 13 | "runOptions": { 14 | "runOn": "folderOpen" 15 | } 16 | }, 17 | { 18 | "label": "Run all pre-commit hooks", 19 | "type": "shell", 20 | "command": "${workspaceFolder}/scripts/run-in-env.sh pre-commit run --all-files", 21 | "group": { 22 | "kind": "test", 23 | "isDefault": true 24 | }, 25 | "presentation": { 26 | "reveal": "always", 27 | "panel": "new" 28 | }, 29 | "problemMatcher": [] 30 | }, 31 | { 32 | "label": "Run mypy", 33 | "type": "shell", 34 | "command": "${workspaceFolder}/scripts/run-in-env.sh mypy", 35 | "group": "test", 36 | "presentation": { 37 | "reveal": "always", 38 | "panel": "new" 39 | }, 40 | "problemMatcher": { 41 | "owner": "mypy", 42 | "fileLocation": ["relative", "${workspaceFolder}"], 43 | "pattern": { 44 | "regexp": "^(.+):(\\d+): (error|warning|note): (.+)$", 45 | "file": 1, 46 | "line": 2, 47 | "severity": 3, 48 | "message": 4 49 | } 50 | } 51 | }, 52 | { 53 | "label": "Run black format", 54 | "type": "shell", 55 | "command": "${workspaceFolder}/scripts/run-in-env.sh black --line-length 88 .", 56 | "group": "test", 57 | "presentation": { 58 | "reveal": "always", 59 | "panel": "new" 60 | }, 61 | "problemMatcher": [] 62 | }, 63 | { 64 | "label": "Run ruff check", 65 | "type": "shell", 66 | "command": "${workspaceFolder}/scripts/run-in-env.sh ruff check --fix .", 67 | "group": "test", 68 | "presentation": { 69 | "reveal": "always", 70 | "panel": "new" 71 | }, 72 | "problemMatcher": [] 73 | } 74 | ] 75 | } 76 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ 2 | Source: 3 | Upstream-Name: span 4 | Upstream-Contact: N/A 5 | License: MIT 6 | Files: * 7 | Copyright: Copyright (c) 2024 Kumar Gala , Jeff Kibuule , Wez Furlong , Kevin Arthur , Greg Gibeling , Melvin Tan , cayossarian 8 | License: MIT 9 | Permission is hereby granted, free of charge, to any person obtaining a copy 10 | of this software and associated documentation files (the "Software"), to deal 11 | in the Software without restriction, including without limitation the rights 12 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | copies of the Software, and to permit persons to whom the Software is 14 | furnished to do so, subject to the following conditions: 15 | 16 | The above copyright notice and this permission notice shall be included in all 17 | copies or substantial portions of the Software. 18 | 19 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 25 | SOFTWARE. 26 | -------------------------------------------------------------------------------- /custom_components/span_panel/__init__.py: -------------------------------------------------------------------------------- 1 | """The Span Panel integration.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | 7 | from homeassistant.config_entries import ConfigEntry 8 | from homeassistant.const import ( 9 | CONF_ACCESS_TOKEN, 10 | CONF_HOST, 11 | CONF_SCAN_INTERVAL, 12 | Platform, 13 | ) 14 | from homeassistant.core import HomeAssistant 15 | from homeassistant.helpers.httpx_client import get_async_client 16 | 17 | from .const import COORDINATOR, DEFAULT_SCAN_INTERVAL, DOMAIN, NAME 18 | from .coordinator import SpanPanelCoordinator 19 | from .entity_summary import log_entity_summary 20 | from .options import Options 21 | from .span_panel import SpanPanel 22 | 23 | PLATFORMS: list[Platform] = [ 24 | Platform.BINARY_SENSOR, 25 | Platform.SELECT, 26 | Platform.SENSOR, 27 | Platform.SWITCH, 28 | ] 29 | 30 | _LOGGER = logging.getLogger(__name__) 31 | 32 | 33 | async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 34 | """Set up Span Panel from a config entry.""" 35 | config = entry.data 36 | host = config[CONF_HOST] 37 | name = "SpanPanel" 38 | 39 | _LOGGER.debug("ASYNC_SETUP_ENTRY %s", host) 40 | 41 | span_panel = SpanPanel( 42 | host=config[CONF_HOST], 43 | access_token=config[CONF_ACCESS_TOKEN], 44 | options=Options(entry), 45 | async_client=get_async_client(hass), 46 | ) 47 | 48 | _LOGGER.debug("ASYNC_SETUP_ENTRY panel %s", span_panel) 49 | 50 | # Get scan interval from options with a default 51 | scan_interval = entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL.seconds) 52 | 53 | coordinator = SpanPanelCoordinator( 54 | hass, span_panel, name, update_interval=scan_interval, config_entry=entry 55 | ) 56 | 57 | await coordinator.async_config_entry_first_refresh() 58 | 59 | entry.async_on_unload(entry.add_update_listener(update_listener)) 60 | 61 | hass.data.setdefault(DOMAIN, {}) 62 | hass.data[DOMAIN][entry.entry_id] = { 63 | COORDINATOR: coordinator, 64 | NAME: name, 65 | } 66 | 67 | await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) 68 | 69 | # Debug logging of entity summary 70 | log_entity_summary(coordinator, entry) 71 | 72 | return True 73 | 74 | 75 | async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 76 | """Unload a config entry.""" 77 | _LOGGER.debug("ASYNC_UNLOAD") 78 | if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): 79 | hass.data[DOMAIN].pop(entry.entry_id) 80 | 81 | return unload_ok 82 | 83 | 84 | async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: 85 | """Update listener.""" 86 | await hass.config_entries.async_reload(entry.entry_id) 87 | -------------------------------------------------------------------------------- /custom_components/span_panel/binary_sensor.py: -------------------------------------------------------------------------------- 1 | """Binary Sensors for status entities.""" 2 | 3 | from __future__ import annotations 4 | 5 | from collections.abc import Callable 6 | from dataclasses import dataclass 7 | import logging 8 | from typing import Any, Generic, TypeVar 9 | 10 | from homeassistant.components.binary_sensor import ( 11 | BinarySensorDeviceClass, 12 | BinarySensorEntity, 13 | BinarySensorEntityDescription, 14 | ) 15 | from homeassistant.config_entries import ConfigEntry 16 | from homeassistant.core import HomeAssistant 17 | from homeassistant.helpers.device_registry import DeviceInfo 18 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 19 | from homeassistant.helpers.update_coordinator import CoordinatorEntity 20 | 21 | from .const import ( 22 | COORDINATOR, 23 | DOMAIN, 24 | SYSTEM_DOOR_STATE_CLOSED, 25 | SYSTEM_DOOR_STATE_OPEN, 26 | USE_DEVICE_PREFIX, 27 | ) 28 | from .coordinator import SpanPanelCoordinator 29 | from .span_panel import SpanPanel 30 | from .span_panel_hardware_status import SpanPanelHardwareStatus 31 | from .util import panel_to_device_info 32 | 33 | # pylint: disable=invalid-overridden-method 34 | 35 | 36 | _LOGGER: logging.Logger = logging.getLogger(__name__) 37 | 38 | 39 | @dataclass(frozen=True) 40 | class SpanPanelRequiredKeysMixin: 41 | """Required keys mixin for Span Panel binary sensors.""" 42 | 43 | value_fn: Callable[[SpanPanelHardwareStatus], bool | None] 44 | 45 | 46 | @dataclass(frozen=True) 47 | class SpanPanelBinarySensorEntityDescription( 48 | BinarySensorEntityDescription, SpanPanelRequiredKeysMixin 49 | ): 50 | """Describes an SpanPanelCircuits sensor entity.""" 51 | 52 | 53 | # Door state has benn observed to return UNKNOWN if the door 54 | # has not been operated recently so we check for invalid values 55 | # pylint: disable=unexpected-keyword-arg 56 | BINARY_SENSORS: tuple[ 57 | SpanPanelBinarySensorEntityDescription, 58 | SpanPanelBinarySensorEntityDescription, 59 | SpanPanelBinarySensorEntityDescription, 60 | SpanPanelBinarySensorEntityDescription, 61 | ] = ( 62 | SpanPanelBinarySensorEntityDescription( 63 | key="doorState", 64 | name="Door State", 65 | device_class=BinarySensorDeviceClass.TAMPER, 66 | value_fn=lambda status_data: ( 67 | None 68 | if status_data.door_state 69 | not in [SYSTEM_DOOR_STATE_CLOSED, SYSTEM_DOOR_STATE_OPEN] 70 | else not status_data.is_door_closed 71 | ), 72 | ), 73 | SpanPanelBinarySensorEntityDescription( 74 | key="eth0Link", 75 | name="Ethernet Link", 76 | device_class=BinarySensorDeviceClass.CONNECTIVITY, 77 | value_fn=lambda status_data: status_data.is_ethernet_connected, 78 | ), 79 | SpanPanelBinarySensorEntityDescription( 80 | key="wlanLink", 81 | name="Wi-Fi Link", 82 | device_class=BinarySensorDeviceClass.CONNECTIVITY, 83 | value_fn=lambda status_data: status_data.is_wifi_connected, 84 | ), 85 | SpanPanelBinarySensorEntityDescription( 86 | key="wwanLink", 87 | name="Cellular Link", 88 | device_class=BinarySensorDeviceClass.CONNECTIVITY, 89 | value_fn=lambda status_data: status_data.is_cellular_connected, 90 | ), 91 | ) 92 | 93 | T = TypeVar("T", bound=SpanPanelBinarySensorEntityDescription) 94 | 95 | 96 | class SpanPanelBinarySensor( 97 | CoordinatorEntity[SpanPanelCoordinator], BinarySensorEntity, Generic[T] 98 | ): 99 | """Binary Sensor status entity.""" 100 | 101 | _attr_icon: str | None = "mdi:flash" 102 | 103 | def __init__( 104 | self, 105 | data_coordinator: SpanPanelCoordinator, 106 | description: T, 107 | ) -> None: 108 | """Initialize Span Panel Circuit entity.""" 109 | super().__init__(data_coordinator, context=description) 110 | span_panel: SpanPanel = data_coordinator.data 111 | 112 | # See developer_attrtribute_readme.md for why we use 113 | # entity_description instead of _attr_entity_description 114 | self.entity_description = description 115 | self._attr_device_class = description.device_class 116 | 117 | # Store direct reference to the value_fn so we don't need to access through 118 | # entity_description later, avoiding type issues 119 | self._value_fn = description.value_fn 120 | 121 | device_info: DeviceInfo = panel_to_device_info(span_panel) 122 | self._attr_device_info = device_info 123 | base_name: str | None = f"{description.name}" 124 | 125 | if ( 126 | data_coordinator.config_entry is not None 127 | and data_coordinator.config_entry.options.get(USE_DEVICE_PREFIX, False) 128 | and "name" in device_info 129 | ): 130 | self._attr_name = f"{device_info['name']} {base_name}" 131 | else: 132 | self._attr_name = base_name 133 | 134 | self._attr_unique_id = ( 135 | f"span_{span_panel.status.serial_number}_{description.key}" 136 | ) 137 | 138 | _LOGGER.debug("CREATE BINSENSOR [%s]", self._attr_name) 139 | 140 | def _handle_coordinator_update(self) -> None: 141 | """Handle updated data from the coordinator.""" 142 | # Get the raw status value from the device 143 | status_data = self.coordinator.data.status 144 | 145 | # Get binary state using the directly stored value_fn reference 146 | status_value = self._value_fn(status_data) 147 | 148 | self._attr_is_on = status_value 149 | self._attr_available = status_value is not None 150 | 151 | _LOGGER.debug( 152 | "BINSENSOR [%s] updated: is_on=%s, available=%s", 153 | self._attr_name, 154 | self._attr_is_on, 155 | self._attr_available, 156 | ) 157 | 158 | super()._handle_coordinator_update() 159 | 160 | 161 | async def async_setup_entry( 162 | hass: HomeAssistant, 163 | config_entry: ConfigEntry, 164 | async_add_entities: AddEntitiesCallback, 165 | ) -> None: 166 | """Set up status sensor platform.""" 167 | 168 | _LOGGER.debug("ASYNC SETUP ENTRY BINARYSENSOR") 169 | 170 | data: dict[str, Any] = hass.data[DOMAIN][config_entry.entry_id] 171 | coordinator: SpanPanelCoordinator = data[COORDINATOR] 172 | 173 | entities: list[SpanPanelBinarySensor[SpanPanelBinarySensorEntityDescription]] = [] 174 | 175 | for description in BINARY_SENSORS: 176 | entities.append(SpanPanelBinarySensor(coordinator, description)) 177 | 178 | async_add_entities(entities) 179 | -------------------------------------------------------------------------------- /custom_components/span_panel/const.py: -------------------------------------------------------------------------------- 1 | """Constants for the Span Panel integration.""" 2 | 3 | from datetime import timedelta 4 | import enum 5 | from typing import Final 6 | 7 | DOMAIN: Final = "span_panel" 8 | COORDINATOR = "coordinator" 9 | NAME = "name" 10 | 11 | CONF_SERIAL_NUMBER = "serial_number" 12 | 13 | URL_STATUS = "http://{}/api/v1/status" 14 | URL_SPACES = "http://{}/api/v1/spaces" 15 | URL_CIRCUITS = "http://{}/api/v1/circuits" 16 | URL_PANEL = "http://{}/api/v1/panel" 17 | URL_REGISTER = "http://{}/api/v1/auth/register" 18 | URL_STORAGE_BATTERY = "http://{}/api/v1/storage/soe" 19 | 20 | STORAGE_BATTERY_PERCENTAGE = "batteryPercentage" 21 | CIRCUITS_NAME = "name" 22 | CIRCUITS_RELAY = "relayState" 23 | CIRCUITS_POWER = "instantPowerW" 24 | CIRCUITS_ENERGY_PRODUCED = "producedEnergyWh" 25 | CIRCUITS_ENERGY_CONSUMED = "consumedEnergyWh" 26 | CIRCUITS_BREAKER_POSITIONS = "tabs" 27 | CIRCUITS_PRIORITY = "priority" 28 | CIRCUITS_IS_USER_CONTROLLABLE = "is_user_controllable" 29 | CIRCUITS_IS_SHEDDABLE = "is_sheddable" 30 | CIRCUITS_IS_NEVER_BACKUP = "is_never_backup" 31 | 32 | SPAN_CIRCUITS = "circuits" 33 | SPAN_SOE = "soe" 34 | SPAN_SYSTEM = "system" 35 | PANEL_POWER = "instantGridPowerW" 36 | SYSTEM_DOOR_STATE = "doorState" 37 | SYSTEM_DOOR_STATE_CLOSED = "CLOSED" 38 | SYSTEM_DOOR_STATE_UNKNOWN = "UNKNOWN" 39 | SYSTEM_DOOR_STATE_OPEN = "OPEN" 40 | SYSTEM_ETHERNET_LINK = "eth0Link" 41 | SYSTEM_CELLULAR_LINK = "wwanLink" 42 | SYSTEM_WIFI_LINK = "wlanLink" 43 | 44 | STATUS_SOFTWARE_VER = "softwareVer" 45 | DSM_GRID_STATE = "dsmGridState" 46 | DSM_STATE = "dsmState" 47 | CURRENT_RUN_CONFIG = "currentRunConfig" 48 | MAIN_RELAY_STATE = "mainRelayState" 49 | 50 | PANEL_MAIN_RELAY_STATE_UNKNOWN_VALUE = "UNKNOWN" 51 | USE_DEVICE_PREFIX = "use_device_prefix" 52 | USE_CIRCUIT_NUMBERS = "use_circuit_numbers" 53 | 54 | # Entity naming pattern options 55 | ENTITY_NAMING_PATTERN = "entity_naming_pattern" 56 | 57 | DEFAULT_SCAN_INTERVAL = timedelta(seconds=15) 58 | API_TIMEOUT = 30 59 | 60 | 61 | class CircuitRelayState(enum.Enum): 62 | """Enumeration representing the possible relay states for a circuit.""" 63 | 64 | OPEN = "Open" 65 | CLOSED = "Closed" 66 | UNKNOWN = "Unknown" 67 | 68 | 69 | class CircuitPriority(enum.Enum): 70 | """Enumeration representing the possible circuit priority levels.""" 71 | 72 | MUST_HAVE = "Must Have" 73 | NICE_TO_HAVE = "Nice To Have" 74 | NON_ESSENTIAL = "Non-Essential" 75 | UNKNOWN = "Unknown" 76 | 77 | 78 | class EntityNamingPattern(enum.Enum): 79 | """Entity naming pattern options for user selection.""" 80 | 81 | FRIENDLY_NAMES = "friendly_names" # Device + Friendly Name (e.g., span_panel_kitchen_outlets_power) 82 | CIRCUIT_NUMBERS = ( 83 | "circuit_numbers" # Device + Circuit Numbers (e.g., span_panel_circuit_1_power) 84 | ) 85 | LEGACY_NAMES = "legacy_names" # No Device Prefix (e.g., kitchen_outlets_power) - Read-only for pre-1.0.4 86 | -------------------------------------------------------------------------------- /custom_components/span_panel/coordinator.py: -------------------------------------------------------------------------------- 1 | """Coordinator for Span Panel.""" 2 | 3 | import asyncio 4 | from datetime import timedelta 5 | import logging 6 | 7 | from homeassistant.config_entries import ConfigEntry 8 | from homeassistant.core import HomeAssistant 9 | from homeassistant.exceptions import ( 10 | ConfigEntryAuthFailed, 11 | ConfigEntryNotReady, 12 | HomeAssistantError, 13 | ) 14 | from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed 15 | import httpx 16 | 17 | from .const import API_TIMEOUT 18 | from .span_panel import SpanPanel 19 | 20 | _LOGGER: logging.Logger = logging.getLogger(__name__) 21 | 22 | 23 | class SpanPanelCoordinator(DataUpdateCoordinator[SpanPanel]): 24 | """Coordinator for Span Panel.""" 25 | 26 | def __init__( 27 | self, 28 | hass: HomeAssistant, 29 | span_panel: SpanPanel, 30 | name: str, 31 | update_interval: int, 32 | config_entry: ConfigEntry, 33 | ) -> None: 34 | """Initialize the coordinator.""" 35 | super().__init__( 36 | hass, 37 | _LOGGER, 38 | name=f"span panel {name}", 39 | update_interval=timedelta(seconds=update_interval), 40 | always_update=True, 41 | ) 42 | self.span_panel_api = span_panel 43 | self.config_entry: ConfigEntry | None = config_entry 44 | 45 | # Flag for panel name auto-sync integration reload 46 | self._needs_reload = False 47 | 48 | def request_reload(self) -> None: 49 | """Request an integration reload for the next update cycle.""" 50 | self._needs_reload = True 51 | _LOGGER.debug("Integration reload requested for next update cycle") 52 | 53 | async def _async_update_data(self) -> SpanPanel: 54 | """Fetch data from API endpoint.""" 55 | # Check if reload is needed before updating (auto-sync) 56 | if self._needs_reload: 57 | self._needs_reload = False 58 | _LOGGER.info("Auto-sync triggering integration reload") 59 | try: 60 | if self.config_entry is None: 61 | _LOGGER.error( 62 | "Cannot reload: config_entry is None - integration incorrectly initialized" 63 | ) 64 | raise ConfigEntryNotReady( 65 | "Config entry is None - integration incorrectly initialized" 66 | ) 67 | await self.hass.config_entries.async_reload(self.config_entry.entry_id) 68 | # After successful reload, this coordinator instance is destroyed 69 | # so we never reach this point - no need to return anything 70 | return ( 71 | self.span_panel_api 72 | ) # Return current data in case reload is delayed 73 | except (ConfigEntryNotReady, HomeAssistantError) as e: 74 | _LOGGER.error("auto-sync failed to reload integration: %s", e) 75 | # Continue with normal update if reload fails 76 | 77 | try: 78 | _LOGGER.debug("Starting coordinator update") 79 | await asyncio.wait_for(self.span_panel_api.update(), timeout=API_TIMEOUT) 80 | return self.span_panel_api 81 | except httpx.HTTPStatusError as err: 82 | if err.response.status_code == httpx.codes.UNAUTHORIZED: 83 | raise ConfigEntryAuthFailed from err 84 | _LOGGER.error( 85 | "httpx.StatusError occurred while updating Span data: %s", 86 | str(err), 87 | ) 88 | raise UpdateFailed(f"Error communicating with API: {err}") from err 89 | except httpx.HTTPError as err: 90 | _LOGGER.error( 91 | "An httpx.HTTPError occurred while updating Span data: %s", str(err) 92 | ) 93 | raise UpdateFailed(f"Error communicating with API: {err}") from err 94 | except TimeoutError as err: 95 | _LOGGER.error( 96 | "An asyncio.TimeoutError occurred while updating Span data: %s", 97 | str(err), 98 | ) 99 | raise UpdateFailed(f"Error communicating with API: {err}") from err 100 | -------------------------------------------------------------------------------- /custom_components/span_panel/entity_summary.py: -------------------------------------------------------------------------------- 1 | """Entity summary logging for Span Panel integration.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | 7 | from homeassistant.config_entries import ConfigEntry 8 | 9 | from .coordinator import SpanPanelCoordinator 10 | from .options import BATTERY_ENABLE, INVERTER_ENABLE 11 | from .sensor import ( 12 | CIRCUITS_SENSORS, 13 | PANEL_DATA_STATUS_SENSORS, 14 | PANEL_SENSORS, 15 | STATUS_SENSORS, 16 | STORAGE_BATTERY_SENSORS, 17 | SYNTHETIC_SENSOR_TEMPLATES, 18 | ) 19 | 20 | _LOGGER = logging.getLogger(__name__) 21 | 22 | 23 | def log_entity_summary( 24 | coordinator: SpanPanelCoordinator, config_entry: ConfigEntry 25 | ) -> None: 26 | """Log a comprehensive summary of entities that will be created. 27 | 28 | Uses debug level for detailed info, info level for basic summary. 29 | 30 | Args: 31 | coordinator: The SpanPanelCoordinator instance 32 | config_entry: The config entry with options 33 | 34 | """ 35 | # Check if any logging is enabled for the main span_panel module 36 | main_logger = logging.getLogger("custom_components.span_panel") 37 | use_debug_level = main_logger.isEnabledFor(logging.DEBUG) 38 | use_info_level = main_logger.isEnabledFor(logging.INFO) 39 | 40 | if not (use_debug_level or use_info_level): 41 | return 42 | 43 | span_panel_data = coordinator.data 44 | total_circuits = len(span_panel_data.circuits) 45 | 46 | # Count controllable circuits and identify non-controllable ones 47 | controllable_circuits = sum( 48 | 1 49 | for circuit in span_panel_data.circuits.values() 50 | if circuit.is_user_controllable 51 | ) 52 | non_controllable_circuits = total_circuits - controllable_circuits 53 | 54 | # Identify non-controllable circuits for debugging 55 | non_controllable_circuit_names = [ 56 | f"{circuit.name} (ID: {circuit.circuit_id})" 57 | for circuit in span_panel_data.circuits.values() 58 | if not circuit.is_user_controllable 59 | ] 60 | 61 | solar_enabled = config_entry.options.get(INVERTER_ENABLE, False) 62 | battery_enabled = config_entry.options.get(BATTERY_ENABLE, False) 63 | 64 | # Circuit sensors are created for all circuits in the circuits collection 65 | # Solar legs are NOT in this collection - they're accessed via raw branch data 66 | circuit_sensors = total_circuits * len(CIRCUITS_SENSORS) 67 | synthetic_sensors = len(SYNTHETIC_SENSOR_TEMPLATES) if solar_enabled else 0 68 | panel_sensor_count = len(PANEL_SENSORS) + len(PANEL_DATA_STATUS_SENSORS) 69 | status_sensors = len(STATUS_SENSORS) 70 | battery_sensors = len(STORAGE_BATTERY_SENSORS) if battery_enabled else 0 71 | 72 | total_sensors = ( 73 | circuit_sensors 74 | + synthetic_sensors 75 | + panel_sensor_count 76 | + status_sensors 77 | + battery_sensors 78 | ) 79 | total_switches = controllable_circuits # Only controllable circuits get switches 80 | total_selects = controllable_circuits # Only controllable circuits get selects 81 | 82 | # Choose logging level based on what's enabled 83 | log_func = main_logger.debug if use_debug_level else main_logger.info 84 | 85 | log_func("=== SPAN PANEL ENTITY SUMMARY ===") 86 | log_func( 87 | "Total circuits: %d (%d controllable, %d non-controllable)", 88 | total_circuits, 89 | controllable_circuits, 90 | non_controllable_circuits, 91 | ) 92 | 93 | # Show non-controllable circuit info at both info and debug levels 94 | if non_controllable_circuit_names: 95 | # Force info level to ensure this shows up 96 | main_logger.info( 97 | "Non-controllable circuits: %s", 98 | ", ".join(non_controllable_circuit_names), 99 | ) 100 | else: 101 | main_logger.info("Non-controllable circuits: None") 102 | 103 | log_func( 104 | "Circuit sensors: %d (%d circuits x %d sensors per circuit)", 105 | circuit_sensors, 106 | total_circuits, 107 | len(CIRCUITS_SENSORS), 108 | ) 109 | if solar_enabled: 110 | log_func("Synthetic sensors: %d (solar inverter)", synthetic_sensors) 111 | else: 112 | log_func("Synthetic sensors: 0 (solar disabled)") 113 | log_func("Panel sensors: %d", panel_sensor_count) 114 | log_func("Status sensors: %d", status_sensors) 115 | if battery_enabled: 116 | log_func("Battery sensors: %d", battery_sensors) 117 | else: 118 | log_func("Battery sensors: 0 (battery disabled)") 119 | log_func("Circuit switches: %d (controllable circuits only)", total_switches) 120 | log_func("Circuit selects: %d (controllable circuits only)", total_selects) 121 | log_func( 122 | "Total entities: %d sensors + %d switches + %d selects = %d", 123 | total_sensors, 124 | total_switches, 125 | total_selects, 126 | total_sensors + total_switches + total_selects, 127 | ) 128 | log_func("=== END ENTITY SUMMARY ===") 129 | -------------------------------------------------------------------------------- /custom_components/span_panel/exceptions.py: -------------------------------------------------------------------------------- 1 | """Exceptions for Span Panel integration.""" 2 | 3 | 4 | class SpanPanelReturnedEmptyData(Exception): 5 | """Exception raised when the Span Panel API returns empty or missing data.""" 6 | -------------------------------------------------------------------------------- /custom_components/span_panel/helpers.py: -------------------------------------------------------------------------------- 1 | """Helper functions for Span Panel integration.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | 7 | from .const import USE_CIRCUIT_NUMBERS, USE_DEVICE_PREFIX 8 | from .coordinator import SpanPanelCoordinator 9 | from .span_panel import SpanPanel 10 | from .util import panel_to_device_info 11 | 12 | _LOGGER = logging.getLogger(__name__) 13 | 14 | 15 | def sanitize_name_for_entity_id(name: str) -> str: 16 | """Sanitize a name for use in entity IDs.""" 17 | return name.lower().replace(" ", "_").replace("-", "_") 18 | 19 | 20 | def construct_entity_id( 21 | coordinator: SpanPanelCoordinator, 22 | span_panel: SpanPanel, 23 | platform: str, 24 | circuit_name: str, 25 | circuit_number: str | int, 26 | suffix: str, 27 | ) -> str | None: 28 | """Construct entity ID based on integration configuration flags. 29 | 30 | Args: 31 | coordinator: The coordinator instance 32 | span_panel: The span panel data 33 | platform: Platform name ("sensor", "switch", "select") 34 | circuit_name: Human-readable circuit name 35 | circuit_number: Circuit number (tab position) 36 | suffix: Entity-specific suffix ("power", "breaker", "priority", etc.) 37 | 38 | Returns: 39 | Constructed entity ID string or None if device info unavailable 40 | 41 | """ 42 | config_entry = coordinator.config_entry 43 | if config_entry is None: 44 | raise RuntimeError( 45 | "Config entry missing from coordinator - integration improperly set up" 46 | ) 47 | 48 | use_device_prefix = config_entry.options.get(USE_DEVICE_PREFIX, False) 49 | use_circuit_numbers = config_entry.options.get(USE_CIRCUIT_NUMBERS, False) 50 | 51 | # Get device info for device name 52 | device_info = panel_to_device_info(span_panel) 53 | device_name_raw = device_info.get("name") 54 | 55 | if use_circuit_numbers: 56 | # New installation (v1.0.9+) - stable circuit-based entity IDs 57 | if device_name_raw: 58 | device_name = sanitize_name_for_entity_id(device_name_raw) 59 | return f"{platform}.{device_name}_circuit_{circuit_number}_{suffix}" 60 | else: 61 | return None 62 | 63 | elif use_device_prefix: 64 | # Post-1.0.4 installation - device prefix with circuit names 65 | if device_name_raw: 66 | device_name = sanitize_name_for_entity_id(device_name_raw) 67 | circuit_name_sanitized = sanitize_name_for_entity_id(circuit_name) 68 | return f"{platform}.{device_name}_{circuit_name_sanitized}_{suffix}" 69 | else: 70 | return None 71 | 72 | else: 73 | # Pre-1.0.4 installation - no device prefix, just circuit names 74 | circuit_name_sanitized = sanitize_name_for_entity_id(circuit_name) 75 | return f"{platform}.{circuit_name_sanitized}_{suffix}" 76 | 77 | 78 | def get_user_friendly_suffix(description_key: str) -> str: 79 | """Convert API field names to user-friendly entity ID suffixes.""" 80 | suffix_mapping = { 81 | # Circuit sensor API field mappings 82 | "instantPowerW": "power", 83 | "producedEnergyWh": "energy_produced", 84 | "consumedEnergyWh": "energy_consumed", 85 | "importedEnergyWh": "energy_imported", 86 | "exportedEnergyWh": "energy_exported", 87 | "circuit_priority": "priority", 88 | } 89 | return suffix_mapping.get(description_key, description_key.lower()) 90 | 91 | 92 | def construct_synthetic_entity_id( 93 | coordinator: SpanPanelCoordinator, 94 | span_panel: SpanPanel, 95 | platform: str, 96 | circuit_numbers: list[int], 97 | suffix: str, 98 | friendly_name: str | None = None, 99 | ) -> str | None: 100 | """Construct synthetic entity ID for multi-circuit entities based on integration configuration flags. 101 | 102 | This function handles entity naming for synthetic sensors that combine multiple circuits, 103 | such as solar inverters or custom circuit groups (Phase 3). The naming pattern is determined 104 | entirely by the USE_CIRCUIT_NUMBERS and USE_DEVICE_PREFIX configuration flags. 105 | 106 | Args: 107 | coordinator: The coordinator instance 108 | span_panel: The span panel data 109 | platform: Platform name ("sensor", "switch", "select") 110 | circuit_numbers: List of circuit numbers to combine (e.g., [30, 32] for solar inverter) 111 | suffix: Entity-specific suffix ("instant_power", "energy_produced", etc.) 112 | friendly_name: Optional friendly name for name-based entity 113 | 114 | Returns: 115 | Constructed entity ID string or None if device info unavailable 116 | 117 | """ 118 | config_entry = coordinator.config_entry 119 | if config_entry is None: 120 | raise RuntimeError( 121 | "Config entry missing from coordinator - integration improperly set up" 122 | ) 123 | 124 | use_device_prefix = config_entry.options.get(USE_DEVICE_PREFIX, False) 125 | use_circuit_numbers = config_entry.options.get(USE_CIRCUIT_NUMBERS, False) 126 | 127 | # Get device info for device name 128 | device_info = panel_to_device_info(span_panel) 129 | device_name_raw = device_info.get("name") 130 | 131 | if use_circuit_numbers: 132 | # New installation (v1.0.9+) - stable circuit-based entity IDs 133 | # Format: sensor.span_panel_circuit_30_32_instant_power 134 | if device_name_raw: 135 | device_name = sanitize_name_for_entity_id(device_name_raw) 136 | # Filter out zero/invalid circuit numbers and create circuit specification 137 | valid_circuits = [str(num) for num in circuit_numbers if num > 0] 138 | circuit_spec = "_".join(valid_circuits) if valid_circuits else "unknown" 139 | return f"{platform}.{device_name}_circuit_{circuit_spec}_{suffix}" 140 | else: 141 | return None 142 | 143 | else: 144 | # named based entity - use friendly name to construct entity ID 145 | if friendly_name: 146 | # Convert friendly name to entity ID format (e.g., "Solar Production" -> "solar_production") 147 | entity_name = sanitize_name_for_entity_id(friendly_name) 148 | else: 149 | # Fallback to circuit-based naming if no friendly name provided 150 | valid_circuits = [str(num) for num in circuit_numbers if num > 0] 151 | entity_name = f"circuit_group_{'_'.join(valid_circuits)}" 152 | 153 | # Format: sensor.span_panel_solar_production_instant_power (with device prefix) 154 | # Format: sensor.solar_production_instant_power (without device prefix) 155 | if use_device_prefix and device_name_raw: 156 | device_name = sanitize_name_for_entity_id(device_name_raw) 157 | return f"{platform}.{device_name}_{entity_name}_{suffix}" 158 | else: 159 | return f"{platform}.{entity_name}_{suffix}" 160 | 161 | 162 | def construct_solar_inverter_entity_id( 163 | coordinator: SpanPanelCoordinator, 164 | span_panel: SpanPanel, 165 | platform: str, 166 | inverter_leg1: int, 167 | inverter_leg2: int, 168 | suffix: str, 169 | friendly_name: str | None = None, 170 | ) -> str | None: 171 | """Construct solar inverter entity ID based on integration configuration flags. 172 | 173 | This is a convenience wrapper around construct_synthetic_entity_id for solar inverters. 174 | 175 | Args: 176 | coordinator: The coordinator instance 177 | span_panel: The span panel data 178 | platform: Platform name ("sensor") 179 | inverter_leg1: First circuit/leg number 180 | inverter_leg2: Second circuit/leg number 181 | suffix: Entity-specific suffix ("instant_power", "energy_produced", etc.) 182 | friendly_name: Optional friendly name for legacy installations (e.g., "Solar Production") 183 | 184 | Returns: 185 | Constructed entity ID string or None if device info unavailable 186 | 187 | """ 188 | # Convert solar inverter legs to circuit numbers list 189 | circuit_numbers = [inverter_leg1] 190 | if inverter_leg2 > 0: 191 | circuit_numbers.append(inverter_leg2) 192 | 193 | return construct_synthetic_entity_id( 194 | coordinator=coordinator, 195 | span_panel=span_panel, 196 | platform=platform, 197 | circuit_numbers=circuit_numbers, 198 | suffix=suffix, 199 | friendly_name=friendly_name, 200 | ) 201 | 202 | 203 | def construct_synthetic_friendly_name( 204 | circuit_numbers: list[int], 205 | suffix_description: str, 206 | user_friendly_name: str | None = None, 207 | ) -> str: 208 | """Construct friendly display name for synthetic sensors. 209 | 210 | Args: 211 | circuit_numbers: List of circuit numbers (e.g., [30, 32] for solar inverter) 212 | suffix_description: Human-readable suffix (e.g., "Instant Power", "Energy Produced") 213 | user_friendly_name: Optional user-provided name (e.g., "Solar Production") 214 | 215 | Returns: 216 | Friendly name for display in Home Assistant 217 | 218 | """ 219 | if user_friendly_name: 220 | # User provided a custom name - use it with the suffix 221 | return f"{user_friendly_name} {suffix_description}" 222 | 223 | # Fallback to circuit-based name 224 | valid_circuits = [str(num) for num in circuit_numbers if num > 0] 225 | if len(valid_circuits) > 1: 226 | circuit_spec = "-".join(valid_circuits) 227 | return f"Circuit {circuit_spec} {suffix_description}" 228 | elif len(valid_circuits) == 1: 229 | return f"Circuit {valid_circuits[0]} {suffix_description}" 230 | else: 231 | return f"Unknown Circuit {suffix_description}" 232 | -------------------------------------------------------------------------------- /custom_components/span_panel/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "span_panel", 3 | "name": "Span Panel", 4 | "codeowners": ["@SpanPanel"], 5 | "config_flow": true, 6 | "documentation": "https://github.com/SpanPanel/span", 7 | "iot_class": "local_polling", 8 | "issue_tracker": "https://github.com/SpanPanel/span/issues", 9 | "requirements": [], 10 | "version": "1.0.10", 11 | "zeroconf": [ 12 | { 13 | "type": "_span._tcp.local." 14 | } 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /custom_components/span_panel/options.py: -------------------------------------------------------------------------------- 1 | """Option configurations.""" 2 | 3 | from typing import Any 4 | 5 | from homeassistant.config_entries import ConfigEntry 6 | 7 | INVERTER_ENABLE = "enable_solar_circuit" 8 | INVERTER_LEG1 = "leg1" 9 | INVERTER_LEG2 = "leg2" 10 | INVERTER_MAXLEG = 32 11 | BATTERY_ENABLE = "enable_battery_percentage" 12 | 13 | 14 | class Options: 15 | """Class representing the options like the solar inverter.""" 16 | 17 | # pylint: disable=R0903 18 | 19 | def __init__(self, entry: ConfigEntry) -> None: 20 | """Initialize the options.""" 21 | self.enable_solar_sensors: bool = entry.options.get(INVERTER_ENABLE, False) 22 | self.inverter_leg1: int = entry.options.get(INVERTER_LEG1, 0) 23 | self.inverter_leg2: int = entry.options.get(INVERTER_LEG2, 0) 24 | self.enable_battery_percentage: bool = entry.options.get(BATTERY_ENABLE, False) 25 | 26 | def get_options(self) -> dict[str, Any]: 27 | """Return the current options as a dictionary.""" 28 | return { 29 | INVERTER_ENABLE: self.enable_solar_sensors, 30 | INVERTER_LEG1: self.inverter_leg1, 31 | INVERTER_LEG2: self.inverter_leg2, 32 | BATTERY_ENABLE: self.enable_battery_percentage, 33 | } 34 | -------------------------------------------------------------------------------- /custom_components/span_panel/select.py: -------------------------------------------------------------------------------- 1 | """Select entity for the Span Panel.""" 2 | 3 | from collections.abc import Callable 4 | import logging 5 | from typing import Any, Final 6 | 7 | from homeassistant.components.persistent_notification import async_create 8 | from homeassistant.components.select import SelectEntity, SelectEntityDescription 9 | from homeassistant.config_entries import ConfigEntry 10 | from homeassistant.core import HomeAssistant 11 | from homeassistant.exceptions import ServiceNotFound 12 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 13 | from homeassistant.helpers.update_coordinator import CoordinatorEntity 14 | import httpx 15 | 16 | from .const import COORDINATOR, DOMAIN, CircuitPriority 17 | from .coordinator import SpanPanelCoordinator 18 | from .helpers import construct_entity_id, get_user_friendly_suffix 19 | from .span_panel import SpanPanel 20 | from .span_panel_circuit import SpanPanelCircuit 21 | from .util import panel_to_device_info 22 | 23 | ICON = "mdi:chevron-down" 24 | 25 | _LOGGER = logging.getLogger(__name__) 26 | 27 | 28 | class SpanPanelSelectEntityDescriptionWrapper: 29 | """Wrapper class for Span Panel Select entities.""" 30 | 31 | # The wrapper is required because the SelectEntityDescription is frozen 32 | # and we need to pass in the entity_description to the constructor 33 | # Using keyword arguments gives a warning about unexpected arguments 34 | # pylint: disable=R0903 35 | 36 | def __init__( 37 | self, 38 | key: str, 39 | name: str, 40 | icon: str, 41 | options_fn: Callable[[SpanPanelCircuit], list[str]] = lambda _: [], 42 | current_option_fn: Callable[[SpanPanelCircuit], str | None] = lambda _: None, 43 | select_option_fn: Callable[[SpanPanelCircuit, str], None] | None = None, 44 | ) -> None: 45 | """Initialize the select entity description wrapper.""" 46 | self.entity_description = SelectEntityDescription(key=key, name=name, icon=icon) 47 | self.options_fn = options_fn 48 | self.current_option_fn = current_option_fn 49 | self.select_option_fn = select_option_fn 50 | 51 | 52 | CIRCUIT_PRIORITY_DESCRIPTION: Final = SpanPanelSelectEntityDescriptionWrapper( 53 | key="circuit_priority", 54 | name="Circuit Priority", 55 | icon=ICON, 56 | options_fn=lambda _: [ 57 | e.value for e in CircuitPriority if e != CircuitPriority.UNKNOWN 58 | ], 59 | current_option_fn=lambda circuit: CircuitPriority[circuit.priority].value, 60 | ) 61 | 62 | 63 | class SpanPanelCircuitsSelect(CoordinatorEntity[SpanPanelCoordinator], SelectEntity): 64 | """Represent a select entity for Span Panel circuits.""" 65 | 66 | _attr_has_entity_name = True 67 | 68 | def __init__( 69 | self, 70 | coordinator: SpanPanelCoordinator, 71 | description: SpanPanelSelectEntityDescriptionWrapper, 72 | circuit_id: str, 73 | name: str, 74 | ) -> None: 75 | """Initialize the select.""" 76 | super().__init__(coordinator) 77 | span_panel: SpanPanel = coordinator.data 78 | 79 | # Get the circuit from the span_panel to access its properties 80 | circuit = span_panel.circuits.get(circuit_id) 81 | if not circuit: 82 | raise ValueError(f"Circuit {circuit_id} not found") 83 | 84 | # Get the circuit number (tab position) 85 | circuit_number = circuit.tabs[0] if circuit.tabs else circuit_id 86 | 87 | self.entity_description = description.entity_description 88 | self.description_wrapper = ( 89 | description # Keep reference to wrapper for custom functions 90 | ) 91 | self.id = circuit_id 92 | 93 | self._attr_unique_id = ( 94 | f"span_{span_panel.status.serial_number}_select_{self.id}" 95 | ) 96 | self._attr_device_info = panel_to_device_info(span_panel) 97 | 98 | entity_suffix = get_user_friendly_suffix(description.entity_description.key) 99 | self.entity_id = construct_entity_id( # type: ignore[assignment] 100 | coordinator, span_panel, "select", name, circuit_number, entity_suffix 101 | ) 102 | 103 | friendly_name = f"{name} {description.entity_description.name}" 104 | 105 | self._attr_name = friendly_name 106 | 107 | circuit = self._get_circuit() 108 | self._attr_options = description.options_fn(circuit) 109 | self._attr_current_option = description.current_option_fn(circuit) 110 | 111 | _LOGGER.debug( 112 | "CREATE SELECT %s with options: %s", self._attr_name, self._attr_options 113 | ) 114 | 115 | # Store initial circuit name for change detection in auto-sync of names 116 | self._previous_circuit_name = name 117 | 118 | def _get_circuit(self) -> SpanPanelCircuit: 119 | """Get the circuit for this entity.""" 120 | circuit = self.coordinator.data.circuits[self.id] 121 | if not isinstance(circuit, SpanPanelCircuit): 122 | raise TypeError(f"Expected SpanPanelCircuit, got {type(circuit)}") 123 | return circuit 124 | 125 | async def async_select_option(self, option: str) -> None: 126 | """Change the selected option.""" 127 | _LOGGER.debug("Selecting option: %s", option) 128 | span_panel: SpanPanel = self.coordinator.data 129 | priority = CircuitPriority(option) 130 | curr_circuit = self._get_circuit() 131 | 132 | try: 133 | await span_panel.api.set_priority(curr_circuit, priority) 134 | await self.coordinator.async_request_refresh() 135 | except ServiceNotFound as snf: 136 | _LOGGER.warning("Service not found when setting priority: %s", snf) 137 | async_create( 138 | self.hass, 139 | message="The requested service is not available in the SPAN API.", 140 | title="Service Not Found", 141 | notification_id=f"span_panel_service_not_found_{self.id}", 142 | ) 143 | except httpx.HTTPStatusError: 144 | warning_msg = ( 145 | f"SPAN API returned an HTTP Status Error attempting " 146 | f"to change the circuit priority for {self._attr_name}. " 147 | f"This typically indicates panel firmware doesn't support " 148 | f"this operation." 149 | ) 150 | _LOGGER.warning("SPAN API may not support setting priority") 151 | async_create( 152 | self.hass, 153 | message=warning_msg, 154 | title="SPAN API Error", 155 | notification_id=f"span_panel_api_error_{self.id}", 156 | ) 157 | 158 | def select_option(self, option: str) -> None: 159 | """Select an option synchronously.""" 160 | _LOGGER.debug("Selecting option synchronously: %s", option) 161 | self.hass.async_add_executor_job(self.async_select_option, option) 162 | 163 | def _handle_coordinator_update(self) -> None: 164 | """Handle updated data from the coordinator.""" 165 | 166 | span_panel: SpanPanel = self.coordinator.data 167 | circuit = span_panel.circuits.get(self.id) 168 | if circuit: 169 | current_circuit_name = circuit.name 170 | 171 | # Only request reload if the circuit name has actually changed 172 | if current_circuit_name != self._previous_circuit_name: 173 | _LOGGER.info( 174 | "Auto-sync detected circuit name change from '%s' to '%s' for select, requesting integration reload", 175 | self._previous_circuit_name, 176 | current_circuit_name, 177 | ) 178 | 179 | # Update stored previous name for next comparison 180 | self._previous_circuit_name = current_circuit_name 181 | 182 | # Request integration reload for next update cycle 183 | self.coordinator.request_reload() 184 | 185 | # Update options and current option based on coordinator data 186 | circuit = self._get_circuit() 187 | self._attr_options = self.description_wrapper.options_fn(circuit) 188 | self._attr_current_option = self.description_wrapper.current_option_fn(circuit) 189 | super()._handle_coordinator_update() 190 | 191 | 192 | async def async_setup_entry( 193 | hass: HomeAssistant, 194 | config_entry: ConfigEntry, 195 | async_add_entities: AddEntitiesCallback, 196 | ) -> None: 197 | """Set up select entities for Span Panel.""" 198 | 199 | _LOGGER.debug("ASYNC SETUP ENTRY SELECT") 200 | data: dict[str, Any] = hass.data[DOMAIN][config_entry.entry_id] 201 | 202 | coordinator: SpanPanelCoordinator = data[COORDINATOR] 203 | span_panel: SpanPanel = coordinator.data 204 | 205 | entities: list[SpanPanelCircuitsSelect] = [] 206 | 207 | for circuit_id, circuit_data in span_panel.circuits.items(): 208 | if circuit_data.is_user_controllable: 209 | entities.append( 210 | SpanPanelCircuitsSelect( 211 | coordinator, 212 | CIRCUIT_PRIORITY_DESCRIPTION, 213 | circuit_id, 214 | circuit_data.name, 215 | ) 216 | ) 217 | 218 | async_add_entities(entities) 219 | -------------------------------------------------------------------------------- /custom_components/span_panel/span_panel.py: -------------------------------------------------------------------------------- 1 | """Module to read production and consumption values from a Span panel.""" 2 | 3 | import logging 4 | 5 | import httpx 6 | 7 | from .exceptions import SpanPanelReturnedEmptyData 8 | from .options import Options 9 | from .span_panel_api import SpanPanelApi 10 | from .span_panel_circuit import SpanPanelCircuit 11 | from .span_panel_data import SpanPanelData 12 | from .span_panel_hardware_status import SpanPanelHardwareStatus 13 | from .span_panel_storage_battery import SpanPanelStorageBattery 14 | 15 | STATUS_URL = "http://{}/api/v1/status" 16 | SPACES_URL = "http://{}/api/v1/spaces" 17 | CIRCUITS_URL = "http://{}/api/v1/circuits" 18 | PANEL_URL = "http://{}/api/v1/panel" 19 | REGISTER_URL = "http://{}/api/v1/auth/register" 20 | STORAGE_BATTERY_URL = "http://{}/api/v1/storage/soe" 21 | 22 | _LOGGER: logging.Logger = logging.getLogger(__name__) 23 | 24 | SPAN_CIRCUITS = "circuits" 25 | SPAN_SYSTEM = "system" 26 | PANEL_POWER = "instantGridPowerW" 27 | SYSTEM_DOOR_STATE = "doorState" 28 | SYSTEM_DOOR_STATE_CLOSED = "CLOSED" 29 | SYSTEM_DOOR_STATE_OPEN = "OPEN" 30 | SYSTEM_ETHERNET_LINK = "eth0Link" 31 | SYSTEM_CELLULAR_LINK = "wwanLink" 32 | SYSTEM_WIFI_LINK = "wlanLink" 33 | 34 | 35 | class SpanPanel: 36 | """Class to manage the Span Panel.""" 37 | 38 | def __init__( 39 | self, 40 | host: str, 41 | access_token: str | None = None, # nosec 42 | options: Options | None = None, 43 | async_client: httpx.AsyncClient | None = None, 44 | ) -> None: 45 | """Initialize the Span Panel.""" 46 | self._options = options 47 | self.api = SpanPanelApi(host, access_token, options, async_client) 48 | self._status: SpanPanelHardwareStatus | None = None 49 | self._panel: SpanPanelData | None = None 50 | self._circuits: dict[str, SpanPanelCircuit] = {} 51 | self._storage_battery: SpanPanelStorageBattery | None = None 52 | 53 | def _get_hardware_status(self) -> SpanPanelHardwareStatus: 54 | """Get hardware status with type checking.""" 55 | if self._status is None: 56 | raise RuntimeError("Hardware status not available") 57 | return self._status 58 | 59 | def _get_data(self) -> SpanPanelData: 60 | """Get data with type checking.""" 61 | if self._panel is None: 62 | raise RuntimeError("Panel data not available") 63 | return self._panel 64 | 65 | def _get_storage_battery(self) -> SpanPanelStorageBattery: 66 | """Get storage battery with type checking.""" 67 | if self._storage_battery is None: 68 | raise RuntimeError("Storage battery not available") 69 | return self._storage_battery 70 | 71 | @property 72 | def host(self) -> str: 73 | """Return the host of the panel.""" 74 | return self.api.host 75 | 76 | @property 77 | def options(self) -> Options | None: 78 | """Get options data atomically.""" 79 | return self._options 80 | 81 | def _update_status(self, new_status: SpanPanelHardwareStatus) -> None: 82 | """Atomic update of status data.""" 83 | self._status = new_status 84 | 85 | def _update_panel(self, new_panel: SpanPanelData) -> None: 86 | """Atomic update of panel data.""" 87 | self._panel = new_panel 88 | 89 | def _update_circuits(self, new_circuits: dict[str, SpanPanelCircuit]) -> None: 90 | """Atomic update of circuits data.""" 91 | self._circuits = new_circuits 92 | 93 | def _update_storage_battery(self, new_battery: SpanPanelStorageBattery) -> None: 94 | """Atomic update of storage battery data.""" 95 | self._storage_battery = new_battery 96 | 97 | async def update(self) -> None: 98 | """Update all panel data atomically.""" 99 | try: 100 | _LOGGER.debug("Starting panel update") 101 | # Get new data 102 | new_status = await self.api.get_status_data() 103 | _LOGGER.debug("Got status data: %s", new_status) 104 | new_panel = await self.api.get_panel_data() 105 | _LOGGER.debug("Got panel data: %s", new_panel) 106 | new_circuits = await self.api.get_circuits_data() 107 | _LOGGER.debug("Got circuits data: %s", new_circuits) 108 | 109 | # Atomic updates 110 | self._update_status(new_status) 111 | self._update_panel(new_panel) 112 | self._update_circuits(new_circuits) 113 | 114 | if self._options and self._options.enable_battery_percentage: 115 | new_battery = await self.api.get_storage_battery_data() 116 | _LOGGER.debug("Got battery data: %s", new_battery) 117 | self._update_storage_battery(new_battery) 118 | 119 | _LOGGER.debug("Panel update completed successfully") 120 | except SpanPanelReturnedEmptyData: 121 | _LOGGER.warning("Span Panel returned empty data") 122 | except Exception as err: 123 | _LOGGER.error("Error updating panel: %s", err, exc_info=True) 124 | raise 125 | 126 | @property 127 | def status(self) -> SpanPanelHardwareStatus: 128 | """Get status data atomically.""" 129 | return self._get_hardware_status() 130 | 131 | @property 132 | def panel(self) -> SpanPanelData: 133 | """Get panel data atomically.""" 134 | return self._get_data() 135 | 136 | @property 137 | def circuits(self) -> dict[str, SpanPanelCircuit]: 138 | """Get circuits data atomically.""" 139 | return self._circuits 140 | 141 | @property 142 | def storage_battery(self) -> SpanPanelStorageBattery: 143 | """Get storage battery data atomically.""" 144 | return self._get_storage_battery() 145 | -------------------------------------------------------------------------------- /custom_components/span_panel/span_panel_api.py: -------------------------------------------------------------------------------- 1 | """Span Panel API.""" 2 | 3 | import asyncio 4 | from copy import deepcopy 5 | import logging 6 | from typing import Any 7 | import uuid 8 | 9 | import httpx 10 | 11 | from .const import ( 12 | API_TIMEOUT, 13 | PANEL_MAIN_RELAY_STATE_UNKNOWN_VALUE, 14 | SPAN_CIRCUITS, 15 | SPAN_SOE, 16 | URL_CIRCUITS, 17 | URL_PANEL, 18 | URL_REGISTER, 19 | URL_STATUS, 20 | URL_STORAGE_BATTERY, 21 | CircuitPriority, 22 | CircuitRelayState, 23 | ) 24 | from .exceptions import SpanPanelReturnedEmptyData 25 | from .options import Options 26 | from .span_panel_circuit import SpanPanelCircuit 27 | from .span_panel_data import SpanPanelData 28 | from .span_panel_hardware_status import SpanPanelHardwareStatus 29 | from .span_panel_storage_battery import SpanPanelStorageBattery 30 | 31 | _LOGGER: logging.Logger = logging.getLogger(__name__) 32 | 33 | 34 | class SpanPanelApi: 35 | """Span Panel API.""" 36 | 37 | def __init__( 38 | self, 39 | host: str, 40 | access_token: str | None = None, # nosec 41 | options: Options | None = None, 42 | async_client: httpx.AsyncClient | None = None, 43 | ) -> None: 44 | """Initialize the Span Panel API.""" 45 | self.host: str = host.lower() 46 | self.access_token: str | None = access_token 47 | self.options: Options | None = options 48 | self._async_client: Any | None = async_client 49 | 50 | @property 51 | def async_client(self) -> Any | Any: 52 | """Return the httpx.AsyncClient.""" 53 | 54 | return self._async_client or httpx.AsyncClient(verify=True, timeout=API_TIMEOUT) 55 | 56 | async def ping(self) -> bool: 57 | """Ping the Span Panel API.""" 58 | 59 | # status endpoint doesn't require auth. 60 | try: 61 | await self.get_status_data() 62 | return True 63 | except httpx.HTTPError: 64 | return False 65 | 66 | async def ping_with_auth(self) -> bool: 67 | """Test connection and authentication.""" 68 | try: 69 | # Use get_panel_data() since it requires authentication 70 | await self.get_panel_data() 71 | return True 72 | except httpx.HTTPStatusError as err: 73 | if err.response.status_code == httpx.codes.UNAUTHORIZED: 74 | return False 75 | raise 76 | except (httpx.TransportError, SpanPanelReturnedEmptyData): 77 | return False 78 | 79 | async def get_access_token(self) -> str: 80 | """Get the access token.""" 81 | register_results = await self.post_data( 82 | URL_REGISTER, 83 | { 84 | "name": f"home-assistant-{uuid.uuid4()}", 85 | "description": "Home Assistant Local Span Integration", 86 | }, 87 | ) 88 | response_data: dict[str, str] = register_results.json() 89 | if "accessToken" not in response_data: 90 | raise SpanPanelReturnedEmptyData("No access token in response") 91 | return response_data["accessToken"] 92 | 93 | async def get_status_data(self) -> SpanPanelHardwareStatus: 94 | """Get the status data.""" 95 | response: httpx.Response = await self.get_data(URL_STATUS) 96 | status_data: SpanPanelHardwareStatus = SpanPanelHardwareStatus.from_dict( 97 | response.json() 98 | ) 99 | return status_data 100 | 101 | async def get_panel_data(self) -> SpanPanelData: 102 | """Get the panel data.""" 103 | response: httpx.Response = await self.get_data(URL_PANEL) 104 | # Deep copy the raw data before processing in case cached data cleaned up 105 | raw_data: Any = deepcopy(response.json()) 106 | panel_data: SpanPanelData = SpanPanelData.from_dict(raw_data, self.options) 107 | 108 | # Span Panel API might return empty result. 109 | # We use relay state == UNKNOWN as an indication of that scenario. 110 | if panel_data.main_relay_state == PANEL_MAIN_RELAY_STATE_UNKNOWN_VALUE: 111 | raise SpanPanelReturnedEmptyData() 112 | 113 | return panel_data 114 | 115 | async def get_circuits_data(self) -> dict[str, SpanPanelCircuit]: 116 | """Get the circuits data.""" 117 | response: httpx.Response = await self.get_data(URL_CIRCUITS) 118 | raw_circuits_data: Any = deepcopy(response.json()[SPAN_CIRCUITS]) 119 | 120 | if not raw_circuits_data: 121 | raise SpanPanelReturnedEmptyData() 122 | 123 | circuits_data: dict[str, SpanPanelCircuit] = {} 124 | for circuit_id, raw_circuit_data in raw_circuits_data.items(): 125 | circuits_data[circuit_id] = SpanPanelCircuit.from_dict(raw_circuit_data) 126 | return circuits_data 127 | 128 | async def get_storage_battery_data(self) -> SpanPanelStorageBattery: 129 | """Get the storage battery data.""" 130 | response: httpx.Response = await self.get_data(URL_STORAGE_BATTERY) 131 | storage_battery_data: Any = response.json()[SPAN_SOE] 132 | 133 | # Span Panel API might return empty result. 134 | # We use relay state == UNKNOWN as an indication of that scenario. 135 | if not storage_battery_data: 136 | raise SpanPanelReturnedEmptyData() 137 | 138 | return SpanPanelStorageBattery.from_dic(storage_battery_data) 139 | 140 | async def set_relay( 141 | self, circuit: SpanPanelCircuit, state: CircuitRelayState 142 | ) -> None: 143 | """Set the relay state.""" 144 | await self.post_data( 145 | f"{URL_CIRCUITS}/{circuit.circuit_id}", 146 | {"relayStateIn": {"relayState": state.name}}, 147 | ) 148 | 149 | async def set_priority( 150 | self, circuit: SpanPanelCircuit, priority: CircuitPriority 151 | ) -> None: 152 | """Set the priority.""" 153 | await self.post_data( 154 | f"{URL_CIRCUITS}/{circuit.circuit_id}", 155 | {"priorityIn": {"priority": priority.name}}, 156 | ) 157 | 158 | async def get_data(self, url: str) -> httpx.Response: 159 | """Fetch data from the endpoint.""" 160 | 161 | formatted_url: str = url.format(self.host) 162 | response: httpx.Response = await self._async_fetch_with_retry( 163 | formatted_url, follow_redirects=False 164 | ) 165 | return response 166 | 167 | async def post_data(self, url: str, payload: dict[str, Any]) -> httpx.Response: 168 | """Post data to the endpoint.""" 169 | formatted_url: str = url.format(self.host) 170 | response: httpx.Response = await self._async_post(formatted_url, payload) 171 | return response 172 | 173 | async def _async_fetch_with_retry(self, url: str, **kwargs: Any) -> httpx.Response: 174 | """Retry 3 times if there is a transport error or certain HTTP errors.""" 175 | headers: dict[str, str] = {"Accept": "application/json"} 176 | if self.access_token: 177 | headers["Authorization"] = f"Bearer {self.access_token}" 178 | 179 | # HTTP status codes that are worth retrying (typically transient issues) 180 | retry_status_codes = {502, 503, 504, 429} 181 | 182 | last_exception: Exception | None = None 183 | for attempt in range(3): 184 | _LOGGER.debug("HTTP GET Attempt #%s: %s", attempt + 1, url) 185 | try: 186 | async with self.async_client as client: 187 | resp: httpx.Response = await client.get( 188 | url, timeout=API_TIMEOUT, headers=headers, **kwargs 189 | ) 190 | 191 | # Only retry specific HTTP status codes that are typically transient 192 | if resp.status_code in retry_status_codes and attempt < 2: 193 | _LOGGER.debug( 194 | "Received status %s for %s, retrying (attempt %s of 3)", 195 | resp.status_code, 196 | url, 197 | attempt + 1, 198 | ) 199 | # Add exponential backoff delay between retries (0.5s, then 1s) 200 | await asyncio.sleep(0.5 * (2**attempt)) 201 | continue 202 | 203 | # For all other status codes, raise immediately 204 | resp.raise_for_status() 205 | _LOGGER.debug("Fetched from %s: %s: %s", url, resp, resp.text) 206 | return resp 207 | 208 | except httpx.HTTPStatusError as exc: 209 | if exc.response.status_code in retry_status_codes and attempt < 2: 210 | _LOGGER.debug( 211 | "HTTP error %s for %s, retrying (attempt %s of 3)", 212 | exc.response.status_code, 213 | url, 214 | attempt + 1, 215 | ) 216 | # Add exponential backoff delay between retries 217 | await asyncio.sleep(0.5 * (2**attempt)) 218 | last_exception = exc 219 | continue 220 | raise 221 | 222 | except httpx.TransportError as exc: 223 | if attempt < 2: 224 | _LOGGER.debug( 225 | "Transport error for %s, retrying (attempt %s of 3): %s", 226 | url, 227 | attempt + 1, 228 | str(exc), 229 | ) 230 | # Add exponential backoff delay between retries 231 | await asyncio.sleep(0.5 * (2**attempt)) 232 | last_exception = exc 233 | continue 234 | raise 235 | 236 | # If we get here, we've exhausted all retries 237 | if last_exception: 238 | raise last_exception 239 | raise httpx.TransportError("Too many attempts") 240 | 241 | async def _async_post( 242 | self, url: str, json: dict[str, Any] | None = None, **kwargs: Any 243 | ) -> httpx.Response: 244 | """POST to the url.""" 245 | headers: dict[str, str] = {"accept": "application/json"} 246 | if self.access_token: 247 | headers["Authorization"] = f"Bearer {self.access_token}" 248 | 249 | _LOGGER.debug("HTTP POST Attempt: %s", url) 250 | async with self.async_client as client: 251 | resp: httpx.Response = await client.post( 252 | url, json=json, headers=headers, timeout=API_TIMEOUT, **kwargs 253 | ) 254 | resp.raise_for_status() 255 | _LOGGER.debug("HTTP POST %s: %s: %s", url, resp, resp.text) 256 | return resp 257 | -------------------------------------------------------------------------------- /custom_components/span_panel/span_panel_circuit.py: -------------------------------------------------------------------------------- 1 | """Data models for Span Panel circuit information.""" 2 | 3 | from copy import deepcopy 4 | from dataclasses import dataclass, field 5 | from typing import Any 6 | 7 | from .const import CircuitRelayState 8 | 9 | 10 | @dataclass 11 | class SpanPanelCircuit: 12 | """Class representing a Span Panel circuit.""" 13 | 14 | circuit_id: str 15 | name: str 16 | relay_state: str 17 | instant_power: float 18 | instant_power_update_time: int 19 | produced_energy: float 20 | consumed_energy: float 21 | energy_accum_update_time: int 22 | tabs: list[int] 23 | priority: str 24 | is_user_controllable: bool 25 | is_sheddable: bool 26 | is_never_backup: bool 27 | breaker_positions: list[int] = field(default_factory=list) 28 | metadata: dict[str, Any] = field(default_factory=dict) 29 | circuit_config: dict[str, Any] = field(default_factory=dict) 30 | state_config: dict[str, Any] = field(default_factory=dict) 31 | raw_data: dict[str, Any] = field(default_factory=dict) 32 | 33 | @property 34 | def is_relay_closed(self) -> bool: 35 | """Return True if the relay is in closed state.""" 36 | return self.relay_state == CircuitRelayState.CLOSED.name 37 | 38 | @staticmethod 39 | def from_dict(data: dict[str, Any]) -> "SpanPanelCircuit": 40 | """Create a SpanPanelCircuit instance from a dictionary. 41 | 42 | Args: 43 | data: Dictionary containing circuit data from the Span Panel API. 44 | 45 | Returns: 46 | A new SpanPanelCircuit instance. 47 | 48 | """ 49 | data_copy: dict[str, Any] = deepcopy(data) 50 | return SpanPanelCircuit( 51 | circuit_id=data_copy["id"], 52 | name=data_copy["name"], 53 | relay_state=data_copy["relayState"], 54 | instant_power=data_copy["instantPowerW"], 55 | instant_power_update_time=data_copy["instantPowerUpdateTimeS"], 56 | produced_energy=data_copy["producedEnergyWh"], 57 | consumed_energy=data_copy["consumedEnergyWh"], 58 | energy_accum_update_time=data_copy["energyAccumUpdateTimeS"], 59 | tabs=data_copy["tabs"], 60 | priority=data_copy["priority"], 61 | is_user_controllable=data_copy["isUserControllable"], 62 | is_sheddable=data_copy["isSheddable"], 63 | is_never_backup=data_copy["isNeverBackup"], 64 | circuit_config=data_copy.get("config", {}), 65 | state_config=data_copy.get("state", {}), 66 | raw_data=data_copy, 67 | ) 68 | 69 | def copy(self) -> "SpanPanelCircuit": 70 | """Create a deep copy for atomic operations.""" 71 | # Circuit contains nested mutable objects, use deepcopy 72 | return deepcopy(self) 73 | -------------------------------------------------------------------------------- /custom_components/span_panel/span_panel_data.py: -------------------------------------------------------------------------------- 1 | """Span Panel Data.""" 2 | 3 | from copy import deepcopy 4 | from dataclasses import dataclass, field 5 | from typing import Any 6 | 7 | from .options import INVERTER_MAXLEG, Options 8 | 9 | 10 | @dataclass 11 | class SpanPanelData: 12 | """Class representing the data from the Span Panel.""" 13 | 14 | main_relay_state: str 15 | main_meter_energy_produced: float 16 | main_meter_energy_consumed: float 17 | instant_grid_power: float 18 | feedthrough_power: float 19 | feedthrough_energy_produced: float 20 | feedthrough_energy_consumed: float 21 | grid_sample_start_ms: int 22 | grid_sample_end_ms: int 23 | dsm_grid_state: str 24 | dsm_state: str 25 | current_run_config: str 26 | solar_inverter_instant_power: float 27 | solar_inverter_energy_produced: float 28 | solar_inverter_energy_consumed: float 29 | main_meter_energy: dict[str, Any] = field(default_factory=dict) 30 | feedthrough_energy: dict[str, Any] = field(default_factory=dict) 31 | solar_data: dict[str, Any] = field(default_factory=dict) 32 | inverter_data: dict[str, Any] = field(default_factory=dict) 33 | relay_states: dict[str, Any] = field(default_factory=dict) 34 | solar_inverter_data: dict[str, Any] = field(default_factory=dict) 35 | state_data: dict[str, Any] = field(default_factory=dict) 36 | raw_data: dict[str, Any] = field(default_factory=dict) 37 | 38 | @classmethod 39 | def from_dict( 40 | cls, data: dict[str, Any], options: Options | None = None 41 | ) -> "SpanPanelData": 42 | """Create instance from dict with deep copy of input data.""" 43 | data = deepcopy(data) 44 | common_data: dict[str, Any] = { 45 | "main_relay_state": str(data["mainRelayState"]), 46 | "main_meter_energy_produced": float( 47 | data["mainMeterEnergy"]["producedEnergyWh"] 48 | ), 49 | "main_meter_energy_consumed": float( 50 | data["mainMeterEnergy"]["consumedEnergyWh"] 51 | ), 52 | "instant_grid_power": float(data["instantGridPowerW"]), 53 | "feedthrough_power": float(data["feedthroughPowerW"]), 54 | "feedthrough_energy_produced": float( 55 | data["feedthroughEnergy"]["producedEnergyWh"] 56 | ), 57 | "feedthrough_energy_consumed": float( 58 | data["feedthroughEnergy"]["consumedEnergyWh"] 59 | ), 60 | "grid_sample_start_ms": int(data["gridSampleStartMs"]), 61 | "grid_sample_end_ms": int(data["gridSampleEndMs"]), 62 | "dsm_grid_state": str(data["dsmGridState"]), 63 | "dsm_state": str(data["dsmState"]), 64 | "current_run_config": str(data["currentRunConfig"]), 65 | "solar_inverter_instant_power": 0.0, 66 | "solar_inverter_energy_produced": 0.0, 67 | "solar_inverter_energy_consumed": 0.0, 68 | "main_meter_energy": data.get("mainMeterEnergy", {}), 69 | "feedthrough_energy": data.get("feedthroughEnergy", {}), 70 | "solar_inverter_data": data.get("solarInverter", {}), 71 | "state_data": data.get("state", {}), 72 | "raw_data": data, 73 | } 74 | 75 | if options and options.enable_solar_sensors: 76 | for leg in [options.inverter_leg1, options.inverter_leg2]: 77 | if 1 <= leg <= INVERTER_MAXLEG: 78 | branch = data["branches"][leg - 1] 79 | common_data["solar_inverter_instant_power"] += float( 80 | branch["instantPowerW"] 81 | ) 82 | common_data["solar_inverter_energy_produced"] += float( 83 | branch["importedActiveEnergyWh"] 84 | ) 85 | common_data["solar_inverter_energy_consumed"] += float( 86 | branch["exportedActiveEnergyWh"] 87 | ) 88 | 89 | return cls(**common_data) 90 | 91 | def copy(self) -> "SpanPanelData": 92 | """Create a deep copy for atomic operations.""" 93 | return deepcopy(self) 94 | -------------------------------------------------------------------------------- /custom_components/span_panel/span_panel_hardware_status.py: -------------------------------------------------------------------------------- 1 | """Span Panel Hardware Status.""" 2 | 3 | from copy import deepcopy 4 | from dataclasses import dataclass, field 5 | import logging 6 | from typing import Any 7 | 8 | from .const import SYSTEM_DOOR_STATE_CLOSED, SYSTEM_DOOR_STATE_OPEN 9 | 10 | _LOGGER = logging.getLogger(__name__) 11 | 12 | 13 | @dataclass 14 | class SpanPanelHardwareStatus: 15 | """Class representing the hardware status of the Span Panel.""" 16 | 17 | firmware_version: str 18 | update_status: str 19 | env: str 20 | manufacturer: str 21 | serial_number: str 22 | model: str 23 | door_state: str | None 24 | uptime: int 25 | is_ethernet_connected: bool 26 | is_wifi_connected: bool 27 | is_cellular_connected: bool 28 | proximity_proven: bool | None = None 29 | remaining_auth_unlock_button_presses: int = 0 30 | _system_data: dict[str, Any] = field(default_factory=dict) 31 | 32 | # Door state has been known to return UNKNOWN if the door has not been 33 | # operated recently Sensor is a tamper sensor not a door sensor 34 | @property 35 | def is_door_closed(self) -> bool | None: 36 | """Return whether the door is closed, or None if state is unknown.""" 37 | _LOGGER.debug("Door state raw value: %s", self.door_state) 38 | if self.door_state is None: 39 | _LOGGER.debug("Door state is None") 40 | return None 41 | if self.door_state not in (SYSTEM_DOOR_STATE_OPEN, SYSTEM_DOOR_STATE_CLOSED): 42 | _LOGGER.debug("Door state is not OPEN or CLOSED: %s", self.door_state) 43 | return None 44 | result = self.door_state == SYSTEM_DOOR_STATE_CLOSED 45 | _LOGGER.debug("is_door_closed returning: %s", result) 46 | return result 47 | 48 | @property 49 | def system_data(self) -> dict[str, Any]: 50 | """Return the system data.""" 51 | return deepcopy(self._system_data) 52 | 53 | @classmethod 54 | def from_dict(cls, data: dict[str, Any]) -> "SpanPanelHardwareStatus": 55 | """Create a new instance with deep copied data.""" 56 | data_copy = deepcopy(data) 57 | system_data = data_copy.get("system", {}) 58 | 59 | # Handle proximity authentication for both new and old firmware 60 | proximity_proven = None 61 | remaining_auth_unlock_button_presses = 0 62 | 63 | if "proximityProven" in system_data: 64 | # New firmware (r202342 and newer) 65 | proximity_proven = system_data["proximityProven"] 66 | else: 67 | # Old firmware (before r202342) 68 | remaining_auth_unlock_button_presses = system_data.get( 69 | "remainingAuthUnlockButtonPresses", 0 70 | ) 71 | 72 | return cls( 73 | firmware_version=data_copy["software"]["firmwareVersion"], 74 | update_status=data_copy["software"]["updateStatus"], 75 | env=data_copy["software"]["env"], 76 | manufacturer=data_copy["system"]["manufacturer"], 77 | serial_number=data_copy["system"]["serial"], 78 | model=data_copy["system"]["model"], 79 | door_state=data_copy["system"]["doorState"], 80 | uptime=data_copy["system"]["uptime"], 81 | is_ethernet_connected=data_copy["network"]["eth0Link"], 82 | is_wifi_connected=data_copy["network"]["wlanLink"], 83 | is_cellular_connected=data_copy["network"]["wwanLink"], 84 | proximity_proven=proximity_proven, 85 | remaining_auth_unlock_button_presses=remaining_auth_unlock_button_presses, 86 | _system_data=system_data, 87 | ) 88 | 89 | def copy(self) -> "SpanPanelHardwareStatus": 90 | """Create a deep copy of hardware status.""" 91 | return deepcopy(self) 92 | -------------------------------------------------------------------------------- /custom_components/span_panel/span_panel_storage_battery.py: -------------------------------------------------------------------------------- 1 | """span_panel_storage_battery.""" 2 | 3 | from dataclasses import dataclass, field 4 | from typing import Any 5 | 6 | 7 | @dataclass 8 | class SpanPanelStorageBattery: 9 | """Class to manage the storage battery data.""" 10 | 11 | storage_battery_percentage: int 12 | # Any nested mutable structures should use field with default_factory 13 | raw_data: dict[str, Any] = field(default_factory=dict) 14 | 15 | @staticmethod 16 | def from_dic(data: dict[str, Any]) -> "SpanPanelStorageBattery": 17 | """Read the data from the dictionary.""" 18 | return SpanPanelStorageBattery( 19 | storage_battery_percentage=data.get("percentage", 0) 20 | ) 21 | -------------------------------------------------------------------------------- /custom_components/span_panel/strings.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "no_devices_found": "No devices found on the network", 5 | "already_configured": "Span Panel already configured. Only a single configuration is possible.", 6 | "reauth_successful": "Authentication successful." 7 | }, 8 | "error": { 9 | "cannot_connect": "Failed to connect to Span Panel", 10 | "invalid_auth": "Invalid authentication", 11 | "unknown": "Unexpected error" 12 | }, 13 | "flow_title": "Span Panel ({host})", 14 | "step": { 15 | "confirm_discovery": { 16 | "description": "Do you want to setup Span Panel at {host}?", 17 | "title": "Connect to the Span Panel" 18 | }, 19 | "user": { 20 | "data": { 21 | "host": "Host" 22 | }, 23 | "title": "Connect to the Span Panel" 24 | }, 25 | "choose_auth_type": { 26 | "title": "Choose Authentication Options", 27 | "menu_options": { 28 | "auth_proximity": "Authenticate through your physical Span Panel", 29 | "auth_token": "Authenticate using an access token" 30 | } 31 | }, 32 | "auth_proximity": { 33 | "title": "Proximity Authentication", 34 | "description": "Please open and close the Span Panel door 3 times." 35 | }, 36 | "auth_token": { 37 | "title": "Manual Token Authentication", 38 | "description": "Please enter your access token (empty to start over):", 39 | "data": { 40 | "access_token": "Access Token" 41 | } 42 | } 43 | } 44 | }, 45 | "options": { 46 | "step": { 47 | "init": { 48 | "title": "Options Menu", 49 | "menu_options": { 50 | "general_options": "General Options", 51 | "entity_naming": "Entity Naming Pattern" 52 | } 53 | }, 54 | "general_options": { 55 | "title": "General Options", 56 | "data": { 57 | "scan_interval": "Scan interval in seconds", 58 | "enable_battery_percentage": "Enable Battery Percentage Sensor", 59 | "enable_solar_circuit": "Enable Solar Inverter Sensors", 60 | "leg1": "Solar Leg 1 (0 is not used)", 61 | "leg2": "Solar Leg 2 (0 is not used)" 62 | } 63 | }, 64 | "entity_naming": { 65 | "title": "Entity Naming Pattern", 66 | "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", 67 | "data": { 68 | "entity_naming_pattern": "Entity Naming Pattern" 69 | } 70 | } 71 | } 72 | }, 73 | "selector": { 74 | "entity_naming_pattern": { 75 | "options": { 76 | "friendly_names": "Friendly Names (e.g., span_panel_kitchen_outlets_power)", 77 | "circuit_numbers": "Circuit Numbers (e.g., span_panel_circuit_15_power)" 78 | } 79 | } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /custom_components/span_panel/switch.py: -------------------------------------------------------------------------------- 1 | """Control switches.""" 2 | 3 | import logging 4 | from typing import Any, Literal 5 | 6 | from homeassistant.components.switch import SwitchEntity 7 | from homeassistant.config_entries import ConfigEntry 8 | from homeassistant.core import HomeAssistant 9 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 10 | from homeassistant.helpers.update_coordinator import CoordinatorEntity 11 | 12 | from custom_components.span_panel.span_panel_circuit import SpanPanelCircuit 13 | 14 | from .const import COORDINATOR, DOMAIN, CircuitRelayState 15 | from .coordinator import SpanPanelCoordinator 16 | from .helpers import construct_entity_id 17 | from .span_panel import SpanPanel 18 | from .util import panel_to_device_info 19 | 20 | ICON: Literal["mdi:toggle-switch"] = "mdi:toggle-switch" 21 | 22 | _LOGGER: logging.Logger = logging.getLogger(__name__) 23 | 24 | 25 | class SpanPanelCircuitsSwitch(CoordinatorEntity[SpanPanelCoordinator], SwitchEntity): 26 | """Represent a switch entity.""" 27 | 28 | def __init__( 29 | self, coordinator: SpanPanelCoordinator, circuit_id: str, name: str 30 | ) -> None: 31 | """Initialize the values.""" 32 | _LOGGER.debug("CREATE SWITCH %s", name) 33 | span_panel: SpanPanel = coordinator.data 34 | 35 | circuit = span_panel.circuits.get(circuit_id) 36 | if not circuit: 37 | raise ValueError(f"Circuit {circuit_id} not found") 38 | 39 | # Get the actual circuit number (tab position) 40 | circuit_number = circuit.tabs[0] if circuit.tabs else circuit_id 41 | 42 | self.circuit_id: str = circuit_id 43 | self._attr_icon = "mdi:toggle-switch" 44 | self._attr_unique_id = ( 45 | f"span_{span_panel.status.serial_number}_relay_{circuit_id}" 46 | ) 47 | self._attr_device_info = panel_to_device_info(span_panel) 48 | 49 | self.entity_id = construct_entity_id( # type: ignore[assignment] 50 | coordinator, span_panel, "switch", name, circuit_number, "breaker" 51 | ) 52 | 53 | friendly_name = f"{name} Breaker" 54 | 55 | self._attr_name = friendly_name 56 | 57 | super().__init__(coordinator) 58 | 59 | self._update_is_on() 60 | 61 | # Store initial circuit name for change detection in auto-sync 62 | self._previous_circuit_name = name 63 | 64 | def _handle_coordinator_update(self) -> None: 65 | """Handle updated data from the coordinator.""" 66 | # Check for circuit name changes 67 | span_panel: SpanPanel = self.coordinator.data 68 | circuit = span_panel.circuits.get(self.circuit_id) 69 | if circuit: 70 | current_circuit_name = circuit.name 71 | 72 | # Only request reload if the circuit name has actually changed 73 | if current_circuit_name != self._previous_circuit_name: 74 | _LOGGER.info( 75 | "Auto-sync detected circuit name change from '%s' to '%s' for " 76 | "switch, requesting integration reload", 77 | self._previous_circuit_name, 78 | current_circuit_name, 79 | ) 80 | 81 | # Update stored previous name for next comparison 82 | self._previous_circuit_name = current_circuit_name 83 | 84 | # Request integration reload for next update cycle 85 | self.coordinator.request_reload() 86 | 87 | self._update_is_on() 88 | super()._handle_coordinator_update() 89 | 90 | def _update_is_on(self) -> None: 91 | """Update the is_on state based on the circuit state.""" 92 | span_panel: SpanPanel = self.coordinator.data 93 | # Get atomic snapshot of circuits data 94 | circuits: dict[str, SpanPanelCircuit] = span_panel.circuits 95 | circuit: SpanPanelCircuit | None = circuits.get(self.circuit_id) 96 | if circuit: 97 | # Use copy to ensure atomic state 98 | circuit = circuit.copy() 99 | self._attr_is_on = circuit.relay_state == CircuitRelayState.CLOSED.name 100 | else: 101 | self._attr_is_on = None 102 | 103 | def turn_on(self, **kwargs: Any) -> None: 104 | """Turn the switch on.""" 105 | 106 | self.hass.create_task(self.async_turn_on(**kwargs)) 107 | 108 | def turn_off(self, **kwargs: Any) -> None: 109 | """Turn the switch off.""" 110 | self.hass.create_task(self.async_turn_off(**kwargs)) 111 | 112 | async def async_turn_on(self, **kwargs: Any) -> None: 113 | """Turn the switch on.""" 114 | span_panel: SpanPanel = self.coordinator.data 115 | circuits: dict[str, SpanPanelCircuit] = ( 116 | span_panel.circuits 117 | ) # Get atomic snapshot of circuits 118 | if self.circuit_id in circuits: 119 | # Create a copy of the circuit for the operation 120 | curr_circuit: SpanPanelCircuit = circuits[self.circuit_id].copy() 121 | # Perform the state change 122 | await span_panel.api.set_relay(curr_circuit, CircuitRelayState.CLOSED) 123 | # Request refresh to get the new state 124 | await self.coordinator.async_request_refresh() 125 | 126 | async def async_turn_off(self, **kwargs: Any) -> None: 127 | """Turn the switch off.""" 128 | span_panel: SpanPanel = self.coordinator.data 129 | circuits: dict[str, SpanPanelCircuit] = ( 130 | span_panel.circuits 131 | ) # Get atomic snapshot of circuits 132 | if self.circuit_id in circuits: 133 | # Create a copy of the circuit for the operation 134 | curr_circuit: SpanPanelCircuit = circuits[self.circuit_id].copy() 135 | # Perform the state change 136 | await span_panel.api.set_relay(curr_circuit, CircuitRelayState.OPEN) 137 | 138 | await self.coordinator.async_request_refresh() 139 | 140 | 141 | async def async_setup_entry( 142 | hass: HomeAssistant, 143 | config_entry: ConfigEntry, 144 | async_add_entities: AddEntitiesCallback, 145 | ) -> None: 146 | """Set up sensor platform.""" 147 | 148 | _LOGGER.debug("ASYNC SETUP ENTRY SWITCH") 149 | data: dict[str, Any] = hass.data[DOMAIN][config_entry.entry_id] 150 | 151 | coordinator: SpanPanelCoordinator = data[COORDINATOR] 152 | span_panel: SpanPanel = coordinator.data 153 | 154 | entities: list[SpanPanelCircuitsSwitch] = [] 155 | 156 | for circuit_id, circuit_data in span_panel.circuits.items(): 157 | if circuit_data.is_user_controllable: 158 | entities.append( 159 | SpanPanelCircuitsSwitch(coordinator, circuit_id, circuit_data.name) 160 | ) 161 | 162 | async_add_entities(entities) 163 | -------------------------------------------------------------------------------- /custom_components/span_panel/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "no_devices_found": "No devices found on the network", 5 | "already_configured": "Span Panel already configured. Only a single configuration is possible.", 6 | "reauth_successful": "Authentication successful." 7 | }, 8 | "error": { 9 | "cannot_connect": "Failed to connect to Span Panel", 10 | "invalid_auth": "Invalid authentication", 11 | "unknown": "Unexpected error" 12 | }, 13 | "flow_title": "Span Panel ({host})", 14 | "step": { 15 | "confirm_discovery": { 16 | "description": "Do you want to setup Span Panel at {host}?", 17 | "title": "Connect to the Span Panel" 18 | }, 19 | "user": { 20 | "data": { 21 | "host": "Host" 22 | }, 23 | "title": "Connect to the Span Panel" 24 | }, 25 | "choose_auth_type": { 26 | "title": "Choose Authentication Options", 27 | "menu_options": { 28 | "auth_proximity": "Authenticate through your physical Span Panel", 29 | "auth_token": "Authenticate using an access token" 30 | } 31 | }, 32 | "auth_proximity": { 33 | "title": "Proximity Authentication", 34 | "description": "Please open and close the Span Panel door 3 times." 35 | }, 36 | "auth_token": { 37 | "title": "Manual Token Authentication", 38 | "description": "Please enter your access token (Empty to start over)", 39 | "data": { 40 | "access_token": "Access Token" 41 | } 42 | } 43 | } 44 | }, 45 | "options": { 46 | "step": { 47 | "init": { 48 | "title": "Options Menu", 49 | "menu_options": { 50 | "general_options": "General Options", 51 | "entity_naming": "Entity Naming Pattern" 52 | } 53 | }, 54 | "general_options": { 55 | "title": "General Options", 56 | "data": { 57 | "scan_interval": "Scan interval in seconds", 58 | "enable_battery_percentage": "Enable Battery Percentage Sensor", 59 | "enable_solar_circuit": "Enable Solar Inverter Sensors", 60 | "leg1": "Solar Leg 1 (0 is not used)", 61 | "leg2": "Solar Leg 2 (0 is not used)" 62 | } 63 | }, 64 | "entity_naming": { 65 | "title": "Entity Naming Pattern", 66 | "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", 67 | "data": { 68 | "entity_naming_pattern": "Entity Naming Pattern" 69 | } 70 | } 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /custom_components/span_panel/translations/es.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "no_devices_found": "No se hallaron aparatos en la red", 5 | "already_configured": "Span Panel ya esta configurado. Solo una configuración es posible.", 6 | "reauth_successful": "Autenticación exitosa." 7 | }, 8 | "error": { 9 | "cannot_connect": "No se logró establecer conexión con Span Panel", 10 | "invalid_auth": "Autenticación invalida", 11 | "unknown": "Error inesperado" 12 | }, 13 | "flow_title": "Span Panel ({host})", 14 | "step": { 15 | "confirm_discovery": { 16 | "description": "¿Deseas configurar Span Panel en {host}?", 17 | "title": "Establecer conexión al Span Panel" 18 | }, 19 | "user": { 20 | "data": { 21 | "host": "Host" 22 | }, 23 | "title": "Establecer conexión al Span Panel" 24 | }, 25 | "choose_auth_type": { 26 | "title": "Escoga Opciones de Autenticación", 27 | "menu_options": { 28 | "auth_proximity": "Autenticar a través de su Span Panel físicamente", 29 | "auth_token": "Autenticar usando un token de acceso" 30 | } 31 | }, 32 | "auth_proximity": { 33 | "title": "Autenticación de proximidad", 34 | "description": "Por favor abra y cierre la puerta de Span Panel {remaining} veces." 35 | }, 36 | "auth_token": { 37 | "title": "Autenticación de Token Manualmente", 38 | "description": "Por favor, introduce tu token de acceso (deja en blanco para empezar de nuevo):", 39 | "data": { 40 | "access_token": "Token de Acceso" 41 | } 42 | } 43 | } 44 | }, 45 | "options": { 46 | "step": { 47 | "init": { 48 | "title": "Menú de Opciones", 49 | "menu_options": { 50 | "general_options": "Opciones Generales", 51 | "entity_naming": "Patrón de Nomenclatura de Entidades" 52 | } 53 | }, 54 | "general_options": { 55 | "title": "Opciones Generales", 56 | "data": { 57 | "scan_interval": "Intervalo de escaneo en segundos", 58 | "enable_battery_percentage": "Habilitar Sensor de Porcentaje de Batería", 59 | "enable_solar_circuit": "Habilitar Sensores de Inversor Solar", 60 | "leg1": "Pata solar 1 (0 no se utiliza)", 61 | "leg2": "Pata solar 2 (0 no se utiliza)" 62 | } 63 | }, 64 | "entity_naming": { 65 | "title": "Patrón de Nomenclatura de Entidades", 66 | "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", 67 | "data": { 68 | "entity_naming_pattern": "Patrón de Nomenclatura de Entidades" 69 | } 70 | } 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /custom_components/span_panel/translations/fr.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "no_devices_found": "Aucun appareil trouvé sur le réseau", 5 | "already_configured": "Span Panel déjà configuré. Une seule configuration est possible.", 6 | "reauth_successful": "Authentification réussie." 7 | }, 8 | "error": { 9 | "cannot_connect": "Échec de la connexion au Span Panel", 10 | "invalid_auth": "Authentification invalide", 11 | "unknown": "Erreur inattendue" 12 | }, 13 | "flow_title": "Span Panel ({host})", 14 | "step": { 15 | "confirm_discovery": { 16 | "description": "Voulez-vous configurer Span Panel sur {host} ?", 17 | "title": "Connectez-vous au Span Panel" 18 | }, 19 | "user": { 20 | "data": { 21 | "host": "Host" 22 | }, 23 | "title": "Connectez-vous au Span Panel" 24 | }, 25 | "choose_auth_type": { 26 | "title": "Choisir les options d'authentification", 27 | "menu_options": { 28 | "auth_proximity": "Authentifiez-vous via votre Span Panel physique", 29 | "auth_token": "Authentifier à l'aide d'un jeton d'accès" 30 | } 31 | }, 32 | "auth_proximity": { 33 | "title": "Authentification de proximité", 34 | "description": "Veuillez ouvrir et fermer la porte du Span Panel les fois {remaining}." 35 | }, 36 | "auth_token": { 37 | "title": "Authentification manuelle du jeton", 38 | "description": "Vieuillez entrer votre jeton d'accès (laissez vide pour recommencer):", 39 | "data": { 40 | "access_token": "Jeton d'accès" 41 | } 42 | } 43 | } 44 | }, 45 | "options": { 46 | "step": { 47 | "init": { 48 | "title": "Menu des Options", 49 | "menu_options": { 50 | "general_options": "Options Générales", 51 | "entity_naming": "Modèle de Nommage des Entités" 52 | } 53 | }, 54 | "general_options": { 55 | "title": "Options Générales", 56 | "data": { 57 | "scan_interval": "Intervalle d'analyse en secondes", 58 | "enable_battery_percentage": "Activer le Capteur de Pourcentage de Batterie", 59 | "enable_solar_circuit": "Activer les capteurs de l'onduleur solaire", 60 | "leg1": "Jambe solaire 1 (0 n'est pas utilisé)", 61 | "leg2": "Jambe solaire 2 (0 n'est pas utilisé)" 62 | } 63 | }, 64 | "entity_naming": { 65 | "title": "Modèle de Nommage des Entités", 66 | "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é", 67 | "data": { 68 | "entity_naming_pattern": "Modèle de Nommage des Entités" 69 | } 70 | } 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /custom_components/span_panel/translations/ja.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "no_devices_found": "ネットワーク上にデバイスが見つかりません", 5 | "already_configured": "スパンパネルがすでに設定されています。設定は一つのみです。", 6 | "reauth_successful": "認証できました" 7 | }, 8 | "error": { 9 | "cannot_connect": "スパンパネルへの接続に失敗しました", 10 | "invalid_auth": "認証が無効です", 11 | "unknown": "予期しないエラー" 12 | }, 13 | "flow_title": "スパンパネル ({host})", 14 | "step": { 15 | "confirm_discovery": { 16 | "description": "{host}でスパンパネルをセットアップしますか?", 17 | "title": "スパンパネルに接続" 18 | }, 19 | "user": { 20 | "data": { 21 | "host": "ホスト" 22 | }, 23 | "title": "スパンパネルに接続" 24 | }, 25 | "choose_auth_type": { 26 | "title": "認証オプションを選んでください", 27 | "menu_options": { 28 | "auth_proximity": "スパンパネル実物を通じて認証する", 29 | "auth_token": "アクセストークンを使用して認証する" 30 | } 31 | }, 32 | "auth_proximity": { 33 | "title": "近接認証", 34 | "description": "スパンパネルのドアを {remaining} 回開閉してください。" 35 | }, 36 | "auth_token": { 37 | "title": "手動にトークン認証", 38 | "description": "あなたのアクセストークンを入力してください(空白のままで再開):", 39 | "data": { 40 | "access_token": "アクセストークン" 41 | } 42 | } 43 | } 44 | }, 45 | "options": { 46 | "step": { 47 | "init": { 48 | "title": "オプションメニュー", 49 | "menu_options": { 50 | "general_options": "一般オプション", 51 | "entity_naming": "エンティティ命名パターン" 52 | } 53 | }, 54 | "general_options": { 55 | "title": "一般オプション", 56 | "data": { 57 | "scan_interval": "スキャンインターバル(秒)", 58 | "enable_battery_percentage": "バッテリーパーセントセンサーを有効にする", 59 | "enable_solar_circuit": "ソーラーインバーターセンサーを有効にする", 60 | "leg1": "ソーラーレッグ1(0は使用されません)", 61 | "leg2": "ソーラーレッグ2(0は使用されません)" 62 | } 63 | }, 64 | "entity_naming": { 65 | "title": "エンティティ命名パターン", 66 | "description": "回路エンティティの命名方法を選択してください。**これを変更すると既存のエンティティが名前変更されます。**\n\n{friendly_example}\n\n{circuit_example}\n\n⚠️ **重要**: \n• エンティティ履歴は名前変更中に保持されます\n• 続行する前に設定のバックアップを検討してください\n• 自動化とスクリプトは新しいエンティティIDを使用するために手動更新が必要な場合があります", 67 | "data": { 68 | "entity_naming_pattern": "エンティティ命名パターン" 69 | } 70 | } 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /custom_components/span_panel/translations/pt.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "no_devices_found": "Não foram encontrados dispositivos na rede", 5 | "already_configured": "Painel Span já configurado. Só é possível uma única configuração.", 6 | "reauth_successful": "Autenticação bem-sucedida." 7 | }, 8 | "error": { 9 | "cannot_connect": "Falha ao ligar ao Painel Span", 10 | "invalid_auth": "Autenticação inválida", 11 | "unknown": "Erro inesperado" 12 | }, 13 | "flow_title": "Painel Span ({host})", 14 | "step": { 15 | "confirm_discovery": { 16 | "description": "Deseja configurar o Painel Span em {host}?", 17 | "title": "Ligar ao Painel Span" 18 | }, 19 | "user": { 20 | "data": { 21 | "host": "Anfitrião" 22 | }, 23 | "title": "Ligar ao Painel Span" 24 | }, 25 | "choose_auth_type": { 26 | "title": "Escolher Opções de Autenticação", 27 | "menu_options": { 28 | "auth_proximity": "Autenticar através do seu Painel Span físico", 29 | "auth_token": "Autenticar usando um token de acesso" 30 | } 31 | }, 32 | "auth_proximity": { 33 | "title": "Autenticação por Proximidade", 34 | "description": "Por favor, abra e feche a porta do Painel Span 3 vezes." 35 | }, 36 | "auth_token": { 37 | "title": "Autenticação Manual por Token", 38 | "description": "Por favor, introduza o seu token de acesso (Vazio para recomeçar)", 39 | "data": { 40 | "access_token": "Token de Acesso" 41 | } 42 | } 43 | } 44 | }, 45 | "options": { 46 | "step": { 47 | "init": { 48 | "title": "Menu de Opções", 49 | "menu_options": { 50 | "general_options": "Opções Gerais", 51 | "entity_naming": "Padrão de Nomenclatura de Entidades" 52 | } 53 | }, 54 | "general_options": { 55 | "title": "Opções Gerais", 56 | "data": { 57 | "scan_interval": "Intervalo de verificação em segundos", 58 | "enable_battery_percentage": "Ativar Sensor de Percentagem da Bateria", 59 | "enable_solar_circuit": "Ativar Sensores do Inversor Solar", 60 | "leg1": "Fase Solar 1 (0 não utilizado)", 61 | "leg2": "Fase Solar 2 (0 não utilizado)" 62 | } 63 | }, 64 | "entity_naming": { 65 | "title": "Padrão de Nomenclatura de Entidades", 66 | "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", 67 | "data": { 68 | "entity_naming_pattern": "Padrão de Nomenclatura de Entidades" 69 | } 70 | } 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /custom_components/span_panel/util.py: -------------------------------------------------------------------------------- 1 | """Utility functions for the Span integration.""" 2 | 3 | from homeassistant.helpers.entity import DeviceInfo 4 | 5 | from .const import DOMAIN 6 | from .span_panel import SpanPanel 7 | 8 | 9 | def panel_to_device_info(panel: SpanPanel) -> DeviceInfo: 10 | """Convert a Span Panel to a Home Assistant device info object.""" 11 | return DeviceInfo( 12 | identifiers={(DOMAIN, panel.status.serial_number)}, 13 | manufacturer="Span", 14 | model=f"Span Panel ({panel.status.model})", 15 | name="Span Panel", 16 | sw_version=panel.status.firmware_version, 17 | configuration_url=f"http://{panel.host}", 18 | ) 19 | -------------------------------------------------------------------------------- /custom_components/span_panel/version.py: -------------------------------------------------------------------------------- 1 | """Version for span.""" 2 | 3 | import json 4 | import os 5 | from typing import Any, cast 6 | 7 | 8 | def get_version() -> str: 9 | """Return the version from manifest.json.""" 10 | manifest_path = os.path.join(os.path.dirname(__file__), "manifest.json") 11 | with open(manifest_path, encoding="utf-8") as f: 12 | manifest: dict[str, Any] = json.load(f) 13 | return cast(str, manifest["version"]) 14 | 15 | 16 | __version__ = get_version() 17 | -------------------------------------------------------------------------------- /debug_options_flow.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """Test script to debug the options flow flag handling.""" 3 | 4 | import sys 5 | import os 6 | 7 | # Add the custom_components path to sys.path 8 | sys.path.insert(0, os.path.join(os.path.dirname(__file__), "custom_components")) 9 | 10 | from custom_components.span_panel.const import ( 11 | USE_CIRCUIT_NUMBERS, 12 | USE_DEVICE_PREFIX, 13 | EntityNamingPattern, 14 | ENTITY_NAMING_PATTERN, 15 | ) 16 | 17 | 18 | def test_new_installation_flow(): 19 | """Test the options flow for a new installation changing from circuit numbers to friendly names.""" 20 | 21 | class MockEntry: 22 | def __init__(self, options): 23 | self.options = options 24 | 25 | class MockOptionsFlowHandler: 26 | def __init__(self, entry): 27 | self.entry = entry 28 | 29 | def _get_current_naming_pattern(self) -> str: 30 | """Determine the current entity naming pattern from configuration flags.""" 31 | use_circuit_numbers = self.entry.options.get(USE_CIRCUIT_NUMBERS, False) 32 | use_device_prefix = self.entry.options.get(USE_DEVICE_PREFIX, False) 33 | 34 | if use_circuit_numbers: 35 | return EntityNamingPattern.CIRCUIT_NUMBERS.value 36 | elif use_device_prefix: 37 | return EntityNamingPattern.FRIENDLY_NAMES.value 38 | else: 39 | # Pre-1.0.4 installation - no device prefix 40 | return EntityNamingPattern.LEGACY_NAMES.value 41 | 42 | def simulate_user_input(self, user_input): 43 | """Simulate the options flow logic.""" 44 | current_pattern = self._get_current_naming_pattern() 45 | new_pattern = user_input.get(ENTITY_NAMING_PATTERN, current_pattern) 46 | 47 | print(f"Current pattern: {current_pattern}") 48 | print(f"User selected pattern: {new_pattern}") 49 | print(f"Pattern changed: {new_pattern != current_pattern}") 50 | 51 | if new_pattern != current_pattern: 52 | print("Processing pattern change...") 53 | # Entity naming pattern changed - update the configuration flags 54 | if new_pattern == EntityNamingPattern.CIRCUIT_NUMBERS.value: 55 | user_input[USE_CIRCUIT_NUMBERS] = True 56 | user_input[USE_DEVICE_PREFIX] = True 57 | print( 58 | "Set flags for CIRCUIT_NUMBERS: circuit_numbers=True, device_prefix=True" 59 | ) 60 | elif new_pattern == EntityNamingPattern.FRIENDLY_NAMES.value: 61 | user_input[USE_CIRCUIT_NUMBERS] = False 62 | user_input[USE_DEVICE_PREFIX] = True 63 | print( 64 | "Set flags for FRIENDLY_NAMES: circuit_numbers=False, device_prefix=True" 65 | ) 66 | # Note: LEGACY_NAMES is read-only, users can't select it 67 | 68 | # Remove the pattern selector from saved options (it's derived from the flags) 69 | user_input.pop(ENTITY_NAMING_PATTERN, None) 70 | else: 71 | print( 72 | "No pattern change - preserving existing flags (including False values)..." 73 | ) 74 | # No pattern change - preserve existing flags (including False values) 75 | use_prefix = self.entry.options.get(USE_DEVICE_PREFIX, False) 76 | user_input[USE_DEVICE_PREFIX] = use_prefix 77 | print(f"Preserved device_prefix: {use_prefix}") 78 | 79 | use_circuit_numbers = self.entry.options.get(USE_CIRCUIT_NUMBERS, False) 80 | user_input[USE_CIRCUIT_NUMBERS] = use_circuit_numbers 81 | print(f"Preserved circuit_numbers: {use_circuit_numbers}") 82 | 83 | # Remove the pattern selector from saved options 84 | user_input.pop(ENTITY_NAMING_PATTERN, None) 85 | 86 | return user_input 87 | 88 | print("=" * 60) 89 | print("Testing new installation options flow") 90 | print("=" * 60) 91 | 92 | # Simulate new installation (as created by create_new_entry) 93 | new_installation_options = { 94 | USE_DEVICE_PREFIX: True, 95 | USE_CIRCUIT_NUMBERS: True, 96 | } 97 | 98 | entry = MockEntry(new_installation_options) 99 | handler = MockOptionsFlowHandler(entry) 100 | 101 | print(f"Initial installation options: {new_installation_options}") 102 | print() 103 | 104 | # Test 1: User selects "Friendly Names" (should change from circuit numbers to friendly names) 105 | print("Test 1: User selects 'Friendly Names'") 106 | print("-" * 40) 107 | user_input = { 108 | ENTITY_NAMING_PATTERN: EntityNamingPattern.FRIENDLY_NAMES.value, 109 | # ... other options would be here too 110 | } 111 | 112 | result = handler.simulate_user_input(user_input.copy()) 113 | print(f"Final user_input after processing: {result}") 114 | print() 115 | 116 | # Test 2: User selects "Circuit Numbers" (should be no change) 117 | print("Test 2: User selects 'Circuit Numbers' (no change)") 118 | print("-" * 40) 119 | user_input = { 120 | ENTITY_NAMING_PATTERN: EntityNamingPattern.CIRCUIT_NUMBERS.value, 121 | } 122 | 123 | result = handler.simulate_user_input(user_input.copy()) 124 | print(f"Final user_input after processing: {result}") 125 | print() 126 | 127 | print("=" * 60) 128 | print("Expected behavior:") 129 | print("- Test 1 should set USE_CIRCUIT_NUMBERS=False, USE_DEVICE_PREFIX=True") 130 | print("- Test 2 should preserve existing flags (no change)") 131 | print("=" * 60) 132 | 133 | 134 | if __name__ == "__main__": 135 | test_new_installation_flow() 136 | -------------------------------------------------------------------------------- /developer_attribute_readme.md: -------------------------------------------------------------------------------- 1 | # Home Assistant Entity Attribute Guide 2 | 3 | ## The Dual Attribute Pattern in Home Assistant 4 | 5 | Home Assistant uses two different patterns for entity attributes that can be confusing and lead to subtle bugs. This guide explains these patterns to help you avoid common pitfalls when developing integrations. 6 | 7 | ## Two Attribute Patterns 8 | 9 | ### 1. Protected Attributes (`_attr_*`) 10 | 11 | For most entity attributes (state, name, icon, etc.), Home Assistant uses protected attributes with an `_attr_` prefix. These are managed by the `CachedProperties` metaclass which provides automatic caching and invalidation: 12 | 13 | ```python 14 | class MyEntity(Entity): 15 | """My entity implementation.""" 16 | 17 | # Protected attributes with _attr_ prefix 18 | _attr_name: str | None = None 19 | _attr_icon: str | None = None 20 | _attr_device_class: str | None = None 21 | _attr_extra_state_attributes: dict[str, Any] = {} 22 | ``` 23 | 24 | The `CachedProperties` metaclass: 25 | 26 | - Automatically creates property getters/setters for `_attr_*` attributes 27 | - Manages caching of property values 28 | - Invalidates cache when attributes are modified 29 | - Handles type annotations correctly 30 | 31 | ### 2. Direct Public Attributes 32 | 33 | While most attributes use the protected `_attr_` pattern, there are a few special cases that use direct public attributes: 34 | 35 | 1. `entity_description`: The primary example, used for storing entity descriptions 36 | 2. `unique_id`: In some cases, used for direct entity identification 37 | 3. `platform`: Used to identify the platform an entity belongs to 38 | 4. `registry_entry`: Used for entity registry entries 39 | 5. `hass`: Reference to the Home Assistant instance 40 | 41 | Example: 42 | 43 | ```python 44 | # These are set directly without _attr_ prefix 45 | self.entity_description = description 46 | self.unique_id = f"{serial_number}_{entity_id}" 47 | self.platform = platform 48 | ``` 49 | 50 | The reason these attributes are public varies: 51 | 52 | 1. They represent fundamental identity or configuration that shouldn't be overridden 53 | 2. They are part of the public API contract 54 | 3. They are frequently accessed by the core framework 55 | 4. They are used in property getter fallback chains 56 | 57 | ## Type Annotations and Custom EntityDescriptions 58 | 59 | When extending an entity description with custom attributes, type checkers will often complain when you try to access the custom attributes. This is because the type system only sees the base class type (e.g., `BinarySensorEntityDescription`), not your custom type. 60 | 61 | ### Example Issue 62 | 63 | ```python 64 | # Your custom entity description class with added attributes 65 | @dataclass(frozen=True) 66 | class MyCustomEntityDescription(BinarySensorEntityDescription): 67 | """Custom entity description with extra attributes.""" 68 | value_fn: Callable[[Any], bool] # Custom attribute 69 | 70 | # Your entity class 71 | class MyEntity(BinarySensorEntity): 72 | def __init__(self, description: MyCustomEntityDescription): 73 | self.entity_description = description # Type is seen as BinarySensorEntityDescription 74 | 75 | def update(self): 76 | # Type error! BinarySensorEntityDescription has no attribute 'value_fn' 77 | result = self.entity_description.value_fn(self.data) 78 | ``` 79 | 80 | ### Proper Solutions 81 | 82 | There are several ways to handle this typing issue, each with their own advantages: 83 | 84 | #### 1. Store Direct References (Recommended) 85 | 86 | The cleanest solution is to store direct references to the custom attributes during initialization: 87 | 88 | ```python 89 | def __init__(self, description: MyCustomEntityDescription): 90 | super().__init__() 91 | self.entity_description = description 92 | 93 | # Store a direct reference to value_fn to avoid type issues later 94 | self._value_fn = description.value_fn 95 | 96 | def update(self): 97 | # Use the directly stored reference - no type issues! 98 | result = self._value_fn(self.data) 99 | ``` 100 | 101 | This approach: 102 | 103 | - Works correctly even with optimized Python (`-O` flag) 104 | - Has no runtime overhead 105 | - Keeps code clean and readable 106 | - Preserves proper type information 107 | 108 | #### 2. Use `typing.cast` 109 | 110 | For cases where storing a direct reference isn't feasible, use `typing.cast`: 111 | 112 | ```python 113 | from typing import cast 114 | 115 | def update(self): 116 | # Cast to our specific type for type-checking - this has no runtime overhead 117 | description = cast(MyCustomEntityDescription, self.entity_description) 118 | result = description.value_fn(self.data) 119 | ``` 120 | 121 | This approach: 122 | 123 | - Satisfies the type checker 124 | - Has zero runtime overhead (cast is removed during compilation) 125 | - Doesn't protect against actual type errors at runtime 126 | 127 | #### 3. Use Helper Properties or Methods 128 | 129 | Create helper properties or methods that handle the typing: 130 | 131 | ```python 132 | @property 133 | def my_description(self) -> MyCustomEntityDescription: 134 | """Return the entity description as the specific type.""" 135 | return self.entity_description # type: ignore[return-value] 136 | 137 | def update(self): 138 | result = self.my_description.value_fn(self.data) 139 | ``` 140 | 141 | ### What NOT to Do: Using Assertions 142 | 143 | ❌ **Do not use assertions for type checking:** 144 | 145 | ```python 146 | def update(self): 147 | description = self.entity_description 148 | assert isinstance(description, MyCustomEntityDescription) # BAD PRACTICE! 149 | result = description.value_fn(self.data) 150 | ``` 151 | 152 | This approach is problematic because: 153 | 154 | 1. Assertions are completely removed when Python runs with optimizations enabled (`-O` flag) 155 | 2. This can lead to runtime errors in production environments 156 | 3. Security linters like Bandit will flag this as a vulnerability (B101) 157 | 158 | ## When to Use Each Pattern 159 | 160 | - **Use `self._attr_*`** for most entity attributes (name, state, device_class, etc.) 161 | - **Use `self.entity_description`** specifically for the entity description 162 | 163 | ## Common Pitfalls 164 | 165 | ### The `entity_description` Trap 166 | 167 | The most common mistake is using `self._attr_entity_description = description` instead of `self.entity_description = description`. 168 | 169 | This can cause subtle bugs because: 170 | 171 | 1. The entity will initialize without errors 172 | 2. Basic functionality might work 173 | 3. But properties that fall back to the entity description (like device_class) won't work correctly 174 | 4. Runtime errors may occur when trying to access methods or properties of the entity description 175 | 176 | ### Example of What Not to Do: 177 | 178 | ```python 179 | # INCORRECT - Will cause bugs 180 | def __init__(self, coordinator, description): 181 | super().__init__(coordinator) 182 | self._attr_entity_description = description # WRONG! 183 | self._attr_device_class = description.device_class 184 | ``` 185 | 186 | ### Correct Implementation: 187 | 188 | ```python 189 | # CORRECT 190 | def __init__(self, coordinator, description): 191 | super().__init__(coordinator) 192 | self.entity_description = description # Correct! 193 | self._attr_device_class = description.device_class # This is also correct 194 | ``` 195 | 196 | ## How Home Assistant Uses entity_description 197 | 198 | Understanding how Home Assistant uses `entity_description` internally helps explain why it's treated differently: 199 | 200 | ```python 201 | # From Home Assistant's Entity class 202 | @cached_property 203 | def device_class(self) -> str | None: 204 | """Return the class of this entity.""" 205 | if hasattr(self, "_attr_device_class"): 206 | return self._attr_device_class 207 | if hasattr(self, "entity_description"): # Fallback to entity_description 208 | return self.entity_description.device_class 209 | return None 210 | ``` 211 | 212 | This pattern appears throughout Home Assistant's code. The framework first checks the direct attribute, then falls back to the entity description if available. 213 | 214 | ## Why The Dual Pattern Exists 215 | 216 | Home Assistant's approach evolved over time: 217 | 218 | 1. **Historical Evolution**: Older code used direct attributes, newer code uses the `_attr_` pattern 219 | 2. **Special Role**: `entity_description` serves as a container of defaults and is a public API 220 | 3. **Cached Properties**: The `_attr_` pattern works with Home Assistant's property caching system 221 | 4. **Fallback Chain**: Property getters use a fallback chain: `_attr_*` → `entity_description.*` → default 222 | 223 | ### Why `entity_description` is a Public Attribute 224 | 225 | Home Assistant likely uses a public attribute for `entity_description` for several reasons: 226 | 227 | 1. **API Contract**: The entity description represents a public API contract that is meant to be preserved and directly accessed 228 | 2. **Composition vs. Inheritance**: It emphasizes composition (an entity has a description) rather than inheritance (an entity is a description) 229 | 3. **Interoperability**: Allows for more flexible interoperability between integrations and the core framework 230 | 4. **Serialization**: May facilitate easier serialization/deserialization when needed 231 | 5. **Accessor Pattern**: Other parts of Home Assistant can access the description directly without needing accessor methods 232 | 233 | The inconsistency between `entity_description` and other `_attr_*` attributes may simply be an architectural decision made at different points in Home Assistant's development history. 234 | 235 | ## Best Practices 236 | 237 | 1. **Use `self._attr_*` for entity attributes** - This automatically gets you: 238 | 239 | - Protected attribute storage 240 | - Cached property getters/setters (via the `CachedProperties` metaclass) 241 | - Proper type annotation handling 242 | - Automatic cache invalidation 243 | 244 | 2. **Use `self.entity_description`** (never `self._attr_entity_description`) for entity descriptions 245 | 246 | 3. **When extending `Entity` classes:** 247 | 248 | - Check the parent class implementation to understand the attribute pattern 249 | - Use the same pattern as the parent class for consistency 250 | - Include proper type annotations to help catch issues earlier 251 | 252 | 4. **For custom entity descriptions:** 253 | 254 | - Store direct references to custom description attributes in your entity's `__init__` method 255 | - Use proper type annotations to avoid type checker issues 256 | - Test property access, especially for device_class and other properties that might come from entity_description 257 | 258 | 5. **For custom properties** (when you need something beyond the standard `_attr_*` pattern): 259 | ```python 260 | @cached_property 261 | def custom_property(self) -> str: 262 | """Return a computed property value.""" 263 | return self._compute_value() 264 | ``` 265 | 266 | ## Summary 267 | 268 | Home Assistant's dual attribute pattern can be confusing, but following these guidelines will help avoid subtle bugs: 269 | 270 | - Use `self._attr_*` for most attributes (this automatically includes caching) 271 | - Use `self.entity_description` (no underscore prefix) for the entity description 272 | - Store direct references to custom description attributes to avoid type issues 273 | 274 | This inconsistency in the framework's design is unfortunately something developers need to be aware of when building integrations. 275 | -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Span Panel", 3 | "content_in_root": false, 4 | "homeassistant": "2023.3.0", 5 | "render_readme": true, 6 | "zip_release": false, 7 | "filename": "custom_components/span_panel" 8 | } 9 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "span" 3 | # integration version is managed in the manifest.json for HA 4 | # version = "0.0.0" 5 | description = "Span Panel Custom Integration for Home Assistant" 6 | authors = ["SpanPanel"] 7 | license = "MIT" 8 | readme = "README.md" 9 | package-mode = false 10 | 11 | [tool.poetry.dependencies] 12 | python = ">=3.13.2,<3.14" 13 | httpx = "^0.28.1" 14 | 15 | [tool.poetry.group.dev.dependencies] 16 | homeassistant-stubs = "*" 17 | types-requests = "*" 18 | ruff = "^0.11.8" 19 | mypy = ">=1.8.0" 20 | pyright = "^1.1.390" 21 | bandit = "^1.7.4" 22 | pre-commit = "^4.2.0" 23 | pydantic = ">=2.0.0,<3.0.0" 24 | voluptuous = ">=0.15.2" 25 | voluptuous-stubs = "^0.1.1" 26 | python-direnv = "^0.2.2" 27 | prettier = "^0.0.7" 28 | black = "^24.0.0" 29 | 30 | [build-system] 31 | requires = ["poetry-core"] 32 | build-backend = "poetry.core.masonry.api" 33 | 34 | [tool.jscpd] 35 | path = ["custom_components/span_panel", "./*.{html,md}"] 36 | format = ["python", "javascript", "json", "markup", "markdown"] 37 | ignore = "custom_components/span_panel/translations/**|**/translations/**|.github/**|env/**|**/site-packages/**|**/.direnv/**" 38 | reporters = ["console"] 39 | output = "./jscpdReport" 40 | gitignore = true 41 | 42 | [tool.mypy] 43 | platform = "linux" 44 | show_error_codes = true 45 | follow_imports = "normal" 46 | 47 | # Type checking settings 48 | strict_equality = true 49 | no_implicit_optional = true 50 | warn_incomplete_stub = true 51 | warn_redundant_casts = true 52 | warn_unused_configs = true 53 | local_partial_types = true 54 | check_untyped_defs = true 55 | disallow_incomplete_defs = true 56 | disallow_subclassing_any = true 57 | disallow_untyped_calls = true 58 | disallow_untyped_decorators = true 59 | warn_return_any = true 60 | strict_optional = true 61 | 62 | # Package handling for HA custom integration 63 | mypy_path = "." 64 | namespace_packages = false 65 | explicit_package_bases = false 66 | 67 | # Module search paths - set this explicitly to prevent duplicate module resolution 68 | files = ["custom_components/span_panel"] 69 | 70 | # Exclude patterns 71 | exclude = [ 72 | "venv/.*", 73 | ".venv/.*", 74 | "scripts/.*" 75 | ] 76 | 77 | # Error codes 78 | enable_error_code = [ 79 | "deprecated", 80 | "ignore-without-code", 81 | "redundant-self", 82 | "truthy-iterable", 83 | "mutable-override" 84 | ] 85 | disable_error_code = [ 86 | "annotation-unchecked", 87 | "import-not-found", 88 | "import-untyped", 89 | "override", 90 | "misc", 91 | ] 92 | 93 | [tool.pydantic-mypy] 94 | init_forbid_extra = true 95 | init_typed = true 96 | warn_required_dynamic_aliases = true 97 | warn_untyped_fields = true 98 | 99 | [tool.pyright] 100 | include = ["custom_components/span_panel"] 101 | exclude = [ 102 | "venv", 103 | ".venv", 104 | "scripts" 105 | ] 106 | pythonPlatform = "Linux" 107 | typeCheckingMode = "basic" 108 | useLibraryCodeForTypes = true 109 | autoSearchPaths = true 110 | reportMissingImports = "warning" 111 | reportMissingTypeStubs = false 112 | 113 | [tool.ruff] 114 | line-length = 88 115 | target-version = "py311" 116 | 117 | [tool.ruff.lint] 118 | select = [ 119 | "B007", # Loop control variable {name} not used within loop body 120 | "B014", # Exception handler is an `except` clause that only reraises 121 | "C", # complexity 122 | "D", # docstrings 123 | "E", # pycodestyle 124 | "F", # pyflakes/autoflake 125 | "ICN001", # import concentions; {name} should be imported as {asname} 126 | "PGH004", # Use specific rule codes when using noqa 127 | "PLC0414", # Useless import alias. Import alias does not rename original package. 128 | "SIM105", # Use contextlib.suppress({exception}) instead of try-except-pass 129 | "SIM117", # Combine multiple with statements 130 | "SIM118", # Use {key} in {dict} instead of {key} in {dict}.keys() 131 | "SIM201", # Use {left} != {right} instead of not {left} == {right} 132 | "SIM212", # Use {a} if {a} else {b} instead of {b} if not {a} else {a} 133 | "SIM300", # Yoda conditions. Use 'age == 42' instead of '42 == age'. 134 | "SIM401", # Use get from dict with default instead of conditional assignment 135 | "T20", # flake8-print 136 | "TRY004", # Prefer TypeError exception for invalid type 137 | "RUF006", # Store a reference to the return value of asyncio.create_task 138 | "UP", # pyupgrade 139 | "W", # pycodestyle 140 | ] 141 | 142 | ignore = [ 143 | "D202", # No blank lines allowed after function docstring 144 | "D203", # 1 blank line required before class docstring 145 | "D213", # Multi-line docstring summary should start at the second line 146 | "D406", # Section name should end with a newline 147 | "D407", # Section name underlining 148 | "E501", # line too long 149 | "E731", # do not assign a lambda expression, use a def 150 | ] 151 | 152 | # Per-file ignores for test and debug files 153 | [tool.ruff.lint.per-file-ignores] 154 | "test_*.py" = ["T201", "D103", "D100"] # Allow print statements and missing docstrings in test files 155 | "debug_*.py" = ["T201", "D103", "D100"] # Allow print statements and missing docstrings in debug files 156 | "tests/**/*.py" = ["T201", "D103", "D100"] # Allow print statements and missing docstrings in test directory 157 | 158 | # Bandit configuration 159 | [tool.bandit] 160 | exclude_dirs = ["tests"] 161 | skips = ["B101", "B108"] # Skip assert_used and hardcoded_tmp_directory for test files 162 | 163 | [tool.ruff.lint.flake8-pytest-style] 164 | fixture-parentheses = false 165 | 166 | [tool.ruff.lint.pyupgrade] 167 | keep-runtime-typing = true 168 | 169 | [tool.ruff.lint.mccabe] 170 | max-complexity = 25 171 | 172 | [tool.black] 173 | line-length = 88 174 | target-version = ['py313'] 175 | exclude = ''' 176 | /( 177 | # directories 178 | \.eggs 179 | | \.git 180 | | \.hg 181 | | \.mypy_cache 182 | | \.tox 183 | | \.venv 184 | | _build 185 | | buck-out 186 | | build 187 | | dist 188 | | scripts 189 | )/ 190 | ''' 191 | 192 | [tool.ruff.lint.isort] 193 | known-first-party = ["custom_components", "span"] 194 | force-sort-within-sections = true 195 | combine-as-imports = true 196 | -------------------------------------------------------------------------------- /pyrightconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["custom_components"], 3 | "extraPaths": [ 4 | "./venv/lib/python3.12/site-packages", 5 | "./venv/lib/python3.11/site-packages", 6 | "./venv/lib/python3.10/site-packages" 7 | ], 8 | "venvPath": "./venv", 9 | "venv": ".", 10 | "pythonVersion": "3.12", 11 | "typeCheckingMode": "basic", 12 | "useLibraryCodeForTypes": true, 13 | "autoSearchPaths": true, 14 | "stubPath": "./venv/lib/python*/site-packages" 15 | } 16 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | asyncio_mode = auto 3 | asyncio_default_fixture_loop_scope = function 4 | filterwarnings = 5 | ignore::pytest.PytestDeprecationWarning 6 | ignore::DeprecationWarning 7 | ignore::PendingDeprecationWarning 8 | testpaths = tests 9 | addopts = -v --tb=short --strict-markers 10 | python_files = test_*.py 11 | python_classes = Test* 12 | python_functions = test_* 13 | markers = 14 | asyncio: marks tests as async 15 | slow: marks tests as slow (deselect with '-m "not slow"') 16 | -------------------------------------------------------------------------------- /requirements_test.txt: -------------------------------------------------------------------------------- 1 | pytest>=7.0.0 2 | pytest-asyncio>=0.21.0 3 | setuptools>=65.7.0 4 | pytest-homeassistant-custom-component>=0.12.0 5 | homeassistant>=2025.5.0 6 | -------------------------------------------------------------------------------- /scripts/run-in-env.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Ensures the script runs in the correct Python environment 4 | # Handles pyenv/virtualenv/poetry activation if needed 5 | 6 | # Find the project root directory (where .git is) 7 | PROJECT_ROOT=$(git rev-parse --show-toplevel) 8 | cd "$PROJECT_ROOT" || exit 1 9 | 10 | # Load environment variables from .env if it exists 11 | if [ -f ".env" ]; then 12 | # shellcheck disable=SC1091 13 | source .env 14 | fi 15 | 16 | # Try to detect and activate the virtual environment 17 | VENV_PATHS=( 18 | ".venv" 19 | "venv" 20 | ".env" 21 | "env" 22 | "$(poetry env info --path 2>/dev/null)" # Try to get Poetry's venv path 23 | ) 24 | 25 | for venv_path in "${VENV_PATHS[@]}"; do 26 | if [ -n "$venv_path" ] && [ -f "$venv_path/bin/activate" ]; then 27 | # shellcheck disable=SC1090 28 | source "$venv_path/bin/activate" 29 | echo "Activated virtual environment at $venv_path" 30 | break 31 | fi 32 | done 33 | 34 | # If poetry is available, ensure dependencies 35 | if command -v poetry &> /dev/null && [ -f "pyproject.toml" ]; then 36 | # Check if pylint is missing 37 | if ! command -v pylint &> /dev/null; then 38 | echo "pylint not found, installing dependencies with poetry..." 39 | poetry install --only dev 40 | fi 41 | fi 42 | 43 | # Execute the requested command 44 | if [[ $# -gt 0 && $1 =~ \.py$ ]]; then 45 | # If first arg is a .py file, run mypy on the files 46 | python -m mypy --follow-imports=silent --ignore-missing-imports "$@" 47 | else 48 | # Otherwise run the command normally 49 | "$@" 50 | fi 51 | -------------------------------------------------------------------------------- /scripts/run-mypy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Get the directory of this script 4 | SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" 5 | 6 | # Go to the project root 7 | cd "$SCRIPT_DIR/.." || exit 8 | 9 | # Source the existing run-in-env.sh to activate the virtual environment 10 | source "$SCRIPT_DIR/run-in-env.sh" 11 | 12 | # Run mypy with explicit module paths and settings for Home Assistant 13 | if [ $# -eq 0 ]; then 14 | # If no files were passed, check the entire directory 15 | cd custom_components && python -m mypy \ 16 | --follow-imports=silent \ 17 | --ignore-missing-imports \ 18 | span_panel 19 | else 20 | # If files were passed, check those specific files 21 | cd custom_components && python -m mypy \ 22 | --follow-imports=silent \ 23 | --ignore-missing-imports \ 24 | $(echo "$@" | sed 's|custom_components/||g') 25 | fi 26 | -------------------------------------------------------------------------------- /scripts/run-pylint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Get the directory of this script 4 | SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" 5 | 6 | # Go to the project root 7 | cd "$SCRIPT_DIR/.." || exit 8 | 9 | # Source the existing run-in-env.sh to activate the virtual environment 10 | source "$SCRIPT_DIR/run-in-env.sh" 11 | 12 | # Run pylint with specific settings 13 | python -m pylint custom_components "$@" --recursive=true 14 | -------------------------------------------------------------------------------- /scripts/run_mypy.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | """Run mypy with Home Assistant core path configuration.""" 4 | 5 | import subprocess # nosec B404 6 | import sys 7 | 8 | 9 | def main() -> None: 10 | """Run mypy with Home Assistant core path configuration.""" 11 | # Run mypy with the provided arguments 12 | result = subprocess.check_call( 13 | ["poetry", "run", "mypy"] + sys.argv[1:] 14 | ) # nosec B603 15 | sys.exit(result) 16 | 17 | 18 | if __name__ == "__main__": 19 | main() 20 | -------------------------------------------------------------------------------- /scripts/setup_env.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | if [ -z "${HA_CORE_PATH}" ]; then 4 | echo "HA_CORE_PATH is not set. Please set it to your Home Assistant core directory." 5 | echo "Example: export HA_CORE_PATH=/path/to/your/homeassistant/core" 6 | exit 1 7 | else 8 | echo "Using Home Assistant core from: ${HA_CORE_PATH}" 9 | fi 10 | 11 | # Add the HA_CORE_PATH to PYTHONPATH 12 | export PYTHONPATH="${HA_CORE_PATH}:${PYTHONPATH}" 13 | -------------------------------------------------------------------------------- /setup-hooks.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Check if pre-commit is installed 4 | if ! command -v pre-commit &> /dev/null; then 5 | echo "Error: pre-commit is not installed." 6 | echo "Please install pre-commit using: pip install pre-commit" 7 | exit 1 8 | fi 9 | 10 | # Install the pre-commit hooks 11 | pre-commit install 12 | 13 | echo "Git hooks installed successfully!" 14 | -------------------------------------------------------------------------------- /tests/common.py: -------------------------------------------------------------------------------- 1 | """Same as home assistant tests/common.py, a util for testing.""" 2 | 3 | import json 4 | import time 5 | from datetime import datetime, timedelta, UTC 6 | from pathlib import Path 7 | from typing import Any 8 | from unittest.mock import patch 9 | 10 | from homeassistant.core import HomeAssistant, callback 11 | from homeassistant.util import dt as dt_util 12 | 13 | 14 | def load_json_object_fixture(filename: str) -> dict[str, object]: 15 | """Load a JSON object from a fixture in the local test/fixtures directory.""" 16 | fixture_path = Path(__file__).parent / "fixtures" / filename 17 | with open(fixture_path, encoding="utf-8") as f: 18 | return json.load(f) 19 | 20 | 21 | @callback 22 | def async_fire_time_changed( 23 | hass: HomeAssistant, datetime_: datetime | None = None, fire_all: bool = False 24 | ) -> None: 25 | """Fire a time changed event. 26 | 27 | If called within the first 500 ms of a second, time will be bumped to exactly 28 | 500 ms to match the async_track_utc_time_change event listeners and 29 | DataUpdateCoordinator which spreads all updates between 0.05..0.50. 30 | Background in PR https://github.com/home-assistant/core/pull/82233 31 | 32 | As asyncio is cooperative, we can't guarantee that the event loop will 33 | run an event at the exact time we want. If you need to fire time changed 34 | for an exact microsecond, use async_fire_time_changed_exact. 35 | """ 36 | if datetime_ is None: 37 | utc_datetime = datetime.now(UTC) 38 | else: 39 | utc_datetime = dt_util.as_utc(datetime_) 40 | 41 | # Increase the mocked time by 0.5 s to account for up to 0.5 s delay 42 | # added to events scheduled by update_coordinator and async_track_time_interval 43 | utc_datetime += timedelta(microseconds=500000) # event.RANDOM_MICROSECOND_MAX 44 | 45 | _async_fire_time_changed(hass, utc_datetime, fire_all) 46 | 47 | 48 | @callback 49 | def _async_fire_time_changed( 50 | hass: HomeAssistant, utc_datetime: datetime | None, fire_all: bool 51 | ) -> None: 52 | timestamp = utc_datetime.timestamp() if utc_datetime else 0.0 53 | from homeassistant.util.async_ import get_scheduled_timer_handles 54 | 55 | for task in list(get_scheduled_timer_handles(hass.loop)): 56 | if task.cancelled(): 57 | continue 58 | mock_seconds_into_future = timestamp - time.time() 59 | future_seconds = task.when() - ( 60 | hass.loop.time() + time.get_clock_info("monotonic").resolution 61 | ) 62 | if fire_all or mock_seconds_into_future >= future_seconds: 63 | with ( 64 | patch( 65 | "homeassistant.helpers.event.time_tracker_utcnow", 66 | return_value=utc_datetime, 67 | ), 68 | patch( 69 | "homeassistant.helpers.event.time_tracker_timestamp", 70 | return_value=timestamp, 71 | ), 72 | ): 73 | task._run() 74 | task.cancel() 75 | 76 | 77 | async def async_fire_state_changed( 78 | hass: HomeAssistant, entity_id: str, new_state: Any, old_state: Any = None 79 | ) -> None: 80 | """Fire a state_changed event for a given entity.""" 81 | hass.bus.async_fire( 82 | "state_changed", 83 | { 84 | "entity_id": entity_id, 85 | "old_state": old_state, 86 | "new_state": new_state, 87 | }, 88 | ) 89 | await hass.async_block_till_done() 90 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """Configure test framework.""" 2 | 3 | import sys 4 | import types 5 | from pathlib import Path 6 | from unittest.mock import MagicMock, patch 7 | 8 | import pytest 9 | 10 | sys.path.insert(0, str(Path(__file__).parent.parent)) 11 | 12 | # This import is required for patching even though it's not directly referenced 13 | import custom_components.span_panel # noqa: F401 # pylint: disable=unused-import # type: ignore[unused-import 14 | 15 | 16 | @pytest.fixture(autouse=True) 17 | def patch_dispatcher_send_for_teardown(): 18 | """Patch dispatcher send for teardown.""" 19 | yield 20 | patch( 21 | "homeassistant.helpers.dispatcher.dispatcher_send", lambda *a, **kw: None 22 | ).start() # type: ignore 23 | 24 | 25 | @pytest.fixture(autouse=True, scope="session") 26 | def patch_frontend_and_panel_custom(): 27 | """Patch frontend and panel_custom.""" 28 | hass_frontend = types.ModuleType("hass_frontend") 29 | setattr(hass_frontend, "where", lambda: Path("/tmp")) # type: ignore[attr-defined] 30 | sys.modules["hass_frontend"] = hass_frontend 31 | with ( 32 | patch("homeassistant.components.frontend", MagicMock()), 33 | patch("homeassistant.components.panel_custom", MagicMock(), create=True), 34 | ): 35 | yield 36 | -------------------------------------------------------------------------------- /tests/factories.py: -------------------------------------------------------------------------------- 1 | """Factories for creating test objects with defaults for Span Panel integration.""" 2 | 3 | import copy 4 | from typing import Any 5 | 6 | from custom_components.span_panel.const import ( 7 | CIRCUITS_ENERGY_CONSUMED, 8 | CIRCUITS_ENERGY_PRODUCED, 9 | CIRCUITS_NAME, 10 | CIRCUITS_POWER, 11 | CIRCUITS_PRIORITY, 12 | CIRCUITS_RELAY, 13 | CIRCUITS_BREAKER_POSITIONS, 14 | CIRCUITS_IS_USER_CONTROLLABLE, 15 | CIRCUITS_IS_SHEDDABLE, 16 | CIRCUITS_IS_NEVER_BACKUP, 17 | CircuitRelayState, 18 | CircuitPriority, 19 | STORAGE_BATTERY_PERCENTAGE, 20 | PANEL_POWER, 21 | SYSTEM_DOOR_STATE_CLOSED, 22 | SYSTEM_ETHERNET_LINK, 23 | SYSTEM_CELLULAR_LINK, 24 | SYSTEM_WIFI_LINK, 25 | DSM_GRID_STATE, 26 | DSM_STATE, 27 | CURRENT_RUN_CONFIG, 28 | MAIN_RELAY_STATE, 29 | ) 30 | 31 | 32 | class SpanPanelCircuitFactory: 33 | """Factory for creating span panel circuit test objects.""" 34 | 35 | _circuit_defaults = { 36 | "id": "1", 37 | CIRCUITS_NAME: "Test Circuit", 38 | CIRCUITS_RELAY: CircuitRelayState.CLOSED.name, 39 | CIRCUITS_POWER: 150.5, 40 | CIRCUITS_ENERGY_CONSUMED: 1500.0, 41 | CIRCUITS_ENERGY_PRODUCED: 0.0, 42 | CIRCUITS_BREAKER_POSITIONS: [1], 43 | CIRCUITS_PRIORITY: CircuitPriority.NICE_TO_HAVE.name, 44 | CIRCUITS_IS_USER_CONTROLLABLE: True, 45 | CIRCUITS_IS_SHEDDABLE: True, 46 | CIRCUITS_IS_NEVER_BACKUP: False, 47 | } 48 | 49 | @staticmethod 50 | def create_circuit( 51 | circuit_id: str = "1", 52 | name: str = "Test Circuit", 53 | relay_state: str = CircuitRelayState.CLOSED.name, 54 | instant_power: float = 150.5, 55 | consumed_energy: float = 1500.0, 56 | produced_energy: float = 0.0, 57 | tabs: list[int] | None = None, 58 | priority: str = CircuitPriority.NICE_TO_HAVE.name, 59 | is_user_controllable: bool = True, 60 | is_sheddable: bool = True, 61 | is_never_backup: bool = False, 62 | **kwargs: Any, 63 | ) -> dict[str, Any]: 64 | """Create a circuit with optional defaults.""" 65 | circuit = copy.deepcopy(SpanPanelCircuitFactory._circuit_defaults) 66 | circuit["id"] = circuit_id 67 | circuit[CIRCUITS_NAME] = name 68 | circuit[CIRCUITS_RELAY] = relay_state 69 | circuit[CIRCUITS_POWER] = instant_power 70 | circuit[CIRCUITS_ENERGY_CONSUMED] = consumed_energy 71 | circuit[CIRCUITS_ENERGY_PRODUCED] = produced_energy 72 | circuit[CIRCUITS_BREAKER_POSITIONS] = tabs or [int(circuit_id)] 73 | circuit[CIRCUITS_PRIORITY] = priority 74 | circuit[CIRCUITS_IS_USER_CONTROLLABLE] = is_user_controllable 75 | circuit[CIRCUITS_IS_SHEDDABLE] = is_sheddable 76 | circuit[CIRCUITS_IS_NEVER_BACKUP] = is_never_backup 77 | 78 | # Add any additional overrides 79 | for k, v in kwargs.items(): 80 | circuit[k] = v 81 | 82 | return circuit 83 | 84 | @staticmethod 85 | def create_kitchen_outlet_circuit() -> dict[str, Any]: 86 | """Create a kitchen outlet circuit.""" 87 | return SpanPanelCircuitFactory.create_circuit( 88 | circuit_id="1", 89 | name="Kitchen Outlets", 90 | instant_power=245.3, 91 | consumed_energy=2450.8, 92 | tabs=[1], 93 | ) 94 | 95 | @staticmethod 96 | def create_living_room_lights_circuit() -> dict[str, Any]: 97 | """Create a living room lights circuit.""" 98 | return SpanPanelCircuitFactory.create_circuit( 99 | circuit_id="2", 100 | name="Living Room Lights", 101 | instant_power=85.2, 102 | consumed_energy=850.5, 103 | tabs=[2], 104 | ) 105 | 106 | @staticmethod 107 | def create_solar_panel_circuit() -> dict[str, Any]: 108 | """Create a solar panel circuit (producing energy).""" 109 | return SpanPanelCircuitFactory.create_circuit( 110 | circuit_id="15", 111 | name="Solar Panels", 112 | instant_power=-1200.0, # Negative indicates production 113 | consumed_energy=0.0, 114 | produced_energy=12000.5, 115 | tabs=[15], 116 | priority=CircuitPriority.MUST_HAVE.name, 117 | is_user_controllable=False, 118 | ) 119 | 120 | @staticmethod 121 | def create_non_controllable_circuit() -> dict[str, Any]: 122 | """Create a non-user-controllable circuit.""" 123 | return SpanPanelCircuitFactory.create_circuit( 124 | circuit_id="30", 125 | name="Main Panel Feed", 126 | is_user_controllable=False, 127 | priority=CircuitPriority.MUST_HAVE.name, 128 | tabs=[30], 129 | ) 130 | 131 | 132 | class SpanPanelDataFactory: 133 | """Factory for creating span panel data test objects.""" 134 | 135 | _panel_defaults = { 136 | PANEL_POWER: 2500.75, 137 | CURRENT_RUN_CONFIG: "PANEL_ON_GRID", 138 | DSM_GRID_STATE: "DSM_GRID_UP", 139 | DSM_STATE: "DSM_ON_GRID", 140 | MAIN_RELAY_STATE: "CLOSED", 141 | "instantGridPowerW": 2500.75, 142 | "feedthroughPowerW": 0.0, 143 | "gridSampleStartMs": 1640995200000, 144 | "gridSampleEndMs": 1640995215000, 145 | "mainMeterEnergy": { 146 | "producedEnergyWh": 0.0, 147 | "consumedEnergyWh": 2500.0, 148 | }, 149 | "feedthroughEnergy": { 150 | "producedEnergyWh": 0.0, 151 | "consumedEnergyWh": 0.0, 152 | }, 153 | } 154 | 155 | @staticmethod 156 | def create_panel_data( 157 | grid_power: float = 2500.75, 158 | dsm_grid_state: str = "DSM_GRID_UP", 159 | dsm_state: str = "DSM_ON_GRID", 160 | main_relay_state: str = "CLOSED", 161 | current_run_config: str = "PANEL_ON_GRID", 162 | **kwargs: Any, 163 | ) -> dict[str, Any]: 164 | """Create panel data with optional defaults.""" 165 | panel_data = copy.deepcopy(SpanPanelDataFactory._panel_defaults) 166 | panel_data[PANEL_POWER] = grid_power 167 | panel_data["instantGridPowerW"] = grid_power 168 | panel_data[DSM_GRID_STATE] = dsm_grid_state 169 | panel_data[DSM_STATE] = dsm_state 170 | panel_data[MAIN_RELAY_STATE] = main_relay_state 171 | panel_data[CURRENT_RUN_CONFIG] = current_run_config 172 | 173 | # Add any additional overrides 174 | for k, v in kwargs.items(): 175 | panel_data[k] = v 176 | 177 | return panel_data 178 | 179 | @staticmethod 180 | def create_on_grid_panel_data() -> dict[str, Any]: 181 | """Create panel data for on-grid operation.""" 182 | return SpanPanelDataFactory.create_panel_data( 183 | grid_power=1850.5, 184 | dsm_grid_state="DSM_GRID_UP", 185 | dsm_state="DSM_ON_GRID", 186 | current_run_config="PANEL_ON_GRID", 187 | ) 188 | 189 | @staticmethod 190 | def create_backup_panel_data() -> dict[str, Any]: 191 | """Create panel data for backup operation.""" 192 | return SpanPanelDataFactory.create_panel_data( 193 | grid_power=0.0, 194 | dsm_grid_state="DSM_GRID_DOWN", 195 | dsm_state="DSM_ON_BACKUP", 196 | current_run_config="PANEL_ON_BACKUP", 197 | ) 198 | 199 | 200 | class SpanPanelStatusFactory: 201 | """Factory for creating span panel status test objects.""" 202 | 203 | _status_defaults = { 204 | "software": { 205 | "firmwareVersion": "1.2.3", 206 | "updateStatus": "IDLE", 207 | "env": "prod", 208 | }, 209 | "system": { 210 | "serial": "ABC123456789", 211 | "manufacturer": "Span", 212 | "model": "Panel", 213 | "doorState": SYSTEM_DOOR_STATE_CLOSED, 214 | "uptime": 86400000, # 24 hours in ms 215 | }, 216 | "network": { 217 | SYSTEM_ETHERNET_LINK: True, 218 | SYSTEM_WIFI_LINK: True, 219 | SYSTEM_CELLULAR_LINK: False, 220 | }, 221 | } 222 | 223 | @staticmethod 224 | def create_status( 225 | serial_number: str = "ABC123456789", 226 | software_version: str = "1.2.3", 227 | door_state: str = SYSTEM_DOOR_STATE_CLOSED, 228 | ethernet_link: bool = True, 229 | cellular_link: bool = False, 230 | wifi_link: bool = True, 231 | **kwargs: Any, 232 | ) -> dict[str, Any]: 233 | """Create status data with optional defaults.""" 234 | status = copy.deepcopy(SpanPanelStatusFactory._status_defaults) 235 | status["system"]["serial"] = serial_number 236 | status["software"]["firmwareVersion"] = software_version 237 | status["system"]["doorState"] = door_state 238 | status["network"][SYSTEM_ETHERNET_LINK] = ethernet_link 239 | status["network"][SYSTEM_CELLULAR_LINK] = cellular_link 240 | status["network"][SYSTEM_WIFI_LINK] = wifi_link 241 | 242 | # Add any additional overrides 243 | for k, v in kwargs.items(): 244 | if "." in k: 245 | # Handle nested keys like "system.uptime" 246 | parts = k.split(".") 247 | current = status 248 | for part in parts[:-1]: 249 | if part not in current: 250 | current[part] = {} 251 | current = current[part] 252 | current[parts[-1]] = v 253 | else: 254 | status[k] = v 255 | 256 | return status 257 | 258 | 259 | class SpanPanelStorageBatteryFactory: 260 | """Factory for creating span panel storage battery test objects.""" 261 | 262 | _battery_defaults = { 263 | STORAGE_BATTERY_PERCENTAGE: 85, 264 | } 265 | 266 | @staticmethod 267 | def create_battery_data( 268 | battery_percentage: int = 85, 269 | **kwargs: Any, 270 | ) -> dict[str, Any]: 271 | """Create battery data with optional defaults.""" 272 | battery = copy.deepcopy(SpanPanelStorageBatteryFactory._battery_defaults) 273 | battery[STORAGE_BATTERY_PERCENTAGE] = battery_percentage 274 | 275 | # Add any additional overrides 276 | for k, v in kwargs.items(): 277 | battery[k] = v 278 | 279 | return battery 280 | 281 | 282 | class SpanPanelApiResponseFactory: 283 | """Factory for creating complete API response objects for testing.""" 284 | 285 | @staticmethod 286 | def create_complete_panel_response( 287 | circuits: list[dict[str, Any]] | None = None, 288 | panel_data: dict[str, Any] | None = None, 289 | status_data: dict[str, Any] | None = None, 290 | battery_data: dict[str, Any] | None = None, 291 | ) -> dict[str, Any]: 292 | """Create a complete panel response with all components.""" 293 | if circuits is None: 294 | circuits = [ 295 | SpanPanelCircuitFactory.create_kitchen_outlet_circuit(), 296 | SpanPanelCircuitFactory.create_living_room_lights_circuit(), 297 | SpanPanelCircuitFactory.create_solar_panel_circuit(), 298 | ] 299 | 300 | if panel_data is None: 301 | panel_data = SpanPanelDataFactory.create_on_grid_panel_data() 302 | 303 | if status_data is None: 304 | status_data = SpanPanelStatusFactory.create_status() 305 | 306 | if battery_data is None: 307 | battery_data = SpanPanelStorageBatteryFactory.create_battery_data() 308 | 309 | # Include circuit data as "branches" in panel data for solar calculations 310 | # The SpanPanelData.from_dict method expects branches to be indexed starting from 0 311 | # Create a list of 32 branches (max circuits) with empty defaults and populate with actual circuits 312 | branches = [] 313 | for _ in range(32): # SPAN panels support up to 32 circuits 314 | # Default empty branch 315 | default_branch = { 316 | "instantPowerW": 0.0, 317 | "importedActiveEnergyWh": 0.0, 318 | "exportedActiveEnergyWh": 0.0, 319 | } 320 | branches.append(default_branch) 321 | 322 | # Populate actual circuit data at the correct indices 323 | for circuit in circuits: 324 | circuit_id = int(circuit["id"]) 325 | if 1 <= circuit_id <= 32: 326 | branch_index = circuit_id - 1 # Convert to 0-based index 327 | branches[branch_index] = { 328 | "instantPowerW": circuit.get(CIRCUITS_POWER, 0.0), 329 | "importedActiveEnergyWh": circuit.get( 330 | CIRCUITS_ENERGY_PRODUCED, 0.0 331 | ), 332 | "exportedActiveEnergyWh": circuit.get( 333 | CIRCUITS_ENERGY_CONSUMED, 0.0 334 | ), 335 | } 336 | 337 | # Add branches to panel data 338 | panel_data["branches"] = branches 339 | 340 | return { 341 | "circuits": {circuit["id"]: circuit for circuit in circuits}, 342 | "panel": panel_data, 343 | "status": status_data, 344 | "battery": battery_data, 345 | } 346 | 347 | @staticmethod 348 | def create_minimal_panel_response() -> dict[str, Any]: 349 | """Create a minimal panel response for basic testing.""" 350 | return SpanPanelApiResponseFactory.create_complete_panel_response( 351 | circuits=[SpanPanelCircuitFactory.create_kitchen_outlet_circuit()], 352 | ) 353 | -------------------------------------------------------------------------------- /tests/helpers.py: -------------------------------------------------------------------------------- 1 | """Helper functions for testing the Span Panel integration.""" 2 | 3 | import datetime 4 | from contextlib import contextmanager 5 | from typing import Any 6 | from unittest.mock import AsyncMock, MagicMock, patch 7 | 8 | from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST, CONF_SCAN_INTERVAL 9 | from homeassistant.core import HomeAssistant 10 | from homeassistant.util.dt import utcnow 11 | from pytest_homeassistant_custom_component.common import MockConfigEntry 12 | 13 | from custom_components.span_panel.const import STORAGE_BATTERY_PERCENTAGE 14 | from custom_components.span_panel.span_panel_hardware_status import ( 15 | SpanPanelHardwareStatus, 16 | ) 17 | from custom_components.span_panel.span_panel_data import SpanPanelData 18 | from .factories import SpanPanelApiResponseFactory 19 | 20 | 21 | class SimpleMockPanel: 22 | """Simple mock panel that returns actual values.""" 23 | 24 | def __init__(self, panel_data: dict[str, Any]): 25 | """Initialize the mock panel.""" 26 | # Map factory data keys to property names expected by sensors 27 | self.instant_grid_power = panel_data.get("instantGridPowerW", 0.0) 28 | self.feedthrough_power = panel_data.get("feedthroughPowerW", 0.0) 29 | self.current_run_config = panel_data.get("currentRunConfig", "PANEL_ON_GRID") 30 | self.dsm_grid_state = panel_data.get("dsmGridState", "DSM_GRID_UP") 31 | self.dsm_state = panel_data.get("dsmState", "DSM_ON_GRID") 32 | self.main_relay_state = panel_data.get("mainRelayState", "CLOSED") 33 | self.grid_sample_start_ms = panel_data.get("gridSampleStartMs", 0) 34 | self.grid_sample_end_ms = panel_data.get("gridSampleEndMs", 0) 35 | self.main_meter_energy_produced = panel_data.get("mainMeterEnergyWh", {}).get( 36 | "producedEnergyWh", 0.0 37 | ) 38 | self.main_meter_energy_consumed = panel_data.get("mainMeterEnergyWh", {}).get( 39 | "consumedEnergyWh", 0.0 40 | ) 41 | self.feedthrough_energy_produced = panel_data.get( 42 | "feedthroughEnergyWh", {} 43 | ).get("producedEnergyWh", 0.0) 44 | self.feedthrough_energy_consumed = panel_data.get( 45 | "feedthroughEnergyWh", {} 46 | ).get("consumedEnergyWh", 0.0) 47 | 48 | # Also set the original keys for direct access if needed 49 | for key, value in panel_data.items(): 50 | if not hasattr(self, key): 51 | setattr(self, key, value) 52 | 53 | 54 | class MockSpanPanelStorageBattery: 55 | """Mock storage battery for testing.""" 56 | 57 | def __init__(self, battery_data: dict[str, Any]): 58 | """Initialize the mock storage battery.""" 59 | self.storage_battery_percentage = battery_data.get( 60 | STORAGE_BATTERY_PERCENTAGE, 85 61 | ) 62 | 63 | # Also set any other battery attributes from the data 64 | for key, value in battery_data.items(): 65 | if not hasattr(self, key): 66 | setattr(self, key, value) 67 | 68 | 69 | @contextmanager 70 | def patch_span_panel_dependencies( 71 | mock_api_responses: dict[str, Any] | None = None, 72 | options: dict[str, Any] | None = None, 73 | ): 74 | """Patches common dependencies for setting up the Span Panel integration in tests.""" 75 | 76 | if mock_api_responses is None: 77 | mock_api_responses = ( 78 | SpanPanelApiResponseFactory.create_complete_panel_response() 79 | ) 80 | 81 | # Create mock API instance 82 | mock_api = AsyncMock() 83 | mock_api.get_status_data = AsyncMock(return_value=mock_api_responses["status"]) 84 | mock_api.get_panel_data = AsyncMock(return_value=mock_api_responses["panel"]) 85 | mock_api.get_circuits_data = AsyncMock(return_value=mock_api_responses["circuits"]) 86 | mock_api.get_storage_battery_data = AsyncMock( 87 | return_value=mock_api_responses["battery"] 88 | ) 89 | mock_api.set_relay = AsyncMock() 90 | 91 | # Create mock objects that properly expose the data as attributes 92 | mock_status = SpanPanelHardwareStatus.from_dict(mock_api_responses["status"]) 93 | 94 | # Create real panel data using the actual from_dict method for proper solar calculations 95 | # If options are provided, create a proper Options object 96 | panel_options = None 97 | if options: 98 | from custom_components.span_panel.options import Options 99 | 100 | # Create a mock config entry with the options 101 | mock_entry = MagicMock() 102 | mock_entry.options = options 103 | panel_options = Options(mock_entry) 104 | 105 | mock_panel_data = SpanPanelData.from_dict( 106 | mock_api_responses["panel"], panel_options 107 | ) 108 | 109 | mock_circuits = {} 110 | for circuit_id, circuit_data in mock_api_responses["circuits"].items(): 111 | # Create proper MockSpanPanelCircuit objects instead of MagicMock 112 | mock_circuits[circuit_id] = MockSpanPanelCircuit(circuit_data) 113 | 114 | # Create proper battery mock instead of MagicMock 115 | mock_battery = MockSpanPanelStorageBattery(mock_api_responses["battery"]) 116 | 117 | # Mock the SpanPanel class and ensure update() calls the API methods 118 | mock_span_panel = MagicMock() 119 | mock_span_panel.api = mock_api 120 | mock_span_panel.status = mock_status 121 | mock_span_panel.panel = mock_panel_data 122 | mock_span_panel.circuits = mock_circuits 123 | mock_span_panel.storage_battery = mock_battery 124 | 125 | # Make update() method actually call the API methods and update the data 126 | async def mock_update(): 127 | """Mock update method that calls the API and updates data.""" 128 | # Call the API methods to register the calls for assertion 129 | await mock_api.get_status_data() 130 | await mock_api.get_panel_data() 131 | await mock_api.get_circuits_data() 132 | await mock_api.get_storage_battery_data() 133 | 134 | # Update the mock data (simulate what the real update does) 135 | status_data = await mock_api.get_status_data() 136 | await mock_api.get_panel_data() 137 | await mock_api.get_circuits_data() 138 | battery_data = await mock_api.get_storage_battery_data() 139 | 140 | # Update mock status with a new real status object 141 | mock_span_panel.status = SpanPanelHardwareStatus.from_dict(status_data) 142 | 143 | # Update mock battery with new data 144 | mock_span_panel.storage_battery = MockSpanPanelStorageBattery(battery_data) 145 | 146 | mock_span_panel.update = mock_update 147 | 148 | patches = [ 149 | patch("custom_components.span_panel.SpanPanel", return_value=mock_span_panel), 150 | patch( 151 | "custom_components.span_panel.span_panel.SpanPanel", 152 | return_value=mock_span_panel, 153 | ), 154 | patch( 155 | "custom_components.span_panel.coordinator.SpanPanel", 156 | return_value=mock_span_panel, 157 | ), 158 | patch( 159 | "homeassistant.helpers.httpx_client.get_async_client", 160 | return_value=AsyncMock(), 161 | ), 162 | patch("custom_components.span_panel.log_entity_summary", return_value=None), 163 | # Disable select platform to avoid type checking issues in tests 164 | patch( 165 | "custom_components.span_panel.select.async_setup_entry", return_value=True 166 | ), 167 | ] 168 | 169 | try: 170 | for p in patches: 171 | p.start() 172 | yield mock_span_panel, mock_api 173 | finally: 174 | for p in patches: 175 | p.stop() 176 | 177 | 178 | def make_span_panel_entry( 179 | entry_id: str = "test_entry", 180 | host: str = "192.168.1.100", 181 | access_token: str = "test_token", 182 | scan_interval: int = 15, 183 | options: dict[str, Any] | None = None, 184 | version: int = 1, 185 | ) -> MockConfigEntry: 186 | """Create a MockConfigEntry for Span Panel with common defaults.""" 187 | return MockConfigEntry( 188 | domain="span_panel", 189 | data={ 190 | CONF_HOST: host, 191 | CONF_ACCESS_TOKEN: access_token, 192 | CONF_SCAN_INTERVAL: scan_interval, 193 | }, 194 | options=options or {}, 195 | entry_id=entry_id, 196 | version=version, 197 | ) 198 | 199 | 200 | def assert_entity_state( 201 | hass: HomeAssistant, entity_id: str, expected_state: Any 202 | ) -> None: 203 | """Assert the state of an entity.""" 204 | state = hass.states.get(entity_id) 205 | assert state is not None, f"Entity {entity_id} not found in hass.states" 206 | assert state.state == str( 207 | expected_state 208 | ), f"Expected {entity_id} to be '{expected_state}', got '{state.state}'" 209 | 210 | 211 | def assert_entity_attribute( 212 | hass: HomeAssistant, entity_id: str, attribute: str, expected_value: Any 213 | ) -> None: 214 | """Assert an attribute of an entity.""" 215 | state = hass.states.get(entity_id) 216 | assert state is not None, f"Entity {entity_id} not found in hass.states" 217 | actual_value = state.attributes.get(attribute) 218 | assert ( 219 | actual_value == expected_value 220 | ), f"Expected {entity_id}.{attribute} to be '{expected_value}', got '{actual_value}'" 221 | 222 | 223 | async def advance_time(hass: HomeAssistant, seconds: int) -> None: 224 | """Advance Home Assistant time by a given number of seconds and block till done.""" 225 | now = utcnow() 226 | future = now + datetime.timedelta(seconds=seconds) 227 | from .common import async_fire_time_changed 228 | 229 | async_fire_time_changed(hass, future) 230 | await hass.async_block_till_done() 231 | 232 | 233 | async def trigger_coordinator_update(coordinator: Any) -> None: 234 | """Manually trigger a coordinator update.""" 235 | await coordinator.async_request_refresh() 236 | await coordinator.hass.async_block_till_done() 237 | 238 | 239 | def setup_span_panel_entry( 240 | hass: HomeAssistant, 241 | mock_api_responses: dict[str, Any] | None = None, 242 | entry_id: str = "test_span_panel", 243 | host: str = "192.168.1.100", 244 | access_token: str = "test_token", 245 | options: dict[str, Any] | None = None, 246 | ) -> tuple[MockConfigEntry, dict[str, Any] | None]: 247 | """Create and setup a span panel entry for testing. 248 | 249 | Returns: 250 | tuple: (config_entry, mock_api_responses) 251 | 252 | """ 253 | entry = make_span_panel_entry( 254 | entry_id=entry_id, 255 | host=host, 256 | access_token=access_token, 257 | options=options, 258 | ) 259 | entry.add_to_hass(hass) 260 | 261 | # This will be used in the context manager 262 | return entry, mock_api_responses 263 | 264 | 265 | def get_circuit_entity_id( 266 | circuit_id: str, 267 | circuit_name: str, 268 | platform: str, 269 | suffix: str, 270 | use_circuit_numbers: bool = False, 271 | use_device_prefix: bool = True, 272 | ) -> str: 273 | """Generate expected entity ID for a circuit entity.""" 274 | if use_device_prefix: 275 | prefix = "span_panel" 276 | else: 277 | prefix = "" 278 | 279 | if use_circuit_numbers: 280 | middle = f"circuit_{circuit_id}" 281 | else: 282 | # Convert circuit name to entity ID format 283 | middle = circuit_name.lower().replace(" ", "_").replace("-", "_") 284 | 285 | parts = [p for p in [prefix, middle, suffix] if p] 286 | entity_id = f"{platform}.{'_'.join(parts)}" 287 | 288 | return entity_id 289 | 290 | 291 | def get_panel_entity_id( 292 | suffix: str, platform: str = "sensor", use_device_prefix: bool = True 293 | ) -> str: 294 | """Generate expected entity ID for a panel-level entity.""" 295 | if use_device_prefix: 296 | prefix = "span_panel" 297 | else: 298 | prefix = "" 299 | 300 | parts = [p for p in [prefix, suffix] if p] 301 | entity_id = f"{platform}.{'_'.join(parts)}" 302 | 303 | return entity_id 304 | 305 | 306 | class MockSpanPanelCircuit: 307 | """Mock circuit for testing.""" 308 | 309 | def __init__(self, circuit_data: dict[str, Any]): 310 | """Initialize the mock circuit.""" 311 | self.id = circuit_data["id"] 312 | self.name = circuit_data.get("name", "Test Circuit") 313 | self.instant_power = circuit_data.get("instantPowerW", 0.0) 314 | self.consumed_energy = circuit_data.get("consumedEnergyWh", 0.0) 315 | self.produced_energy = circuit_data.get("producedEnergyWh", 0.0) 316 | self.relay_state = circuit_data.get("relayState", "CLOSED") 317 | self.tabs = circuit_data.get("tabs", [1]) 318 | self.priority = circuit_data.get("priority", "NICE_TO_HAVE") 319 | self.is_user_controllable = circuit_data.get("is_user_controllable", True) 320 | self.is_sheddable = circuit_data.get("is_sheddable", True) 321 | self.is_never_backup = circuit_data.get("is_never_backup", False) 322 | 323 | def copy(self): 324 | """Create a copy of this circuit.""" 325 | return MockSpanPanelCircuit( 326 | { 327 | "id": self.id, 328 | "name": self.name, 329 | "instantPowerW": self.instant_power, 330 | "consumedEnergyWh": self.consumed_energy, 331 | "producedEnergyWh": self.produced_energy, 332 | "relayState": self.relay_state, 333 | "tabs": self.tabs, 334 | "priority": self.priority, 335 | "is_user_controllable": self.is_user_controllable, 336 | "is_sheddable": self.is_sheddable, 337 | "is_never_backup": self.is_never_backup, 338 | } 339 | ) 340 | 341 | 342 | async def mock_circuit_relay_operation( 343 | mock_api: AsyncMock, circuit_id: str, new_state: str, mock_circuits: dict[str, Any] 344 | ) -> None: 345 | """Mock a circuit relay operation and update the mock data.""" 346 | if circuit_id in mock_circuits: 347 | mock_circuits[circuit_id].relay_state = new_state 348 | # Simulate API call 349 | mock_api.set_relay.return_value = None 350 | -------------------------------------------------------------------------------- /tests/test_circuit_control.py: -------------------------------------------------------------------------------- 1 | """test_circuit_control. 2 | 3 | Tests for Span Panel circuit control functionality (switches, relay operations). 4 | """ 5 | 6 | from typing import Any 7 | from unittest.mock import AsyncMock, MagicMock 8 | 9 | 10 | import pytest 11 | 12 | from custom_components.span_panel.const import CircuitRelayState 13 | from custom_components.span_panel.switch import async_setup_entry 14 | from custom_components.span_panel.switch import SpanPanelCircuitsSwitch 15 | 16 | 17 | @pytest.fixture(autouse=True) 18 | def expected_lingering_timers(): 19 | """Fix expected lingering timers for tests.""" 20 | return True 21 | 22 | 23 | def create_mock_circuit( 24 | circuit_id: str = "1", 25 | name: str = "Test Circuit", 26 | relay_state: str = CircuitRelayState.CLOSED.name, 27 | is_user_controllable: bool = True, 28 | ): 29 | """Create a mock circuit for testing.""" 30 | circuit = MagicMock() 31 | circuit.id = circuit_id 32 | circuit.name = name 33 | circuit.relay_state = relay_state 34 | circuit.is_user_controllable = is_user_controllable 35 | circuit.tabs = [int(circuit_id)] 36 | circuit.copy.return_value = circuit 37 | return circuit 38 | 39 | 40 | def create_mock_span_panel(circuits: dict[str, Any]): 41 | """Create a mock SpanPanel with circuits.""" 42 | panel = MagicMock() 43 | panel.circuits = circuits 44 | panel.status.serial_number = "TEST123" 45 | return panel 46 | 47 | 48 | @pytest.mark.asyncio 49 | async def test_switch_creation_for_controllable_circuit( 50 | hass: Any, enable_custom_integrations: Any 51 | ): 52 | """Test that switches are created only for user-controllable circuits.""" 53 | 54 | # Create controllable circuit 55 | controllable_circuit = create_mock_circuit( 56 | circuit_id="1", 57 | name="Kitchen Outlets", 58 | is_user_controllable=True, 59 | ) 60 | 61 | # Create non-controllable circuit 62 | non_controllable_circuit = create_mock_circuit( 63 | circuit_id="2", 64 | name="Main Feed", 65 | is_user_controllable=False, 66 | ) 67 | 68 | circuits = { 69 | "1": controllable_circuit, 70 | "2": non_controllable_circuit, 71 | } 72 | 73 | mock_panel = create_mock_span_panel(circuits) 74 | mock_coordinator = MagicMock() 75 | mock_coordinator.data = mock_panel 76 | 77 | entities = [] 78 | 79 | def mock_add_entities(new_entities, update_before_add: bool = False): 80 | entities.extend(new_entities) 81 | 82 | mock_config_entry = MagicMock() 83 | mock_config_entry.entry_id = "test_entry" 84 | 85 | # Mock hass data 86 | hass.data = { 87 | "span_panel": { 88 | "test_entry": { 89 | "coordinator": mock_coordinator, 90 | } 91 | } 92 | } 93 | 94 | await async_setup_entry(hass, mock_config_entry, mock_add_entities) 95 | 96 | # Should only create one switch for the controllable circuit 97 | assert len(entities) == 1 98 | assert entities[0].circuit_id == "1" 99 | 100 | 101 | @pytest.mark.asyncio 102 | async def test_switch_turn_on_operation(hass: Any, enable_custom_integrations: Any): 103 | """Test turning on a circuit switch.""" 104 | 105 | circuit = create_mock_circuit( 106 | circuit_id="1", 107 | name="Kitchen Outlets", 108 | relay_state=CircuitRelayState.OPEN.name, 109 | ) 110 | 111 | circuits = {"1": circuit} 112 | mock_panel = create_mock_span_panel(circuits) 113 | mock_panel.api.set_relay = AsyncMock() 114 | 115 | mock_coordinator = MagicMock() 116 | mock_coordinator.data = mock_panel 117 | mock_coordinator.async_request_refresh = AsyncMock() 118 | 119 | switch = SpanPanelCircuitsSwitch(mock_coordinator, "1", "Kitchen Outlets") 120 | 121 | # Turn on the switch 122 | await switch.async_turn_on() 123 | 124 | # Verify API was called with correct parameters 125 | mock_panel.api.set_relay.assert_called_once() 126 | call_args = mock_panel.api.set_relay.call_args 127 | assert ( 128 | call_args[0][1] == CircuitRelayState.CLOSED 129 | ) # Second argument should be CLOSED 130 | 131 | # Verify refresh was requested 132 | mock_coordinator.async_request_refresh.assert_called_once() 133 | 134 | 135 | @pytest.mark.asyncio 136 | async def test_switch_turn_off_operation(hass: Any, enable_custom_integrations: Any): 137 | """Test turning off a circuit switch.""" 138 | 139 | circuit = create_mock_circuit( 140 | circuit_id="1", 141 | name="Kitchen Outlets", 142 | relay_state=CircuitRelayState.CLOSED.name, 143 | ) 144 | 145 | circuits = {"1": circuit} 146 | mock_panel = create_mock_span_panel(circuits) 147 | mock_panel.api.set_relay = AsyncMock() 148 | 149 | mock_coordinator = MagicMock() 150 | mock_coordinator.data = mock_panel 151 | mock_coordinator.async_request_refresh = AsyncMock() 152 | 153 | switch = SpanPanelCircuitsSwitch(mock_coordinator, "1", "Kitchen Outlets") 154 | 155 | # Turn off the switch 156 | await switch.async_turn_off() 157 | 158 | # Verify API was called with correct parameters 159 | mock_panel.api.set_relay.assert_called_once() 160 | call_args = mock_panel.api.set_relay.call_args 161 | assert call_args[0][1] == CircuitRelayState.OPEN # Second argument should be OPEN 162 | 163 | # Verify refresh was requested 164 | mock_coordinator.async_request_refresh.assert_called_once() 165 | 166 | 167 | @pytest.mark.asyncio 168 | async def test_switch_state_reflects_relay_state( 169 | hass: Any, enable_custom_integrations: Any 170 | ): 171 | """Test that switch state correctly reflects circuit relay state.""" 172 | 173 | # Test CLOSED relay -> switch ON 174 | circuit_closed = create_mock_circuit( 175 | circuit_id="1", 176 | name="Kitchen Outlets", 177 | relay_state=CircuitRelayState.CLOSED.name, 178 | ) 179 | 180 | circuits_closed = {"1": circuit_closed} 181 | mock_panel_closed = create_mock_span_panel(circuits_closed) 182 | 183 | mock_coordinator_closed = MagicMock() 184 | mock_coordinator_closed.data = mock_panel_closed 185 | 186 | switch_closed = SpanPanelCircuitsSwitch( 187 | mock_coordinator_closed, "1", "Kitchen Outlets" 188 | ) 189 | 190 | # Check that switch is on for CLOSED relay 191 | assert switch_closed.is_on is True 192 | 193 | # Test OPEN relay -> switch OFF 194 | circuit_open = create_mock_circuit( 195 | circuit_id="1", 196 | name="Kitchen Outlets", 197 | relay_state=CircuitRelayState.OPEN.name, 198 | ) 199 | 200 | circuits_open = {"1": circuit_open} 201 | mock_panel_open = create_mock_span_panel(circuits_open) 202 | 203 | mock_coordinator_open = MagicMock() 204 | mock_coordinator_open.data = mock_panel_open 205 | 206 | switch_open = SpanPanelCircuitsSwitch(mock_coordinator_open, "1", "Kitchen Outlets") 207 | 208 | # Check that switch is off for OPEN relay 209 | assert switch_open.is_on is False 210 | 211 | 212 | @pytest.mark.asyncio 213 | async def test_switch_handles_missing_circuit( 214 | hass: Any, enable_custom_integrations: Any 215 | ): 216 | """Test that switch handles gracefully when circuit is missing.""" 217 | 218 | # Empty circuits dict 219 | circuits = {} 220 | mock_panel = create_mock_span_panel(circuits) 221 | 222 | mock_coordinator = MagicMock() 223 | mock_coordinator.data = mock_panel 224 | 225 | # Should raise ValueError for missing circuit 226 | with pytest.raises(ValueError, match="Circuit 1 not found"): 227 | SpanPanelCircuitsSwitch(mock_coordinator, "1", "Missing Circuit") 228 | 229 | 230 | @pytest.mark.asyncio 231 | async def test_switch_coordinator_update_handling( 232 | hass: Any, enable_custom_integrations: Any 233 | ): 234 | """Test switch updates correctly when coordinator data changes.""" 235 | 236 | circuit = create_mock_circuit( 237 | circuit_id="1", 238 | name="Kitchen Outlets", 239 | relay_state=CircuitRelayState.CLOSED.name, 240 | ) 241 | 242 | circuits = {"1": circuit} 243 | mock_panel = create_mock_span_panel(circuits) 244 | 245 | mock_coordinator = MagicMock() 246 | mock_coordinator.data = mock_panel 247 | 248 | switch = SpanPanelCircuitsSwitch(mock_coordinator, "1", "Kitchen Outlets") 249 | 250 | # Add mock hass and entity registry to prevent "hass is None" error 251 | switch.hass = hass 252 | switch.entity_id = "switch.span_panel_kitchen_outlets_breaker" 253 | switch.registry_entry = MagicMock() 254 | 255 | # Add mock platform to prevent platform_name error 256 | mock_platform = MagicMock() 257 | mock_platform.platform_name = "switch" 258 | switch.platform = mock_platform 259 | 260 | # Initially should be on (CLOSED) 261 | assert switch.is_on is True 262 | 263 | # Change circuit state to OPEN 264 | circuit.relay_state = CircuitRelayState.OPEN.name 265 | 266 | # Trigger coordinator update (now won't fail due to hass being None) 267 | switch._handle_coordinator_update() 268 | 269 | # Switch should now be off 270 | assert switch.is_on is False 271 | 272 | 273 | @pytest.mark.asyncio 274 | async def test_circuit_name_change_triggers_reload_request( 275 | hass: Any, enable_custom_integrations: Any 276 | ): 277 | """Test that changing circuit name triggers integration reload.""" 278 | 279 | circuit = create_mock_circuit( 280 | circuit_id="1", 281 | name="Kitchen Outlets", 282 | relay_state=CircuitRelayState.CLOSED.name, 283 | ) 284 | 285 | circuits = {"1": circuit} 286 | mock_panel = create_mock_span_panel(circuits) 287 | 288 | mock_coordinator = MagicMock() 289 | mock_coordinator.data = mock_panel 290 | mock_coordinator.request_reload = MagicMock() 291 | 292 | switch = SpanPanelCircuitsSwitch(mock_coordinator, "1", "Kitchen Outlets") 293 | 294 | # Add mock hass and entity registry to prevent "hass is None" error 295 | switch.hass = hass 296 | switch.entity_id = "switch.span_panel_kitchen_outlets_breaker" 297 | switch.registry_entry = MagicMock() 298 | 299 | # Add mock platform to prevent platform_name error 300 | mock_platform = MagicMock() 301 | mock_platform.platform_name = "switch" 302 | switch.platform = mock_platform 303 | 304 | # Change circuit name 305 | circuit.name = "New Kitchen Outlets" 306 | 307 | # Trigger coordinator update (now won't fail due to hass being None) 308 | switch._handle_coordinator_update() 309 | 310 | # Should request reload due to name change 311 | mock_coordinator.request_reload.assert_called_once() 312 | -------------------------------------------------------------------------------- /tests/test_configuration_edge_cases.py: -------------------------------------------------------------------------------- 1 | """test_configuration_edge_cases. 2 | 3 | Configuration-related edge case tests for Span Panel integration. 4 | """ 5 | 6 | from typing import Any 7 | import pytest 8 | 9 | from tests.factories import ( 10 | SpanPanelApiResponseFactory, 11 | SpanPanelCircuitFactory, 12 | ) 13 | from tests.helpers import ( 14 | assert_entity_state, 15 | get_circuit_entity_id, 16 | patch_span_panel_dependencies, 17 | setup_span_panel_entry, 18 | trigger_coordinator_update, 19 | ) 20 | 21 | 22 | @pytest.fixture(autouse=True) 23 | def expected_lingering_timers(): 24 | """Fix expected lingering timers for tests.""" 25 | return True 26 | 27 | 28 | @pytest.mark.asyncio 29 | async def test_legacy_naming_scheme_compatibility( 30 | hass: Any, enable_custom_integrations: Any 31 | ): 32 | """Test backward compatibility with legacy naming scheme (pre-1.0.4).""" 33 | 34 | circuit_data = SpanPanelCircuitFactory.create_circuit( 35 | circuit_id="1", 36 | name="Kitchen Outlets", 37 | instant_power=245.3, 38 | ) 39 | 40 | mock_responses = SpanPanelApiResponseFactory.create_complete_panel_response( 41 | circuits=[circuit_data] 42 | ) 43 | 44 | # Configure entry to use ACTUAL legacy naming (pre-1.0.4 style) 45 | # Pre-1.0.4: no device prefix, circuit names (not numbers) 46 | options = { 47 | "use_device_prefix": False, # Legacy mode: no device prefix 48 | "use_circuit_numbers": False, # Legacy mode: use circuit names, not numbers 49 | } 50 | entry, _ = setup_span_panel_entry(hass, mock_responses, options=options) 51 | 52 | with patch_span_panel_dependencies(mock_responses): 53 | await hass.config_entries.async_setup(entry.entry_id) 54 | await hass.async_block_till_done() 55 | 56 | coordinator = hass.data["span_panel"][entry.entry_id]["coordinator"] 57 | await trigger_coordinator_update(coordinator) 58 | 59 | # Test the correct legacy behavior: no device prefix, circuit name based 60 | # Expected entity ID: sensor.kitchen_outlets_power (no "span_panel" prefix) 61 | power_entity_id = get_circuit_entity_id( 62 | "1", 63 | "Kitchen Outlets", 64 | "sensor", 65 | "power", 66 | use_circuit_numbers=False, 67 | use_device_prefix=False, 68 | ) 69 | assert_entity_state(hass, power_entity_id, "245.3") 70 | 71 | 72 | @pytest.mark.asyncio 73 | async def test_config_entry_migration_from_legacy( 74 | hass: Any, enable_custom_integrations: Any 75 | ): 76 | """Test migration of config entry from legacy format to new format.""" 77 | 78 | circuit_data = SpanPanelCircuitFactory.create_circuit( 79 | circuit_id="1", 80 | name="Test Circuit", 81 | instant_power=100.0, 82 | ) 83 | 84 | mock_responses = SpanPanelApiResponseFactory.create_complete_panel_response( 85 | circuits=[circuit_data] 86 | ) 87 | 88 | # Start with legacy config (no options set, but include defaults for the integration) 89 | options = { 90 | "use_device_prefix": True, # Current default 91 | "use_circuit_numbers": False, # Current default 92 | } 93 | entry, _ = setup_span_panel_entry(hass, mock_responses, options=options) 94 | 95 | with patch_span_panel_dependencies(mock_responses): 96 | await hass.config_entries.async_setup(entry.entry_id) 97 | await hass.async_block_till_done() 98 | 99 | # Verify entities are created with expected naming 100 | coordinator = hass.data["span_panel"][entry.entry_id]["coordinator"] 101 | await trigger_coordinator_update(coordinator) 102 | 103 | power_entity_id = get_circuit_entity_id( 104 | "1", "Test Circuit", "sensor", "power", use_device_prefix=True 105 | ) 106 | assert hass.states.get(power_entity_id) is not None 107 | 108 | 109 | @pytest.mark.asyncio 110 | async def test_invalid_configuration_options( 111 | hass: Any, enable_custom_integrations: Any 112 | ): 113 | """Test handling of invalid configuration options.""" 114 | 115 | mock_responses = SpanPanelApiResponseFactory.create_complete_panel_response() 116 | 117 | # Configure entry with invalid option values 118 | options = { 119 | "use_device_prefix": "invalid_string", # Should be boolean 120 | "use_circuit_numbers": None, # Should be boolean 121 | "invalid_option": True, # Unknown option 122 | } 123 | entry, _ = setup_span_panel_entry(hass, mock_responses, options=options) 124 | 125 | with patch_span_panel_dependencies(mock_responses): 126 | # Setup should handle invalid options gracefully 127 | result = await hass.config_entries.async_setup(entry.entry_id) 128 | assert result is True 129 | await hass.async_block_till_done() 130 | 131 | # Integration should fall back to default values for invalid options 132 | coordinator = hass.data["span_panel"][entry.entry_id]["coordinator"] 133 | assert coordinator is not None 134 | -------------------------------------------------------------------------------- /tests/test_door_sensor_integration.py: -------------------------------------------------------------------------------- 1 | """Integration tests for the door state tamper sensor.""" 2 | 3 | from typing import Any 4 | import pytest 5 | 6 | from custom_components.span_panel.const import ( 7 | SYSTEM_DOOR_STATE_CLOSED, 8 | SYSTEM_DOOR_STATE_OPEN, 9 | ) 10 | from tests.factories import SpanPanelApiResponseFactory, SpanPanelStatusFactory 11 | from tests.helpers import ( 12 | assert_entity_state, 13 | patch_span_panel_dependencies, 14 | setup_span_panel_entry, 15 | trigger_coordinator_update, 16 | ) 17 | 18 | 19 | @pytest.fixture(autouse=True) 20 | def expected_lingering_timers(): 21 | """Fix expected lingering timers for tests.""" 22 | return True 23 | 24 | 25 | @pytest.mark.asyncio 26 | async def test_door_state_tamper_sensor_closed( 27 | hass: Any, enable_custom_integrations: Any 28 | ): 29 | """Test that door state tamper sensor reports clear when door is closed.""" 30 | # Create mock responses with door closed 31 | mock_responses = SpanPanelApiResponseFactory.create_complete_panel_response( 32 | status_data=SpanPanelStatusFactory.create_status( 33 | door_state=SYSTEM_DOOR_STATE_CLOSED 34 | ) 35 | ) 36 | 37 | entry, _ = setup_span_panel_entry(hass, mock_responses) 38 | 39 | with patch_span_panel_dependencies(mock_responses): 40 | # Setup the integration 41 | result = await hass.config_entries.async_setup(entry.entry_id) 42 | assert result is True 43 | await hass.async_block_till_done() 44 | 45 | # Trigger coordinator update to get proper data 46 | coordinator = hass.data["span_panel"][entry.entry_id]["coordinator"] 47 | await trigger_coordinator_update(coordinator) 48 | 49 | # Check that door state tamper sensor is clear (OFF) when door is closed 50 | assert_entity_state(hass, "binary_sensor.door_state", "off") 51 | 52 | # Verify the sensor has the correct device class 53 | state = hass.states.get("binary_sensor.door_state") 54 | assert state is not None 55 | assert state.attributes.get("device_class") == "tamper" 56 | 57 | 58 | @pytest.mark.asyncio 59 | async def test_door_state_tamper_sensor_open( 60 | hass: Any, enable_custom_integrations: Any 61 | ): 62 | """Test that door state tamper sensor reports tampered when door is open.""" 63 | # Create mock responses with door open 64 | mock_responses = SpanPanelApiResponseFactory.create_complete_panel_response( 65 | status_data=SpanPanelStatusFactory.create_status( 66 | door_state=SYSTEM_DOOR_STATE_OPEN 67 | ) 68 | ) 69 | 70 | entry, _ = setup_span_panel_entry(hass, mock_responses) 71 | 72 | with patch_span_panel_dependencies(mock_responses): 73 | # Setup the integration 74 | result = await hass.config_entries.async_setup(entry.entry_id) 75 | assert result is True 76 | await hass.async_block_till_done() 77 | 78 | # Trigger coordinator update to get proper data 79 | coordinator = hass.data["span_panel"][entry.entry_id]["coordinator"] 80 | await trigger_coordinator_update(coordinator) 81 | 82 | # Check that door state tamper sensor is tampered (ON) when door is open 83 | assert_entity_state(hass, "binary_sensor.door_state", "on") 84 | 85 | # Verify the sensor has the correct device class 86 | state = hass.states.get("binary_sensor.door_state") 87 | assert state is not None 88 | assert state.attributes.get("device_class") == "tamper" 89 | 90 | 91 | @pytest.mark.asyncio 92 | async def test_door_state_tamper_sensor_unknown( 93 | hass: Any, enable_custom_integrations: Any 94 | ): 95 | """Test that door state tamper sensor remains unknown when door state is unknown.""" 96 | # Create mock responses with unknown door state 97 | mock_responses = SpanPanelApiResponseFactory.create_complete_panel_response( 98 | status_data=SpanPanelStatusFactory.create_status(door_state="UNKNOWN") 99 | ) 100 | 101 | entry, _ = setup_span_panel_entry(hass, mock_responses) 102 | 103 | with patch_span_panel_dependencies(mock_responses): 104 | # Setup the integration 105 | result = await hass.config_entries.async_setup(entry.entry_id) 106 | assert result is True 107 | await hass.async_block_till_done() 108 | 109 | # Trigger coordinator update to get proper data 110 | coordinator = hass.data["span_panel"][entry.entry_id]["coordinator"] 111 | await trigger_coordinator_update(coordinator) 112 | 113 | # Check that door state tamper sensor remains unknown when state is unknown 114 | state = hass.states.get("binary_sensor.door_state") 115 | assert state is not None 116 | assert state.state == "unknown" 117 | 118 | # Verify the sensor has the correct device class 119 | assert state.attributes.get("device_class") == "tamper" 120 | -------------------------------------------------------------------------------- /tests/test_error_handling.py: -------------------------------------------------------------------------------- 1 | """test_error_handling. 2 | 3 | Tests for error handling scenarios in the Span Panel integration. 4 | """ 5 | 6 | from typing import Any 7 | from unittest.mock import AsyncMock, patch 8 | import pytest 9 | import aiohttp 10 | from homeassistant.config_entries import ConfigEntryState 11 | 12 | from tests.factories import SpanPanelApiResponseFactory 13 | from tests.helpers import ( 14 | patch_span_panel_dependencies, 15 | setup_span_panel_entry, 16 | trigger_coordinator_update, 17 | ) 18 | 19 | 20 | @pytest.fixture(autouse=True) 21 | def expected_lingering_timers(): 22 | """Fix expected lingering timers for tests.""" 23 | return True 24 | 25 | 26 | @pytest.mark.asyncio 27 | async def test_api_connection_timeout_during_setup( 28 | hass: Any, enable_custom_integrations: Any 29 | ): 30 | """Test that setup fails gracefully when API connection times out.""" 31 | entry, _ = setup_span_panel_entry(hass) 32 | 33 | # Mock API to raise timeout 34 | with patch("custom_components.span_panel.SpanPanel") as mock_span_panel_class: 35 | mock_span_panel = AsyncMock() 36 | mock_span_panel.update.side_effect = aiohttp.ClientTimeout() 37 | mock_span_panel_class.return_value = mock_span_panel 38 | 39 | # Setup should fail gracefully 40 | result = await hass.config_entries.async_setup(entry.entry_id) 41 | assert result is False 42 | assert entry.state == ConfigEntryState.SETUP_RETRY 43 | 44 | 45 | @pytest.mark.asyncio 46 | async def test_api_connection_refused_during_setup( 47 | hass: Any, enable_custom_integrations: Any 48 | ): 49 | """Test that setup fails gracefully when API connection is refused.""" 50 | entry, _ = setup_span_panel_entry(hass) 51 | 52 | # Mock API to raise connection error 53 | with patch("custom_components.span_panel.SpanPanel") as mock_span_panel_class: 54 | mock_span_panel = AsyncMock() 55 | mock_span_panel.update.side_effect = aiohttp.ClientError("Connection refused") 56 | mock_span_panel_class.return_value = mock_span_panel 57 | 58 | # Setup should fail and trigger retry 59 | result = await hass.config_entries.async_setup(entry.entry_id) 60 | assert result is False 61 | assert entry.state == ConfigEntryState.SETUP_RETRY 62 | 63 | 64 | @pytest.mark.asyncio 65 | async def test_coordinator_update_api_failure( 66 | hass: Any, enable_custom_integrations: Any 67 | ): 68 | """Test coordinator behavior when API calls fail during updates.""" 69 | mock_responses = SpanPanelApiResponseFactory.create_complete_panel_response() 70 | entry, _ = setup_span_panel_entry(hass, mock_responses) 71 | 72 | with patch_span_panel_dependencies(mock_responses) as (mock_panel, mock_api): 73 | # Setup integration successfully first 74 | await hass.config_entries.async_setup(entry.entry_id) 75 | await hass.async_block_till_done() 76 | 77 | coordinator = hass.data["span_panel"][entry.entry_id]["coordinator"] 78 | 79 | # Make API calls fail on next update 80 | mock_api.get_panel_data.side_effect = aiohttp.ClientError("API Error") 81 | mock_api.get_circuits_data.side_effect = aiohttp.ClientError("API Error") 82 | 83 | # Trigger update - should handle errors gracefully 84 | await trigger_coordinator_update(coordinator) 85 | 86 | # Coordinator should still be available but may show as unavailable 87 | assert coordinator is not None 88 | 89 | 90 | @pytest.mark.asyncio 91 | async def test_invalid_authentication_handling( 92 | hass: Any, enable_custom_integrations: Any 93 | ): 94 | """Test handling of authentication failures.""" 95 | entry, _ = setup_span_panel_entry(hass) 96 | 97 | with patch("custom_components.span_panel.SpanPanel") as mock_span_panel_class: 98 | mock_span_panel = AsyncMock() 99 | # Use a simpler approach - just raise a general client error for auth issues 100 | mock_span_panel.update.side_effect = aiohttp.ClientError("401: Unauthorized") 101 | mock_span_panel_class.return_value = mock_span_panel 102 | 103 | # Setup should fail due to auth error 104 | result = await hass.config_entries.async_setup(entry.entry_id) 105 | assert result is False 106 | 107 | 108 | @pytest.mark.asyncio 109 | async def test_network_disconnection_recovery( 110 | hass: Any, enable_custom_integrations: Any 111 | ): 112 | """Test recovery behavior after network disconnection.""" 113 | mock_responses = SpanPanelApiResponseFactory.create_complete_panel_response() 114 | entry, _ = setup_span_panel_entry(hass, mock_responses) 115 | 116 | with patch_span_panel_dependencies(mock_responses) as (mock_panel, mock_api): 117 | # Setup successfully 118 | await hass.config_entries.async_setup(entry.entry_id) 119 | await hass.async_block_till_done() 120 | 121 | coordinator = hass.data["span_panel"][entry.entry_id]["coordinator"] 122 | 123 | # Simulate network disconnection 124 | mock_api.get_panel_data.side_effect = aiohttp.ClientError("Network unreachable") 125 | 126 | # Update should fail 127 | await trigger_coordinator_update(coordinator) 128 | 129 | # Simulate network recovery 130 | mock_api.get_panel_data.side_effect = None 131 | mock_api.get_panel_data.return_value = mock_responses["panel"] 132 | 133 | # Update should succeed again 134 | await trigger_coordinator_update(coordinator) 135 | 136 | # Verify recovery 137 | mock_api.get_panel_data.assert_called() 138 | -------------------------------------------------------------------------------- /tests/test_factories.py: -------------------------------------------------------------------------------- 1 | """Tests for the factory classes and their use of constants.""" 2 | 3 | from custom_components.span_panel.const import ( 4 | CURRENT_RUN_CONFIG, 5 | DSM_GRID_STATE, 6 | DSM_STATE, 7 | MAIN_RELAY_STATE, 8 | SYSTEM_DOOR_STATE_CLOSED, 9 | SYSTEM_DOOR_STATE_OPEN, 10 | SYSTEM_ETHERNET_LINK, 11 | SYSTEM_CELLULAR_LINK, 12 | SYSTEM_WIFI_LINK, 13 | ) 14 | from custom_components.span_panel.span_panel_hardware_status import ( 15 | SpanPanelHardwareStatus, 16 | ) 17 | from tests.factories import ( 18 | SpanPanelApiResponseFactory, 19 | SpanPanelDataFactory, 20 | SpanPanelStatusFactory, 21 | ) 22 | from custom_components.span_panel.binary_sensor import BINARY_SENSORS 23 | 24 | 25 | def test_panel_factory_uses_correct_constants(): 26 | """Test that panel factory uses the correct constant keys.""" 27 | panel_data = SpanPanelDataFactory.create_on_grid_panel_data() 28 | 29 | # Verify that the factory uses the constant keys 30 | assert CURRENT_RUN_CONFIG in panel_data 31 | assert DSM_GRID_STATE in panel_data 32 | assert DSM_STATE in panel_data 33 | assert MAIN_RELAY_STATE in panel_data 34 | 35 | # Verify expected values 36 | assert panel_data[CURRENT_RUN_CONFIG] == "PANEL_ON_GRID" 37 | assert panel_data[DSM_GRID_STATE] == "DSM_GRID_UP" 38 | assert panel_data[DSM_STATE] == "DSM_ON_GRID" 39 | assert panel_data[MAIN_RELAY_STATE] == "CLOSED" 40 | 41 | 42 | def test_status_factory_uses_correct_constants(): 43 | """Test that status factory uses the correct constant values.""" 44 | status_data = SpanPanelStatusFactory.create_status() 45 | 46 | # Verify that the factory uses the correct constant values 47 | assert status_data["system"]["doorState"] == SYSTEM_DOOR_STATE_CLOSED 48 | 49 | # Verify network link constants are used as keys 50 | assert SYSTEM_ETHERNET_LINK in status_data["network"] 51 | assert SYSTEM_WIFI_LINK in status_data["network"] 52 | assert SYSTEM_CELLULAR_LINK in status_data["network"] 53 | 54 | # Verify expected structure for API compatibility 55 | assert "software" in status_data 56 | assert "firmwareVersion" in status_data["software"] 57 | assert "system" in status_data 58 | assert "network" in status_data 59 | 60 | # Verify default network values 61 | assert status_data["network"][SYSTEM_ETHERNET_LINK] is True 62 | assert status_data["network"][SYSTEM_WIFI_LINK] is True 63 | assert status_data["network"][SYSTEM_CELLULAR_LINK] is False 64 | 65 | 66 | def test_status_factory_network_configuration(): 67 | """Test that status factory can create different network configurations.""" 68 | # Test with all connections disabled 69 | status_offline = SpanPanelStatusFactory.create_status( 70 | ethernet_link=False, 71 | wifi_link=False, 72 | cellular_link=False, 73 | ) 74 | 75 | assert status_offline["network"][SYSTEM_ETHERNET_LINK] is False 76 | assert status_offline["network"][SYSTEM_WIFI_LINK] is False 77 | assert status_offline["network"][SYSTEM_CELLULAR_LINK] is False 78 | 79 | # Test with only cellular enabled 80 | status_cellular = SpanPanelStatusFactory.create_status( 81 | ethernet_link=False, 82 | wifi_link=False, 83 | cellular_link=True, 84 | ) 85 | 86 | assert status_cellular["network"][SYSTEM_ETHERNET_LINK] is False 87 | assert status_cellular["network"][SYSTEM_WIFI_LINK] is False 88 | assert status_cellular["network"][SYSTEM_CELLULAR_LINK] is True 89 | 90 | 91 | def test_status_factory_integration_with_hardware_status(): 92 | """Test that status factory data works correctly with SpanPanelHardwareStatus.""" 93 | # Test with mixed network connectivity 94 | status_data = SpanPanelStatusFactory.create_status( 95 | ethernet_link=True, 96 | wifi_link=False, 97 | cellular_link=True, 98 | software_version="2.5.1", 99 | serial_number="TEST123456789", 100 | ) 101 | 102 | # Create actual SpanPanelHardwareStatus object 103 | hardware_status = SpanPanelHardwareStatus.from_dict(status_data) 104 | 105 | # Verify that network constants are properly mapped to boolean properties 106 | assert hardware_status.is_ethernet_connected is True 107 | assert hardware_status.is_wifi_connected is False 108 | assert hardware_status.is_cellular_connected is True 109 | 110 | # Verify other properties work as expected 111 | assert hardware_status.firmware_version == "2.5.1" 112 | assert hardware_status.serial_number == "TEST123456789" 113 | assert hardware_status.door_state == SYSTEM_DOOR_STATE_CLOSED 114 | 115 | 116 | def test_door_state_tamper_sensor_logic(): 117 | """Test that door state works correctly as a tamper sensor.""" 118 | # Test door CLOSED (tamper sensor should be OFF/clear) 119 | status_closed = SpanPanelStatusFactory.create_status( 120 | door_state=SYSTEM_DOOR_STATE_CLOSED 121 | ) 122 | hardware_status_closed = SpanPanelHardwareStatus.from_dict(status_closed) 123 | 124 | assert hardware_status_closed.door_state == SYSTEM_DOOR_STATE_CLOSED 125 | assert hardware_status_closed.is_door_closed is True 126 | # Tamper sensor logic: not is_door_closed -> not True -> False (clear/OFF) 127 | tamper_sensor_value_closed = not hardware_status_closed.is_door_closed 128 | assert tamper_sensor_value_closed is False # Tamper clear when door closed 129 | 130 | # Test door OPEN (tamper sensor should be ON/detected) 131 | status_open = SpanPanelStatusFactory.create_status( 132 | door_state=SYSTEM_DOOR_STATE_OPEN 133 | ) 134 | hardware_status_open = SpanPanelHardwareStatus.from_dict(status_open) 135 | 136 | assert hardware_status_open.door_state == SYSTEM_DOOR_STATE_OPEN 137 | assert hardware_status_open.is_door_closed is False 138 | # Tamper sensor logic: not is_door_closed -> not False -> True (tampered/ON) 139 | tamper_sensor_value_open = not hardware_status_open.is_door_closed 140 | assert tamper_sensor_value_open is True # Tamper detected when door open 141 | 142 | # Test unknown door state (tamper sensor should be unavailable) 143 | status_unknown = SpanPanelStatusFactory.create_status(door_state="UNKNOWN") 144 | hardware_status_unknown = SpanPanelHardwareStatus.from_dict(status_unknown) 145 | 146 | assert hardware_status_unknown.door_state == "UNKNOWN" 147 | assert hardware_status_unknown.is_door_closed is None 148 | # When is_door_closed is None, the binary sensor should be unavailable 149 | # (This matches the binary sensor logic that checks for None) 150 | 151 | 152 | def test_door_state_binary_sensor_availability(): 153 | """Test that door state binary sensor handles availability correctly.""" 154 | 155 | # Find the door state sensor description 156 | door_sensor = None 157 | for sensor in BINARY_SENSORS: 158 | if sensor.key == "doorState": 159 | door_sensor = sensor 160 | break 161 | 162 | assert door_sensor is not None, "Door state sensor should be defined" 163 | assert door_sensor.device_class is not None 164 | assert door_sensor.device_class.value == "tamper" 165 | 166 | # Test the actual value_fn logic used by the binary sensor 167 | 168 | # Test with door closed - should return False (tamper clear) 169 | status_closed = SpanPanelStatusFactory.create_status( 170 | door_state=SYSTEM_DOOR_STATE_CLOSED 171 | ) 172 | hardware_closed = SpanPanelHardwareStatus.from_dict(status_closed) 173 | sensor_value_closed = door_sensor.value_fn(hardware_closed) 174 | assert sensor_value_closed is False # Tamper clear 175 | 176 | # Test with door open - should return True (tamper detected) 177 | status_open = SpanPanelStatusFactory.create_status( 178 | door_state=SYSTEM_DOOR_STATE_OPEN 179 | ) 180 | hardware_open = SpanPanelHardwareStatus.from_dict(status_open) 181 | sensor_value_open = door_sensor.value_fn(hardware_open) 182 | assert sensor_value_open is True # Tamper detected 183 | 184 | # Test with unknown state - should return None (unavailable) 185 | status_unknown = SpanPanelStatusFactory.create_status(door_state="UNKNOWN") 186 | hardware_unknown = SpanPanelHardwareStatus.from_dict(status_unknown) 187 | sensor_value_unknown = door_sensor.value_fn(hardware_unknown) 188 | assert sensor_value_unknown is None # Unavailable 189 | 190 | 191 | def test_complete_response_factory_structure(): 192 | """Test that the complete response factory creates the expected structure.""" 193 | response = SpanPanelApiResponseFactory.create_complete_panel_response() 194 | 195 | # Verify top-level structure 196 | assert "circuits" in response 197 | assert "panel" in response 198 | assert "status" in response 199 | assert "battery" in response 200 | 201 | # Verify panel data uses constants 202 | panel_data = response["panel"] 203 | assert CURRENT_RUN_CONFIG in panel_data 204 | assert DSM_GRID_STATE in panel_data 205 | assert DSM_STATE in panel_data 206 | assert MAIN_RELAY_STATE in panel_data 207 | 208 | # Verify status data uses constants and correct structure 209 | status_data = response["status"] 210 | assert status_data["system"]["doorState"] == SYSTEM_DOOR_STATE_CLOSED 211 | assert "firmwareVersion" in status_data["software"] # API field, not constant 212 | 213 | # Verify network data uses constants as keys 214 | assert SYSTEM_ETHERNET_LINK in status_data["network"] 215 | assert SYSTEM_WIFI_LINK in status_data["network"] 216 | assert SYSTEM_CELLULAR_LINK in status_data["network"] 217 | -------------------------------------------------------------------------------- /tests/test_pattern_detection.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """Test script to verify entity naming pattern detection logic.""" 3 | 4 | import sys 5 | import os 6 | 7 | # Add the custom_components path to sys.path 8 | sys.path.insert(0, os.path.join(os.path.dirname(__file__), "custom_components")) 9 | 10 | from custom_components.span_panel.const import ( 11 | USE_CIRCUIT_NUMBERS, 12 | EntityNamingPattern, 13 | ) 14 | 15 | 16 | def test_pattern_detection(): 17 | """Test the pattern detection logic.""" 18 | 19 | class MockEntry: 20 | def __init__(self, options): 21 | self.options = options 22 | 23 | class MockOptionsFlowHandler: 24 | def __init__(self, entry): 25 | self.entry = entry 26 | 27 | def _get_current_naming_pattern(self) -> str: 28 | """Determine the current entity naming pattern from configuration flags.""" 29 | use_circuit_numbers = self.entry.options.get(USE_CIRCUIT_NUMBERS, False) 30 | 31 | if use_circuit_numbers: 32 | return EntityNamingPattern.CIRCUIT_NUMBERS.value 33 | else: 34 | return EntityNamingPattern.FRIENDLY_NAMES.value 35 | 36 | # Test cases 37 | test_cases = [ 38 | { 39 | "name": "New installation (1.0.9+)", 40 | "options": {USE_CIRCUIT_NUMBERS: True, "use_device_prefix": True}, 41 | "expected": EntityNamingPattern.CIRCUIT_NUMBERS.value, 42 | }, 43 | { 44 | "name": "Post-1.0.4 installation", 45 | "options": {USE_CIRCUIT_NUMBERS: False, "use_device_prefix": True}, 46 | "expected": EntityNamingPattern.FRIENDLY_NAMES.value, 47 | }, 48 | { 49 | "name": "Pre-1.0.4 installation", 50 | "options": {USE_CIRCUIT_NUMBERS: False, "use_device_prefix": False}, 51 | "expected": EntityNamingPattern.FRIENDLY_NAMES.value, 52 | }, 53 | { 54 | "name": "Empty options (default)", 55 | "options": {}, 56 | "expected": EntityNamingPattern.FRIENDLY_NAMES.value, 57 | }, 58 | { 59 | "name": "Only circuit numbers flag", 60 | "options": {USE_CIRCUIT_NUMBERS: True}, 61 | "expected": EntityNamingPattern.CIRCUIT_NUMBERS.value, 62 | }, 63 | ] 64 | 65 | print("Testing entity naming pattern detection...") 66 | print() 67 | 68 | all_passed = True 69 | for test_case in test_cases: 70 | entry = MockEntry(test_case["options"]) 71 | handler = MockOptionsFlowHandler(entry) 72 | 73 | result = handler._get_current_naming_pattern() 74 | passed = result == test_case["expected"] 75 | 76 | status = "✅ PASS" if passed else "❌ FAIL" 77 | print(f"{status} {test_case['name']}") 78 | print(f" Options: {test_case['options']}") 79 | print(f" Expected: {test_case['expected']}") 80 | print(f" Got: {result}") 81 | print() 82 | 83 | if not passed: 84 | all_passed = False 85 | 86 | print("=" * 50) 87 | if all_passed: 88 | print("✅ All tests passed! Pattern detection logic is correct.") 89 | else: 90 | print("❌ Some tests failed! Check the logic.") 91 | 92 | assert all_passed, "Some pattern detection tests failed" 93 | 94 | 95 | if __name__ == "__main__": 96 | test_pattern_detection() 97 | -------------------------------------------------------------------------------- /tests/test_synthetic_detection.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """Simple test script for synthetic entity detection.""" 3 | 4 | import re 5 | 6 | 7 | def _is_synthetic_entity_id(object_id: str) -> bool: 8 | """Check if an entity ID belongs to a synthetic entity (solar inverter, etc.).""" 9 | # Synthetic entities have these patterns: 10 | # 1. Multi-circuit patterns: circuit_30_32_suffix (circuit numbers mode) 11 | # 2. Named patterns: solar_inverter_suffix (friendly names mode) 12 | 13 | # Pattern for multi-circuit entities with circuit numbers: circuit_30_32_suffix 14 | if re.search(r"circuit_\d+_\d+_", object_id): 15 | print(f"Detected multi-circuit synthetic entity (circuit numbers): {object_id}") 16 | return True 17 | 18 | # Pattern for named synthetic entities: solar_inverter_, battery_bank_, etc. 19 | synthetic_name_patterns = [ 20 | "solar_inverter_", 21 | "battery_bank_", 22 | "circuit_group_", 23 | ] 24 | 25 | for pattern in synthetic_name_patterns: 26 | if pattern in object_id: 27 | print(f"Detected named synthetic entity: {object_id}") 28 | return True 29 | 30 | return False 31 | 32 | 33 | def test_synthetic_detection(): 34 | """Test synthetic entity detection.""" 35 | test_cases = [ 36 | # Solar sensor entity IDs 37 | ("span_panel_circuit_30_32_energy_consumed", True), 38 | ("span_panel_circuit_30_32_energy_produced", True), 39 | ("span_panel_circuit_30_32_instant_power", True), 40 | ("span_panel_solar_inverter_energy_consumed", True), 41 | ("span_panel_solar_inverter_energy_produced", True), 42 | ("span_panel_solar_inverter_instant_power", True), 43 | # Regular circuit entities (should not be synthetic) 44 | ("span_panel_circuit_15_power", False), 45 | ("span_panel_kitchen_outlets_power", False), 46 | ("span_panel_circuit_15_breaker", False), 47 | # Panel-level entities (should not be synthetic) 48 | ("span_panel_current_power", False), 49 | ("span_panel_main_meter_produced_energy", False), 50 | ("span_panel_software_version", False), 51 | ] 52 | 53 | print("Testing synthetic entity detection:") 54 | for entity_id, expected in test_cases: 55 | result = _is_synthetic_entity_id(entity_id) 56 | status = "✓" if result == expected else "✗" 57 | print(f" {status} {entity_id}: {result} (expected {expected})") 58 | 59 | 60 | if __name__ == "__main__": 61 | test_synthetic_detection() 62 | --------------------------------------------------------------------------------