├── .github ├── linters │ └── .jscpd.json └── workflows │ ├── .isort.cfg │ ├── hacs.yml │ ├── lint.yml │ └── main.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .vscode ├── settings.json.example └── tasks.json ├── LICENSE ├── README.md ├── custom_components └── span_panel │ ├── __init__.py │ ├── binary_sensor.py │ ├── config_flow.py │ ├── const.py │ ├── coordinator.py │ ├── exceptions.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 │ ├── util.py │ └── version.py ├── hacs.json ├── mypy.ini ├── poetry.lock ├── pyproject.toml └── scripts ├── run_mypy.py └── setup_env.sh /.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 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | workflow_dispatch: 11 | workflow_call: {} 12 | 13 | permissions: 14 | contents: read 15 | checks: write 16 | actions: read 17 | statuses: write 18 | 19 | jobs: 20 | lint: 21 | runs-on: ubuntu-latest 22 | steps: 23 | - name: Checkout code 24 | uses: actions/checkout@v4 25 | - name: Set up Python 26 | uses: actions/setup-python@v5 27 | with: 28 | python-version: "3.12" 29 | 30 | - name: Install dependencies 31 | run: | 32 | python -m pip install --upgrade pip 33 | pip install ruff isort mypy bandit 34 | 35 | - name: Set up Node.js 36 | uses: actions/setup-node@v4 37 | with: 38 | node-version: "v20" 39 | 40 | - name: Install Prettier 41 | run: npm install -g prettier 42 | 43 | - name: Run ruff 44 | run: ruff check . 45 | 46 | - name: Run isort 47 | run: isort check . 48 | 49 | - name: Run bandit 50 | run: bandit -r . 51 | 52 | - name: Run prettier 53 | run: prettier --check "**/*.{js,jsx,ts,tsx,json,css,scss,md}" 54 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Main 3 | 4 | on: 5 | pull_request: 6 | branches: 7 | - main 8 | push: 9 | branches: 10 | - main 11 | workflow_dispatch: 12 | 13 | permissions: 14 | contents: read 15 | packages: read 16 | statuses: write 17 | checks: write 18 | actions: read 19 | 20 | jobs: 21 | lint: 22 | name: Lint Code Base 23 | uses: ./.github/workflows/lint.yml 24 | 25 | hacs: 26 | name: HACS Action 27 | needs: lint 28 | uses: ./.github/workflows/hacs.yml 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .*sw* 2 | *.pyc 3 | /.project 4 | .DS_Store 5 | .vscode/* 6 | !.vscode/settings.json.example 7 | !.vscode/tasks.json 8 | .direnv/ 9 | env/ 10 | .envrc 11 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: local 3 | hooks: 4 | - id: isort 5 | name: isort 6 | entry: poetry run isort 7 | language: system 8 | types: [python] 9 | 10 | - id: ruff 11 | name: ruff 12 | entry: poetry run ruff check 13 | args: 14 | - --fix 15 | language: system 16 | types: [python] 17 | 18 | - id: mypy-with-env-check 19 | name: mypy with HA_CORE_PATH check 20 | entry: poetry run python scripts/run_mypy.py 21 | language: system 22 | types: [python] 23 | 24 | - repo: https://github.com/pre-commit/mirrors-prettier 25 | rev: v3.0.3 26 | hooks: 27 | - id: prettier 28 | -------------------------------------------------------------------------------- /.vscode/settings.json.example: -------------------------------------------------------------------------------- 1 | { 2 | "python.autoComplete.extraPaths": [ 3 | "/your/path/to/HA/core" 4 | ], 5 | "python.analysis.extraPaths": [ 6 | "/your/path/to/HA/core" 7 | ], 8 | "files.associations": { 9 | "*.yaml": "home-assistant" 10 | }, 11 | "git.enabled": true, 12 | "git.env": { 13 | "HA_CORE_PATH": "/your/path/to/HA/core", 14 | "PATH": "/your/path/to/poetry:${env:PATH}" 15 | } -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "Pre-commit", 6 | "type": "shell", 7 | "command": "pre-commit run ruff --all-files", 8 | "group": { 9 | "kind": "test", 10 | "isDefault": true 11 | }, 12 | "presentation": { 13 | "reveal": "always", 14 | "panel": "new" 15 | }, 16 | "problemMatcher": [] 17 | } 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SPAN Panel Integration for Home Assistant 2 | 3 | [Home Assistant](https://www.home-assistant.io/) Integration for [SPAN Panel](https://www.span.io/panel), a smart electrical panel that provides circuit-level monitoring and control of your home's electrical system. 4 | 5 | [![hacs_badge](https://img.shields.io/badge/HACS-Custom-orange.svg)](https://github.com/custom-components/hacs) [![Python](https://img.shields.io/badge/python-3.12-blue.svg)](https://www.python.org/downloads/release/python-3120/) [![Ruff](https://img.shields.io/badge/code%20style-ruff-000000.svg)](https://github.com/astral-sh/ruff) [![Mypy](https://img.shields.io/badge/mypy-checked-blue)](http://mypy-lang.org/) [![isort](https://img.shields.io/badge/%20imports-isort-%231674b1?style=flat&labelColor=ef8336)](https://pycqa.github.io/isort/) [![prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg)](https://github.com/prettier/prettier) 6 | 7 | ## Notice - This Repository will be **_retired_** as all of its changes are now consolidated into the defaults HACs repository which has moved to SpanPanel organization as a joint effort 8 | 9 | **_To get any future updates past v1.0.4 in this repository (which is effectively the same features/fixes in SpanPanel/span v1.0.6a) use the default HACs SPAN repository by removing the custom repository and reloading the default. While there is no urgency as this repository is stable and there are no burning fires, we apologize for any inconvienence but in the end more committers is to everyone's advantage and having two camps is not helpful._** 10 | 11 | There is a workaround to keep the old entity names if you don’t want to fix them up and to get back to the default repo but again you won’t have the mass rename capability: 12 | 13 | 1. Without uninstalling the custom repository or removing the SPAN config install/download the default HACs SPAN repository latest version (22 stars) which is effectively the same as 1.0.4 of the custom repo (33 stars). When you download the default you are overlaying the files over the top of the custom repo files. 14 | 2. Reboot HA and you’ll see version 1.0.6 for the default HACs SPAN integration 15 | 3. Remove the HACs custom repository entry only (not the SPAN integration config) using the 3 dots in the upper right of the HAC’s screen ->Custom Repository. 16 | 17 | --- 18 | 19 | --- 20 | 21 | --- 22 | 23 | As SPAN has not published a documented API, we cannot guarantee this integration will work for you. The integration may break as your panel is updated if SPAN changes the API in an incompatible way. 24 | 25 | The author(s) will try to keep this integration working, but cannot provide technical support for either SPAN or your homes electrical system. The software is provided as-is with no warranty or guarantee of performance or suitability to your particular setting. 26 | 27 | What this integration does do is provide the user Home Assistant sensors and controls that are useful in understanding an installations power consumption, energy usage, and control panel circuits. 28 | 29 | ## Prerequisites 30 | 31 | - [Home Assistant](https://www.home-assistant.io/) installed 32 | - [HACS](https://hacs.xyz/) installed 33 | - SPAN Panel installed and connected to your network 34 | - SPAN Panel's IP address 35 | 36 | ## Features 37 | 38 | ### Available Devices & Entities 39 | 40 | This integration will provide a device for your SPAN panel. This device will have entities for: 41 | 42 | - User Managed Circuits 43 | - On/Off Switch (user managed circuits) 44 | - Priority Selector (user managed circuits) 45 | - Power Sensors 46 | - Power Usage / Generation (Watts) 47 | - Energy Usage / Generation (wH) 48 | - Panel and Grid Status 49 | - Main Relay State (e.g., CLOSED) 50 | - Current Run Config (e.g., PANEL_ON_GRID) 51 | - DSM State (e.g., DSM_GRID_UP) 52 | - DSM Grid State (e.g., DSM_ON_GRID) 53 | - Network Connectivity Status (Wi-Fi, Wired, & Cellular) 54 | - Door State (device class is tamper) 55 | - Storage Battery 56 | - Battery percentage (options configuration) 57 | 58 | ## Installation 59 | 60 | 1. Install [HACS](https://hacs.xyz/) 61 | 2. Go to HACS, select `Integrations` 62 | 3. This repository is not currently the default in HACs so you need to use it as a custom repository (We need two HACs developers to [approve](https://github.com/hacs/default/pull/2560) it). Before installing this repository delete configurations for other similar repositories and remove them from HACs (two steps) and restart Home Assistant. 63 | 4. Once you have removed conflicting repositories use the the three dots in the upper right of the HACs screen and add a custom repository with the URL of this repository. 64 | 5. Select the repository you added in the list of integrations in HACS and select "Download". You can follow the URL to ensure you have the repository you want. 65 | 6. Restart Home Assistant. 66 | 7. In the Home Assistant UI go to `Settings`. 67 | 8. Click `Devices & Services` and you should see this integration. 68 | 9. Click `+ Add Integration`. 69 | 10. Search for "Span". This entry should correspond to this repository and offer the current version. 70 | 11. Enter the IP of your SPAN Panel to begin setup, or select the automatically discovered panel if it shows up or another address if you have multiple panels. 71 | 12. Use the door proximity authentication (see below) and optionally create a token for future configurations. Obtaining a token **_may_** be more durable to network changes, for example,if you change client hostname or IP and don't want to accces the pane for authorization. 72 | 13. See post install steps for solar or scan frequency configuration to optionally add additonal sensors if applicable. 73 | 74 | ## Authorization Methods 75 | 76 | ### Method 1: Door Proximity Authentication 77 | 78 | 1. Open your SPAN Panel door 79 | 2. Press the door sensor button at the top 3 times in succession 80 | 3. Wait for the frame lights to blink, indicating the panel is "unlocked" for 15 minutes 81 | 4. Complete the integration setup in Home Assistant 82 | 83 | ### Method 2: Authentication Token (Optional) 84 | 85 | To acquire an authorization token proceed as follows while the panel is in its unlocked period: 86 | 87 | 1. To record the token use a tool like the VS code extension 'Rest Client' or curl to make a POST to `{Span_Panel_IP}/api/v1/auth/register` with a JSON body of `{"name": "home-assistant-UNIQUEID", "description": "Home Assistant Local SPAN Integration"}`. 88 | - Replace UNIQUEID with your own random unique value. If the name conflicts with one that's already been created, then the request will fail. 89 | - Example via CLI: `curl -X POST https://192.168.1.2/api/v1/auth/register -H 'Content-Type: application/json' -d '{"name": "home-assistant-123456", "description": "Home Assistant Local SPAN Integration"}'` 90 | 2. If the panel is already "unlocked", you will get a 2xx response to this call containing the `"accessToken"`. If not, then you will be prompted to open and close the door of the panel 3 times, once every two seconds, and then retry the query. 91 | 3. Store the value from the `"accessToken"` property of the response. (It will be a long string of random characters). The token can be used with future SPAN integration configurations of the same panel. 92 | 4. If you are calling the SPAN API directly for testing requests would load the HTTP header `"Authorization: Bearer "` 93 | 94 | _(If you have multiple SPAN Panels, you will need to repeat this process for each panel, as tokens are only accepted by the panel that generated them.)_ 95 | 96 | If you have this auth token, you can enter it in the "Existing Auth Token" flow in the configuration menu. 97 | 98 | ## Configuration Options 99 | 100 | ### Basic Settings 101 | 102 | - Integration scan frequency (default: 15 seconds) 103 | - Battery storage percentage display 104 | - Solar inverter mapping 105 | 106 | ### Solar Configuration 107 | 108 | If the inverter sensors are enabled three sensors are created: 109 | 110 | ```yaml 111 | sensor.solar_inverter_instant_power # (watts) 112 | sensor.solar_inverter_energy_produce # (Wh) 113 | sensor.solar_inverter_energy_consumed # (Wh) 114 | ``` 115 | 116 | Disabling the inverter in the configuration removes these specific sensors. No reboot is required to add/remove these inverter sensors. 117 | 118 | Although the solar inverter configuration is primarily aimed at installations that don't have a way to monitor their solar directly from their inverter one could use this configuration to monitor any circuit(s) not provided directly by the underlying SPAN API for whatever reason. The two circuits are always added together to indicate their combined power if both circuits are enabled. 119 | 120 | Adding your own platform integration sensor like so converts to kWh: 121 | 122 | ```yaml 123 | sensor 124 | - platform: integration 125 | source: sensor.solar_inverter_instant_power 126 | name: Solar Inverter Produced kWh 127 | unique_id: sensor.solar_inverter_produced_kwh 128 | unit_prefix: k 129 | round: 2 130 | ``` 131 | 132 | ### Customizing Entity Precision 133 | 134 | The power sensors provided by this add-on report with the exact precision from the SPAN panel, which may be more decimal places than you will want for practical purposes. 135 | By default the sensors will display with precision 2, for example `0.00`, with the exception of battery percentage. Battery percentage will have precision of 0, for example `39`. 136 | 137 | You can change the display precision for any entity in Home Assistant via `Settings` -> `Devices & Services` -> `Entities` tab. 138 | find the entity you would like to change in the list and click on it, then click on the gear wheel in the top right. 139 | Select the precision you prefer from the "Display Precision" menu and then press `UPDATE`. 140 | 141 | ## Troubleshooting 142 | 143 | ### Common Issues 144 | 145 | 1. Door Sensor Unavailable - We have observed the SPAN API returning UNKNOWN if the cabinet door has not been operated recently. This behavior is a defect in the SPAN API so there is nothing we can do to mitigate it other than report that sensor as unavailable in this case. Opening or closing the door will reflect the proper value. The door state is classified as a tamper sensor (reflecting 'Detected' or 'Clear') to differentiate the sensor from a normal door someone would walk through. 146 | 147 | 2. State Class Warnings - "Feed Through" sensors may produce erroneous data if your panel is configured in certain ways that interact with solar or if the SPAN panel itself is returning data that is not meaningful to your installation. These sensors reflect the feed through lugs which may be used for a downstream panel. If you are getting warnings in the log about a feed through sensor that has state class total_increasing, but its state is not strictly increasing you can opt to disable these sensors in the Home Assistant settings/devices/entities section: 148 | 149 | ```text 150 | sensor.feed_through_consumed_energy 151 | sensor.feed_through_produced_energy 152 | ``` 153 | 154 | 3. Entity Names and Device Renaming Errors - Prior to version 1.0.4 entity names were not prefixed with the device name so renaming a device did not allow a user to rename the entities accordingly. Newer versions of the integration use the device name prefix on a **new** configuration. An existing, pre-1.0.4 integration that is upgraded will not result in device prefixes in entity names to avoid breaking dependent dashboards and automations. If you want device name prefixes, install at least 1.0.4, delete the configuration and reconfigure it. 155 | 156 | ## Development Notes 157 | 158 | ### Developer Prerequisites 159 | 160 | - Poetry 161 | - Pre-commit 162 | - Python 3.12+ 163 | 164 | This project uses [poetry](https://python-poetry.org/) for dependency management. Linting and type checking is accomplished using [pre-commit](https://pre-commit.com/) which is installed by poetry. 165 | 166 | If you are running Home Assistant (HA) core development locally in another location you can link this project's directory to your HA core directory. This arrangment will allow you to use the SPAN Panel integration in your Home Assistant instance while debugging in the HA core project and using the `SpanPanel/Span` workspace for git and other project operations. 167 | 168 | For instance you can: 169 | 170 | ```bash 171 | ln -s /span/custom_components/span_panel /config/custom_components/span_panel 172 | ``` 173 | 174 | ### Developer Setup 175 | 176 | 1. Install [poetry](https://python-poetry.org/). 177 | 2. Set the `HA_CORE_PATH` environment variable to the location of your Home Assistant core directory. 178 | 3. In the project root run `poetry install --with dev` to install dependencies. 179 | 4. Run `poetry run pre-commit install` to install pre-commit hooks. 180 | 5. Optionally use `poetry run pre-commit run --all-files` to manually run pre-commit hooks on files locally in your environment as you make changes. 181 | 182 | Commits should be run on the command line so the lint jobs can proceed. The linters may make changes to files when you try to commit, for example to sort imports. Files that are changed by the pre-commit hooks will be unstaged. After reviewing these changes, you can re-stage the changes and recommit or rerun the checks. After the pre-commit hook run succeeds, your commit can proceed. 183 | 184 | ### VS Code 185 | 186 | You can set the `HA_CORE_PATH` environment for VS Code allowing you to use vscode git commands within the workspace GUI. See the .vscode/settings.json.example file for settings that configure the Home Assistant core location. 187 | 188 | ## License 189 | 190 | This integration is published under the MIT license. 191 | 192 | ## Attribution and Contributions 193 | 194 | This repository is set up as part of an organization so a single committer is not the weak link. The repostiorry is a fork in a long line of span forks that may or may not be stable (from newer to older): 195 | 196 | - SpanPanel/Span (current GitHub organization) 197 | - cayossarian/span 198 | - gdgib/span 199 | - thetoothpick/span-hacs 200 | - wez/span-hacs 201 | - galak/span-hacs 202 | 203 | Additional contributors: 204 | 205 | - pavandave 206 | - sargonas 207 | 208 | ## Issues 209 | 210 | If you have a problem with the integration, feel free to [open an issue](https://github.com/SpanPanel/Span/issues), but please know issues regarding your network, SPAN configuration, or home electrical system are outside of our purview. 211 | 212 | For those motivated, please consider offering suggestions for improvement in the discussions or opening a [pull request](https://github.com/SpanPanel/Span/pulls). We're generally very happy to have a starting point when making a change. 213 | -------------------------------------------------------------------------------- /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 (CONF_ACCESS_TOKEN, CONF_HOST, 9 | CONF_SCAN_INTERVAL, Platform) 10 | from homeassistant.core import HomeAssistant 11 | from homeassistant.helpers.httpx_client import get_async_client 12 | 13 | from .const import COORDINATOR, DEFAULT_SCAN_INTERVAL, DOMAIN, NAME 14 | from .coordinator import SpanPanelCoordinator 15 | from .options import Options 16 | from .span_panel import SpanPanel 17 | 18 | PLATFORMS: list[Platform] = [ 19 | Platform.BINARY_SENSOR, 20 | Platform.SELECT, 21 | Platform.SENSOR, 22 | Platform.SWITCH, 23 | ] 24 | 25 | _LOGGER = logging.getLogger(__name__) 26 | 27 | 28 | async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 29 | """ 30 | Set up Span Panel from a config entry. 31 | """ 32 | config = entry.data 33 | host = config[CONF_HOST] 34 | name = "SpanPanel" 35 | 36 | _LOGGER.debug("ASYNC_SETUP_ENTRY %s", host) 37 | 38 | span_panel = SpanPanel( 39 | host=config[CONF_HOST], 40 | access_token=config[CONF_ACCESS_TOKEN], 41 | options=Options(entry), 42 | async_client=get_async_client(hass), 43 | ) 44 | 45 | _LOGGER.debug("ASYNC_SETUP_ENTRY panel %s", span_panel) 46 | 47 | scan_interval: int = entry.options.get( 48 | CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL.seconds 49 | ) 50 | 51 | coordinator = SpanPanelCoordinator( 52 | hass, span_panel, name, update_interval=scan_interval 53 | ) 54 | 55 | await coordinator.async_config_entry_first_refresh() 56 | 57 | entry.async_on_unload(entry.add_update_listener(update_listener)) 58 | 59 | hass.data.setdefault(DOMAIN, {}) 60 | hass.data[DOMAIN][entry.entry_id] = { 61 | COORDINATOR: coordinator, 62 | NAME: name, 63 | } 64 | 65 | await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) 66 | 67 | return True 68 | 69 | 70 | async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 71 | """ 72 | Unload a config entry. 73 | """ 74 | _LOGGER.debug("ASYNC_UNLOAD") 75 | if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): 76 | hass.data[DOMAIN].pop(entry.entry_id) 77 | 78 | return unload_ok 79 | 80 | 81 | async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: 82 | """ 83 | Update listener. 84 | """ 85 | await hass.config_entries.async_reload(entry.entry_id) 86 | -------------------------------------------------------------------------------- /custom_components/span_panel/binary_sensor.py: -------------------------------------------------------------------------------- 1 | """Binary Sensors for status entities.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | from collections.abc import Callable 7 | from dataclasses import dataclass 8 | from typing import Any, cast 9 | 10 | from homeassistant.components.binary_sensor import ( 11 | BinarySensorDeviceClass, BinarySensorEntity, BinarySensorEntityDescription) 12 | from homeassistant.config_entries import ConfigEntry 13 | from homeassistant.core import HomeAssistant 14 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 15 | from homeassistant.helpers.update_coordinator import CoordinatorEntity 16 | 17 | from .const import (COORDINATOR, DOMAIN, SYSTEM_DOOR_STATE_CLOSED, 18 | SYSTEM_DOOR_STATE_OPEN, USE_DEVICE_PREFIX) 19 | from .coordinator import SpanPanelCoordinator 20 | from .span_panel import SpanPanel 21 | from .span_panel_hardware_status import SpanPanelHardwareStatus 22 | from .util import panel_to_device_info 23 | 24 | _LOGGER = logging.getLogger(__name__) 25 | 26 | 27 | @dataclass(frozen=True) 28 | class SpanPanelRequiredKeysMixin: 29 | value_fn: Callable[[SpanPanelHardwareStatus], bool | None] 30 | 31 | 32 | @dataclass(frozen=True) 33 | class SpanPanelBinarySensorEntityDescription( 34 | BinarySensorEntityDescription, SpanPanelRequiredKeysMixin 35 | ): 36 | """Describes an SpanPanelCircuits sensor entity.""" 37 | 38 | # Door state has benn observed to return UNKNOWN if the door 39 | # has not been operated recently so we check for invalid values 40 | # pylint: disable=unexpected-keyword-arg 41 | BINARY_SENSORS = ( 42 | SpanPanelBinarySensorEntityDescription( 43 | key="doorState", 44 | name="Door State", 45 | device_class=BinarySensorDeviceClass.TAMPER, 46 | value_fn=lambda status_data: None if status_data.door_state not in [SYSTEM_DOOR_STATE_CLOSED, SYSTEM_DOOR_STATE_OPEN] 47 | else not status_data.is_door_closed, 48 | ), 49 | SpanPanelBinarySensorEntityDescription( 50 | key="eth0Link", 51 | name="Ethernet Link", 52 | device_class=BinarySensorDeviceClass.CONNECTIVITY, 53 | value_fn=lambda status_data: status_data.is_ethernet_connected, 54 | ), 55 | SpanPanelBinarySensorEntityDescription( 56 | key="wlanLink", 57 | name="Wi-Fi Link", 58 | device_class=BinarySensorDeviceClass.CONNECTIVITY, 59 | value_fn=lambda status_data: status_data.is_wifi_connected, 60 | ), 61 | SpanPanelBinarySensorEntityDescription( 62 | key="wwanLink", 63 | name="Cellular Link", 64 | device_class=BinarySensorDeviceClass.CONNECTIVITY, 65 | value_fn=lambda status_data: status_data.is_cellular_connected, 66 | ), 67 | ) 68 | 69 | 70 | class SpanPanelBinarySensor( 71 | CoordinatorEntity[SpanPanelCoordinator], BinarySensorEntity 72 | ): 73 | """Binary Sensor status entity.""" 74 | 75 | def __init__( 76 | self, 77 | data_coordinator: SpanPanelCoordinator, 78 | description: SpanPanelBinarySensorEntityDescription, 79 | ) -> None: 80 | """Initialize Span Panel Circuit entity.""" 81 | super().__init__(data_coordinator, context=description) 82 | span_panel: SpanPanel = data_coordinator.data 83 | 84 | self.entity_description = description 85 | device_info = panel_to_device_info(span_panel) 86 | self._attr_device_info = device_info 87 | base_name = f"{description.name}" 88 | 89 | if (data_coordinator.config_entry is not None and 90 | data_coordinator.config_entry.options.get(USE_DEVICE_PREFIX, False) and 91 | device_info is not None and 92 | isinstance(device_info, dict) and 93 | "name" in device_info): 94 | self._attr_name = f"{device_info['name']} {base_name}" 95 | else: 96 | self._attr_name = base_name 97 | 98 | self._attr_unique_id = ( 99 | f"span_{span_panel.status.serial_number}_{description.key}" 100 | ) 101 | 102 | _LOGGER.debug("CREATE BINSENSOR [%s]", self._attr_name) 103 | 104 | @property 105 | def is_on(self) -> bool | None: 106 | """Return the status of the sensor.""" 107 | # Get atomic snapshot of panel data 108 | span_panel: SpanPanel = self.coordinator.data 109 | description = cast( 110 | SpanPanelBinarySensorEntityDescription, self.entity_description 111 | ) 112 | # Get atomic snapshot of status data 113 | status = span_panel.status 114 | status_is_on = description.value_fn(status) 115 | _LOGGER.debug("BINSENSOR [%s] is_on:[%s]", self._attr_name, status_is_on) 116 | return status_is_on 117 | 118 | @property 119 | def available(self) -> bool: 120 | """Return True if entity is available.""" 121 | return self.is_on is not None 122 | 123 | 124 | async def async_setup_entry( 125 | hass: HomeAssistant, 126 | config_entry: ConfigEntry, 127 | async_add_entities: AddEntitiesCallback, 128 | ) -> None: 129 | """Set up status sensor platform.""" 130 | 131 | _LOGGER.debug("ASYNC SETUP ENTRY BINARYSENSOR") 132 | 133 | data: dict[str, Any] = hass.data[DOMAIN][config_entry.entry_id] 134 | coordinator: SpanPanelCoordinator = data[COORDINATOR] 135 | 136 | entities: list[SpanPanelBinarySensor] = [] 137 | 138 | for description in BINARY_SENSORS: 139 | entities.append(SpanPanelBinarySensor(coordinator, description)) 140 | 141 | async_add_entities(entities) 142 | -------------------------------------------------------------------------------- /custom_components/span_panel/config_flow.py: -------------------------------------------------------------------------------- 1 | """Span Panel Config Flow""" 2 | 3 | from __future__ import annotations 4 | 5 | import enum 6 | import logging 7 | from collections.abc import Mapping 8 | from typing import Any 9 | 10 | import voluptuous as vol 11 | from homeassistant import config_entries 12 | from homeassistant.components import zeroconf 13 | from homeassistant.config_entries import ConfigFlowResult 14 | from homeassistant.const import (CONF_ACCESS_TOKEN, CONF_HOST, 15 | CONF_SCAN_INTERVAL) 16 | from homeassistant.core import HomeAssistant, callback 17 | from homeassistant.helpers.httpx_client import get_async_client 18 | from homeassistant.util.network import is_ipv4_address 19 | 20 | from .const import DEFAULT_SCAN_INTERVAL, DOMAIN, USE_DEVICE_PREFIX 21 | from .options import (BATTERY_ENABLE, INVERTER_ENABLE, INVERTER_LEG1, 22 | INVERTER_LEG2) 23 | from .span_panel_api import SpanPanelApi 24 | 25 | _LOGGER = logging.getLogger(__name__) 26 | 27 | STEP_USER_DATA_SCHEMA = vol.Schema( 28 | { 29 | vol.Required(CONF_HOST): str, 30 | } 31 | ) 32 | 33 | STEP_AUTH_TOKEN_DATA_SCHEMA = vol.Schema( 34 | { 35 | vol.Optional(CONF_ACCESS_TOKEN): str, 36 | } 37 | ) 38 | 39 | 40 | class TriggerFlowType(enum.Enum): 41 | CREATE_ENTRY = enum.auto() 42 | UPDATE_ENTRY = enum.auto() 43 | 44 | 45 | def create_api_controller( 46 | hass: HomeAssistant, host: str, access_token: str | None = None # nosec 47 | ) -> SpanPanelApi: 48 | params: dict[str, Any] = {"host": host, "async_client": get_async_client(hass)} 49 | if access_token is not None: 50 | params["access_token"] = access_token 51 | return SpanPanelApi(**params) 52 | 53 | 54 | async def validate_host( 55 | hass: HomeAssistant, host: str, access_token: str | None = None # nosec 56 | ) -> bool: 57 | span_api = create_api_controller(hass, host, access_token) 58 | if access_token: 59 | return await span_api.ping_with_auth() 60 | return await span_api.ping() 61 | 62 | 63 | async def validate_auth_token(hass: HomeAssistant, host: str, access_token: str) -> bool: 64 | """Perform an authenticated call to confirm validity of provided token.""" 65 | span_api = create_api_controller(hass, host, access_token) 66 | return await span_api.ping_with_auth() 67 | 68 | 69 | class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): # type: ignore 70 | """ 71 | Handle a config flow for Span Panel. 72 | """ 73 | 74 | VERSION = 1 75 | 76 | def __init__(self) -> None: 77 | self.trigger_flow_type: TriggerFlowType | None = None 78 | self.host: str | None = None 79 | self.serial_number: str | None = None 80 | self.access_token: str | None = None 81 | 82 | self._is_flow_setup: bool = False 83 | 84 | async def setup_flow(self, trigger_type: TriggerFlowType, host: str): 85 | """Set up the flow.""" 86 | 87 | if self._is_flow_setup is True: 88 | raise AssertionError("Flow is already set up") 89 | 90 | span_api = create_api_controller(self.hass, host) 91 | panel_status = await span_api.get_status_data() 92 | 93 | self.trigger_flow_type = trigger_type 94 | self.host = host 95 | self.serial_number = panel_status.serial_number 96 | 97 | # Keep the existing context values and add the host value 98 | self.context = { 99 | **self.context, 100 | "title_placeholders": { 101 | **self.context.get("title_placeholders", {}), 102 | CONF_HOST: self.host, 103 | }, 104 | } 105 | 106 | self._is_flow_setup = True 107 | 108 | def ensure_flow_is_set_up(self): 109 | """Ensure the flow is set up.""" 110 | if self._is_flow_setup is False: 111 | raise AssertionError("Flow is not set up") 112 | 113 | async def ensure_not_already_configured(self): 114 | """Ensure the panel is not already configured.""" 115 | self.ensure_flow_is_set_up() 116 | 117 | # Abort if we had already set this panel up 118 | await self.async_set_unique_id(self.serial_number) 119 | self._abort_if_unique_id_configured(updates={CONF_HOST: self.host}) 120 | 121 | async def async_step_zeroconf( 122 | self, discovery_info: zeroconf.ZeroconfServiceInfo 123 | ) -> ConfigFlowResult: 124 | """ 125 | Handle a flow initiated by zeroconf discovery. 126 | """ 127 | # Do not probe device if the host is already configured 128 | self._async_abort_entries_match({CONF_HOST: discovery_info.host}) 129 | 130 | # Do not probe device if it is not an ipv4 address 131 | if not is_ipv4_address(discovery_info.host): 132 | return self.async_abort(reason="not_ipv4_address") 133 | 134 | # Validate that this is a valid Span Panel 135 | if not await validate_host(self.hass, discovery_info.host): 136 | return self.async_abort(reason="not_span_panel") 137 | 138 | await self.setup_flow(TriggerFlowType.CREATE_ENTRY, discovery_info.host) 139 | await self.ensure_not_already_configured() 140 | return await self.async_step_confirm_discovery() 141 | 142 | async def async_step_user( 143 | self, user_input: dict[str, Any] | None = None 144 | ) -> ConfigFlowResult: 145 | """Handle a flow initiated by the user.""" 146 | if user_input is None: 147 | return self.async_show_form( 148 | step_id="user", data_schema=STEP_USER_DATA_SCHEMA 149 | ) 150 | 151 | host = user_input[CONF_HOST].strip() 152 | 153 | # Validate host before setting up flow 154 | if not await validate_host(self.hass, host): 155 | return self.async_show_form( 156 | step_id="user", 157 | data_schema=STEP_USER_DATA_SCHEMA, 158 | errors={"base": "cannot_connect"}, 159 | ) 160 | 161 | # Only setup flow if validation succeeded 162 | if not self._is_flow_setup: 163 | await self.setup_flow(TriggerFlowType.CREATE_ENTRY, host) 164 | await self.ensure_not_already_configured() 165 | 166 | return await self.async_step_choose_auth_type() 167 | 168 | async def async_step_reauth( 169 | self, entry_data: Mapping[str, Any] 170 | ) -> ConfigFlowResult: 171 | """ 172 | Handle a flow initiated by re-auth. 173 | """ 174 | await self.setup_flow(TriggerFlowType.UPDATE_ENTRY, entry_data[CONF_HOST]) 175 | return await self.async_step_auth_token(dict(entry_data)) 176 | 177 | async def async_step_confirm_discovery( 178 | self, user_input: dict[str, Any] | None = None 179 | ) -> ConfigFlowResult: 180 | """ 181 | Prompt user to confirm a discovered Span Panel. 182 | """ 183 | self.ensure_flow_is_set_up() 184 | 185 | # Prompt the user for confirmation 186 | if user_input is None: 187 | self._set_confirm_only() 188 | return self.async_show_form( 189 | step_id="confirm_discovery", 190 | description_placeholders={ 191 | "host": self.host, 192 | }, 193 | ) 194 | 195 | # Pass (empty) dictionary to signal the call came from this step, not abort 196 | return await self.async_step_choose_auth_type(user_input) 197 | 198 | async def async_step_choose_auth_type( 199 | self, user_input: dict[str, Any] | None = None 200 | ) -> ConfigFlowResult: 201 | """Choose the authentication method to use.""" 202 | self.ensure_flow_is_set_up() 203 | 204 | # None means this method was called by HA core as an abort 205 | if user_input is None: 206 | return await self.async_step_confirm_discovery() 207 | 208 | return self.async_show_menu( 209 | step_id="choose_auth_type", 210 | menu_options={ 211 | "auth_proximity": "Proof of Proximity (recommended)", 212 | "auth_token": "Existing Auth Token", 213 | }, 214 | ) 215 | 216 | async def async_step_auth_proximity( 217 | self, 218 | entry_data: dict[str, Any] | None = None, 219 | ) -> ConfigFlowResult: 220 | """ 221 | Step that guide users through the proximity authentication process. 222 | """ 223 | self.ensure_flow_is_set_up() 224 | 225 | span_api = create_api_controller(self.hass, self.host or "") 226 | panel_status = await span_api.get_status_data() 227 | 228 | # Check if running firmware newer or older than r202342 229 | if panel_status.proximity_proven is not None: 230 | # Reprompt until we are able to do proximity auth for new firmware 231 | proximity_verified = panel_status.proximity_proven 232 | if proximity_verified is False: 233 | return self.async_show_form(step_id="auth_proximity") 234 | else: 235 | # Reprompt until we are able to do proximity auth for old firmware 236 | remaining_presses = panel_status.remaining_auth_unlock_button_presses 237 | if (remaining_presses != 0): 238 | return self.async_show_form( 239 | step_id="auth_proximity", 240 | ) 241 | 242 | # Ensure host is set 243 | if not self.host: 244 | return self.async_abort(reason="host_not_set") 245 | 246 | # Ensure token is valid using authenticated validation 247 | self.access_token = await span_api.get_access_token() 248 | if not await validate_auth_token(self.hass, self.host, self.access_token): 249 | return self.async_abort(reason="invalid_access_token") 250 | 251 | return await self.async_step_resolve_entity(entry_data) 252 | 253 | async def async_step_auth_token( 254 | self, 255 | user_input: dict[str, Any] | None = None, 256 | ) -> ConfigFlowResult: 257 | """ 258 | Step that prompts user for access token. 259 | """ 260 | self.ensure_flow_is_set_up() 261 | 262 | if user_input is None: 263 | # Show the form to prompt for the access token 264 | return self.async_show_form( 265 | step_id="auth_token", data_schema=STEP_AUTH_TOKEN_DATA_SCHEMA 266 | ) 267 | 268 | # Extract access token from user input 269 | access_token = user_input.get(CONF_ACCESS_TOKEN) 270 | if access_token: 271 | self.access_token = access_token 272 | 273 | # Ensure host is set 274 | if not self.host: 275 | return self.async_abort(reason="host_not_set") 276 | 277 | # Validate the provided token 278 | if not await validate_auth_token(self.hass, self.host, access_token): 279 | return self.async_show_form( 280 | step_id="auth_token", 281 | data_schema=STEP_AUTH_TOKEN_DATA_SCHEMA, 282 | errors={"base": "invalid_access_token"}, 283 | ) 284 | 285 | # Proceed to the next step upon successful validation 286 | return await self.async_step_resolve_entity(user_input) 287 | 288 | # If no access token was provided, abort or show the form again 289 | return self.async_abort(reason="missing_access_token") 290 | 291 | async def async_step_resolve_entity( 292 | self, 293 | entry_data: dict[str, Any] | None = None, 294 | ) -> ConfigFlowResult: 295 | """Resolve the entity.""" 296 | self.ensure_flow_is_set_up() 297 | 298 | # Continue based on flow trigger type 299 | match self.trigger_flow_type: 300 | case TriggerFlowType.CREATE_ENTRY: 301 | if self.host is None: 302 | raise ValueError("Host cannot be None when creating a new entry") 303 | if self.serial_number is None: 304 | raise ValueError( 305 | "Serial number cannot be None when creating a new entry" 306 | ) 307 | if self.access_token is None: 308 | raise ValueError( 309 | "Access token cannot be None when creating a new entry" 310 | ) 311 | return self.create_new_entry( 312 | self.host, self.serial_number, self.access_token 313 | ) 314 | case TriggerFlowType.UPDATE_ENTRY: 315 | if self.host is None: 316 | raise ValueError("Host cannot be None when updating an entry") 317 | if self.access_token is None: 318 | raise ValueError( 319 | "Access token cannot be None when updating an entry" 320 | ) 321 | return self.update_existing_entry( 322 | self.context["entry_id"], 323 | self.host, 324 | self.access_token, 325 | entry_data or {}, 326 | ) 327 | case _: 328 | raise NotImplementedError() 329 | 330 | def create_new_entry( 331 | self, host: str, serial_number: str, access_token: str 332 | ) -> ConfigFlowResult: 333 | """ 334 | Creates a new SPAN panel entry. 335 | """ 336 | return self.async_create_entry( 337 | title=serial_number, 338 | data={ 339 | CONF_HOST: host, 340 | CONF_ACCESS_TOKEN: access_token 341 | }, 342 | options={ 343 | USE_DEVICE_PREFIX: True # Only set for new installations 344 | } 345 | ) 346 | 347 | def update_existing_entry( 348 | self, 349 | entry_id: str, 350 | host: str, 351 | access_token: str, 352 | entry_data: Mapping[str, Any], 353 | ) -> ConfigFlowResult: 354 | """ 355 | Updates an existing entry with new configurations. 356 | """ 357 | # Update the existing data with reauthed data 358 | # Create a new mutable copy of the entry data (Mapping is immutable) 359 | updated_data = dict(entry_data) 360 | updated_data[CONF_HOST] = host 361 | updated_data[CONF_ACCESS_TOKEN] = access_token 362 | 363 | # An existing entry must exist before we can update it 364 | entry = self.hass.config_entries.async_get_entry(entry_id) 365 | if entry is None: 366 | raise AssertionError("Entry does not exist") 367 | 368 | self.hass.config_entries.async_update_entry(entry, data=updated_data) 369 | self.hass.async_create_task(self.hass.config_entries.async_reload(entry_id)) 370 | return self.async_abort(reason="reauth_successful") 371 | 372 | @staticmethod 373 | @callback 374 | def async_get_options_flow( 375 | config_entry: config_entries.ConfigEntry, 376 | ) -> config_entries.OptionsFlow: 377 | """Create the options flow by passing entry_id.""" 378 | return OptionsFlowHandler(entry_id=config_entry.entry_id) 379 | 380 | 381 | OPTIONS_SCHEMA = vol.Schema( 382 | { 383 | vol.Optional(CONF_SCAN_INTERVAL): vol.All( 384 | int, vol.Range(min=5) 385 | ), 386 | vol.Optional(BATTERY_ENABLE): bool, 387 | vol.Optional(INVERTER_ENABLE): bool, 388 | vol.Optional(INVERTER_LEG1): vol.All( 389 | vol.Coerce(int), vol.Range(min=0) 390 | ), 391 | vol.Optional(INVERTER_LEG2): vol.All( 392 | vol.Coerce(int), vol.Range(min=0) 393 | ), 394 | } 395 | ) 396 | 397 | class OptionsFlowHandler(config_entries.OptionsFlow): 398 | """Handle the options flow for Span Panel without storing config_entry.""" 399 | 400 | def __init__(self, entry_id: str) -> None: 401 | """Initialize with entry_id only.""" 402 | self._entry_id = entry_id 403 | 404 | @property 405 | def entry(self) -> config_entries.ConfigEntry: 406 | """Get the config entry using the stored entry_id.""" 407 | entry = self.hass.config_entries.async_get_entry(self._entry_id) 408 | if not entry: 409 | raise ValueError("Config entry not found") 410 | return entry 411 | 412 | async def async_step_init( 413 | self, user_input: dict[str, Any] | None = None 414 | ) -> ConfigFlowResult: 415 | """Manage the options.""" 416 | if user_input is not None: 417 | # Preserve the USE_DEVICE_PREFIX setting from the original entry 418 | use_prefix = self.entry.options.get(USE_DEVICE_PREFIX, False) 419 | if use_prefix: 420 | user_input[USE_DEVICE_PREFIX] = use_prefix 421 | return self.async_create_entry(title="", data=user_input) 422 | 423 | defaults = { 424 | CONF_SCAN_INTERVAL: self.entry.options.get( 425 | CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL.seconds 426 | ), 427 | BATTERY_ENABLE: self.entry.options.get("enable_battery_percentage", False), 428 | INVERTER_ENABLE: self.entry.options.get("enable_solar_circuit", False), 429 | INVERTER_LEG1: self.entry.options.get(INVERTER_LEG1, 0), 430 | INVERTER_LEG2: self.entry.options.get(INVERTER_LEG2, 0), 431 | } 432 | 433 | return self.async_show_form( 434 | step_id="init", 435 | data_schema=self.add_suggested_values_to_schema( 436 | OPTIONS_SCHEMA, defaults 437 | ), 438 | ) 439 | -------------------------------------------------------------------------------- /custom_components/span_panel/const.py: -------------------------------------------------------------------------------- 1 | """Constants for the Span Panel integration.""" 2 | 3 | import enum 4 | from datetime import timedelta 5 | 6 | DOMAIN = "span_panel" 7 | COORDINATOR = "coordinator" 8 | NAME = "name" 9 | 10 | CONF_SERIAL_NUMBER = "serial_number" 11 | 12 | URL_STATUS = "http://{}/api/v1/status" 13 | URL_SPACES = "http://{}/api/v1/spaces" 14 | URL_CIRCUITS = "http://{}/api/v1/circuits" 15 | URL_PANEL = "http://{}/api/v1/panel" 16 | URL_REGISTER = "http://{}/api/v1/auth/register" 17 | URL_STORAGE_BATTERY = "http://{}/api/v1/storage/soe" 18 | 19 | STORAGE_BATTERY_PERCENTAGE = "batteryPercentage" 20 | CIRCUITS_NAME = "name" 21 | CIRCUITS_RELAY = "relayState" 22 | CIRCUITS_POWER = "instantPowerW" 23 | CIRCUITS_ENERGY_PRODUCED = "producedEnergyWh" 24 | CIRCUITS_ENERGY_CONSUMED = "consumedEnergyWh" 25 | CIRCUITS_BREAKER_POSITIONS = "tabs" 26 | CIRCUITS_PRIORITY = "priority" 27 | CIRCUITS_IS_USER_CONTROLLABLE = "is_user_controllable" 28 | CIRCUITS_IS_SHEDDABLE = "is_sheddable" 29 | CIRCUITS_IS_NEVER_BACKUP = "is_never_backup" 30 | 31 | SPAN_CIRCUITS = "circuits" 32 | SPAN_SOE = "soe" 33 | SPAN_SYSTEM = "system" 34 | PANEL_POWER = "instantGridPowerW" 35 | SYSTEM_DOOR_STATE = "doorState" 36 | SYSTEM_DOOR_STATE_CLOSED = "CLOSED" 37 | SYSTEM_DOOR_STATE_OPEN = "OPEN" 38 | SYSTEM_ETHERNET_LINK = "eth0Link" 39 | SYSTEM_CELLULAR_LINK = "wwanLink" 40 | SYSTEM_WIFI_LINK = "wlanLink" 41 | 42 | STATUS_SOFTWARE_VER = "softwareVer" 43 | DSM_GRID_STATE = "dsmGridState" 44 | DSM_STATE = "dsmState" 45 | CURRENT_RUN_CONFIG = "currentRunConfig" 46 | MAIN_RELAY_STATE = "mainRelayState" 47 | 48 | PANEL_MAIN_RELAY_STATE_UNKNOWN_VALUE = "UNKNOWN" 49 | USE_DEVICE_PREFIX = "use_device_prefix" 50 | 51 | DEFAULT_SCAN_INTERVAL = timedelta(seconds=15) 52 | API_TIMEOUT = 30 53 | 54 | 55 | class CircuitRelayState(enum.Enum): 56 | OPEN = "Open" 57 | CLOSED = "Closed" 58 | UNKNOWN = "Unknown" 59 | 60 | 61 | class CircuitPriority(enum.Enum): 62 | MUST_HAVE = "Must Have" 63 | NICE_TO_HAVE = "Nice To Have" 64 | NON_ESSENTIAL = "Non-Essential" 65 | UNKNOWN = "Unknown" 66 | -------------------------------------------------------------------------------- /custom_components/span_panel/coordinator.py: -------------------------------------------------------------------------------- 1 | """Coordinator for Span Panel.""" 2 | 3 | import asyncio 4 | import logging 5 | from datetime import timedelta 6 | 7 | from homeassistant.core import HomeAssistant 8 | from homeassistant.exceptions import ConfigEntryAuthFailed 9 | from homeassistant.helpers.httpx_client import httpx 10 | from homeassistant.helpers.update_coordinator import (DataUpdateCoordinator, 11 | UpdateFailed) 12 | 13 | from .const import API_TIMEOUT 14 | from .span_panel import SpanPanel 15 | 16 | _LOGGER = logging.getLogger(__name__) 17 | 18 | 19 | class SpanPanelCoordinator(DataUpdateCoordinator[SpanPanel]): 20 | """Coordinator for Span Panel.""" 21 | 22 | def __init__( 23 | self, 24 | hass: HomeAssistant, 25 | span_panel: SpanPanel, 26 | name: str, 27 | update_interval: int, 28 | ): 29 | super().__init__( 30 | hass, 31 | _LOGGER, 32 | name=f"span panel {name}", 33 | update_interval=timedelta(seconds=update_interval), 34 | always_update=True, 35 | ) 36 | self.span_panel = span_panel 37 | 38 | async def _async_update_data(self) -> SpanPanel: 39 | """Fetch data from API endpoint.""" 40 | try: 41 | _LOGGER.debug("Starting coordinator update") 42 | await asyncio.wait_for(self.span_panel.update(), timeout=API_TIMEOUT) 43 | _LOGGER.debug("Coordinator update successful - data: %s", self.span_panel) 44 | except httpx.HTTPStatusError as err: 45 | if err.response.status_code == httpx.codes.UNAUTHORIZED: 46 | raise ConfigEntryAuthFailed from err 47 | else: 48 | _LOGGER.error( 49 | "httpx.StatusError occurred while updating Span data: %s", 50 | str(err), 51 | ) 52 | raise UpdateFailed(f"Error communicating with API: {err}") from err 53 | except httpx.HTTPError as err: 54 | _LOGGER.error( 55 | "An httpx.HTTPError occurred while updating Span data: %s", str(err) 56 | ) 57 | raise UpdateFailed(f"Error communicating with API: {err}") from err 58 | except asyncio.TimeoutError as err: 59 | _LOGGER.error( 60 | "An asyncio.TimeoutError occurred while updating Span data: %s", 61 | str(err), 62 | ) 63 | raise UpdateFailed(f"Error communicating with API: {err}") from err 64 | 65 | return self.span_panel 66 | -------------------------------------------------------------------------------- /custom_components/span_panel/exceptions.py: -------------------------------------------------------------------------------- 1 | class SpanPanelReturnedEmptyData(Exception): 2 | pass 3 | -------------------------------------------------------------------------------- /custom_components/span_panel/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "span_panel", 3 | "name": "Span Panel", 4 | "codeowners": ["@SpanPanel", "@cayossarian"], 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.4", 11 | "zeroconf": [ 12 | { 13 | "type": "_span._tcp.local." 14 | } 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /custom_components/span_panel/options.py: -------------------------------------------------------------------------------- 1 | """Option configurations.""" 2 | 3 | from homeassistant.config_entries import ConfigEntry 4 | 5 | INVERTER_ENABLE = "enable_solar_circuit" 6 | INVERTER_LEG1 = "leg1" 7 | INVERTER_LEG2 = "leg2" 8 | INVERTER_MAXLEG = 32 9 | BATTERY_ENABLE = "enable_battery_percentage" 10 | 11 | 12 | class Options: 13 | """Class representing the options like the solar inverter.""" 14 | 15 | def __init__(self, entry: ConfigEntry): 16 | self.enable_solar_sensors: bool = entry.options.get(INVERTER_ENABLE, False) 17 | self.inverter_leg1: int = entry.options.get(INVERTER_LEG1, 0) 18 | self.inverter_leg2: int = entry.options.get(INVERTER_LEG2, 0) 19 | self.enable_battery_percentage: bool = entry.options.get(BATTERY_ENABLE, False) 20 | -------------------------------------------------------------------------------- /custom_components/span_panel/select.py: -------------------------------------------------------------------------------- 1 | # pyright: reportShadowedImports=false 2 | import logging 3 | from functools import cached_property 4 | from typing import Any 5 | 6 | from homeassistant.components.select import SelectEntity 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 .const import COORDINATOR, DOMAIN, USE_DEVICE_PREFIX, CircuitPriority 13 | from .coordinator import SpanPanelCoordinator 14 | from .span_panel import SpanPanel 15 | from .util import panel_to_device_info 16 | 17 | ICON = "mdi:toggle-switch" 18 | 19 | _LOGGER = logging.getLogger(__name__) 20 | 21 | 22 | class SpanPanelCircuitsSelect(CoordinatorEntity[SpanPanelCoordinator], SelectEntity): 23 | """Represent a switch entity.""" 24 | 25 | def __init__(self, coordinator: SpanPanelCoordinator, id: str, name: str) -> None: 26 | _LOGGER.debug("CREATE SELECT %s", name) 27 | span_panel: SpanPanel = coordinator.data 28 | 29 | self.id = id 30 | self._attr_unique_id = ( 31 | f"span_{span_panel.status.serial_number}_select_{self.id}" 32 | ) 33 | self._attr_device_info = panel_to_device_info(span_panel) 34 | super().__init__(coordinator) 35 | 36 | @cached_property 37 | def name(self): 38 | """Return the switch name.""" 39 | span_panel: SpanPanel = self.coordinator.data 40 | base_name = f"{span_panel.circuits[self.id].name} Circuit Priority" 41 | if self.coordinator.config_entry.options.get(USE_DEVICE_PREFIX, False): 42 | return f"{self._attr_device_info['name']} {base_name}" 43 | return base_name 44 | 45 | @cached_property 46 | def options(self) -> list[str]: 47 | return [e.value for e in CircuitPriority if e != CircuitPriority.UNKNOWN] 48 | 49 | @cached_property 50 | def current_option(self) -> str | None: 51 | span_panel: SpanPanel = self.coordinator.data 52 | priority = span_panel.circuits[self.id].priority 53 | return CircuitPriority[priority].value 54 | 55 | async def async_select_option(self, option: str) -> None: 56 | span_panel: SpanPanel = self.coordinator.data 57 | priority = CircuitPriority(option) 58 | curr_circuit = span_panel.circuits[self.id] 59 | await span_panel.api.set_priority(curr_circuit, priority) 60 | await self.coordinator.async_request_refresh() 61 | 62 | 63 | async def async_setup_entry( 64 | hass: HomeAssistant, 65 | config_entry: ConfigEntry, 66 | async_add_entities: AddEntitiesCallback, 67 | ) -> None: 68 | """Set up envoy sensor platform.""" 69 | 70 | _LOGGER.debug("ASYNC SETUP ENTRY SWITCH") 71 | data: dict[str, Any] = hass.data[DOMAIN][config_entry.entry_id] 72 | 73 | coordinator: SpanPanelCoordinator = data[COORDINATOR] 74 | span_panel: SpanPanel = coordinator.data 75 | 76 | entities: list[SpanPanelCircuitsSelect] = [] 77 | 78 | for circuit_id, circuit_data in span_panel.circuits.items(): 79 | if circuit_data.is_user_controllable: 80 | entities.append(SpanPanelCircuitsSelect(coordinator, circuit_id, circuit_data.name)) 81 | 82 | async_add_entities(entities) 83 | -------------------------------------------------------------------------------- /custom_components/span_panel/sensor.py: -------------------------------------------------------------------------------- 1 | """Support for Span Panel monitor.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | from collections.abc import Callable 7 | from dataclasses import dataclass 8 | from typing import Any, Generic, List, TypeVar 9 | 10 | from homeassistant.components.sensor import (SensorDeviceClass, SensorEntity, 11 | SensorEntityDescription, 12 | SensorStateClass) 13 | from homeassistant.config_entries import ConfigEntry 14 | from homeassistant.const import PERCENTAGE, UnitOfEnergy, UnitOfPower 15 | from homeassistant.core import HomeAssistant 16 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 17 | from homeassistant.helpers.update_coordinator import CoordinatorEntity 18 | 19 | from .const import (CIRCUITS_ENERGY_CONSUMED, CIRCUITS_ENERGY_PRODUCED, 20 | CIRCUITS_POWER, COORDINATOR, CURRENT_RUN_CONFIG, DOMAIN, 21 | DSM_GRID_STATE, DSM_STATE, MAIN_RELAY_STATE, 22 | STATUS_SOFTWARE_VER, STORAGE_BATTERY_PERCENTAGE, 23 | USE_DEVICE_PREFIX) 24 | from .coordinator import SpanPanelCoordinator 25 | from .options import BATTERY_ENABLE, INVERTER_ENABLE 26 | from .span_panel import SpanPanel 27 | from .span_panel_circuit import SpanPanelCircuit 28 | from .span_panel_data import SpanPanelData 29 | from .span_panel_hardware_status import SpanPanelHardwareStatus 30 | from .span_panel_storage_battery import SpanPanelStorageBattery 31 | from .util import panel_to_device_info 32 | 33 | 34 | @dataclass(frozen=True) 35 | class SpanPanelCircuitsRequiredKeysMixin: 36 | value_fn: Callable[[SpanPanelCircuit], float] 37 | 38 | 39 | @dataclass(frozen=True) 40 | class SpanPanelCircuitsSensorEntityDescription( 41 | SensorEntityDescription, SpanPanelCircuitsRequiredKeysMixin 42 | ): 43 | pass 44 | 45 | 46 | @dataclass(frozen=True) 47 | class SpanPanelDataRequiredKeysMixin: 48 | value_fn: Callable[[SpanPanelData], float | str] 49 | 50 | 51 | @dataclass(frozen=True) 52 | class SpanPanelDataSensorEntityDescription( 53 | SensorEntityDescription, SpanPanelDataRequiredKeysMixin 54 | ): 55 | pass 56 | 57 | 58 | @dataclass(frozen=True) 59 | class SpanPanelStatusRequiredKeysMixin: 60 | value_fn: Callable[[SpanPanelHardwareStatus], str] 61 | 62 | 63 | @dataclass(frozen=True) 64 | class SpanPanelStatusSensorEntityDescription( 65 | SensorEntityDescription, SpanPanelStatusRequiredKeysMixin 66 | ): 67 | pass 68 | 69 | 70 | @dataclass(frozen=True) 71 | class SpanPanelStorageBatteryRequiredKeysMixin: 72 | value_fn: Callable[[SpanPanelStorageBattery], int] 73 | 74 | 75 | @dataclass(frozen=True) 76 | class SpanPanelStorageBatterySensorEntityDescription( 77 | SensorEntityDescription, SpanPanelStorageBatteryRequiredKeysMixin 78 | ): 79 | pass 80 | 81 | 82 | # pylint: disable=unexpected-keyword-arg 83 | CIRCUITS_SENSORS = ( 84 | SpanPanelCircuitsSensorEntityDescription( 85 | key=CIRCUITS_POWER, 86 | name="Power", 87 | native_unit_of_measurement=UnitOfPower.WATT, 88 | state_class=SensorStateClass.MEASUREMENT, 89 | suggested_display_precision=2, 90 | device_class=SensorDeviceClass.POWER, 91 | value_fn=lambda circuit: abs(circuit.instant_power), 92 | ), 93 | SpanPanelCircuitsSensorEntityDescription( 94 | key=CIRCUITS_ENERGY_PRODUCED, 95 | name="Produced Energy", 96 | native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, 97 | state_class=SensorStateClass.TOTAL_INCREASING, 98 | suggested_display_precision=2, 99 | device_class=SensorDeviceClass.ENERGY, 100 | value_fn=lambda circuit: circuit.produced_energy, 101 | ), 102 | SpanPanelCircuitsSensorEntityDescription( 103 | key=CIRCUITS_ENERGY_CONSUMED, 104 | name="Consumed Energy", 105 | native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, 106 | state_class=SensorStateClass.TOTAL_INCREASING, 107 | suggested_display_precision=2, 108 | device_class=SensorDeviceClass.ENERGY, 109 | value_fn=lambda circuit: circuit.consumed_energy, 110 | ), 111 | ) 112 | 113 | PANEL_SENSORS = ( 114 | SpanPanelDataSensorEntityDescription( 115 | key="instantGridPowerW", 116 | name="Current Power", 117 | native_unit_of_measurement=UnitOfPower.WATT, 118 | device_class=SensorDeviceClass.POWER, 119 | state_class=SensorStateClass.MEASUREMENT, 120 | suggested_display_precision=2, 121 | value_fn=lambda panel_data: panel_data.instant_grid_power, 122 | ), 123 | SpanPanelDataSensorEntityDescription( 124 | key="feedthroughPowerW", 125 | name="Feed Through Power", 126 | native_unit_of_measurement=UnitOfPower.WATT, 127 | device_class=SensorDeviceClass.POWER, 128 | state_class=SensorStateClass.MEASUREMENT, 129 | suggested_display_precision=2, 130 | value_fn=lambda panel_data: panel_data.feedthrough_power, 131 | ), 132 | SpanPanelDataSensorEntityDescription( 133 | key="mainMeterEnergy.producedEnergyWh", 134 | name="Main Meter Produced Energy", 135 | native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, 136 | state_class=SensorStateClass.TOTAL_INCREASING, 137 | suggested_display_precision=2, 138 | device_class=SensorDeviceClass.ENERGY, 139 | value_fn=lambda panel_data: panel_data.main_meter_energy_produced, 140 | ), 141 | SpanPanelDataSensorEntityDescription( 142 | key="mainMeterEnergy.consumedEnergyWh", 143 | name="Main Meter Consumed Energy", 144 | native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, 145 | state_class=SensorStateClass.TOTAL_INCREASING, 146 | suggested_display_precision=2, 147 | device_class=SensorDeviceClass.ENERGY, 148 | value_fn=lambda panel_data: panel_data.main_meter_energy_consumed, 149 | ), 150 | SpanPanelDataSensorEntityDescription( 151 | key="feedthroughEnergy.producedEnergyWh", 152 | name="Feed Through Produced Energy", 153 | native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, 154 | state_class=SensorStateClass.TOTAL_INCREASING, 155 | suggested_display_precision=2, 156 | device_class=SensorDeviceClass.ENERGY, 157 | value_fn=lambda panel_data: panel_data.feedthrough_energy_produced, 158 | ), 159 | SpanPanelDataSensorEntityDescription( 160 | key="feedthroughEnergy.consumedEnergyWh", 161 | name="Feed Through Consumed Energy", 162 | native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, 163 | state_class=SensorStateClass.TOTAL_INCREASING, 164 | suggested_display_precision=2, 165 | device_class=SensorDeviceClass.ENERGY, 166 | value_fn=lambda panel_data: panel_data.feedthrough_energy_consumed, 167 | ), 168 | ) 169 | 170 | INVERTER_SENSORS = ( 171 | SpanPanelDataSensorEntityDescription( 172 | key="solar_inverter_instant_power", 173 | name="Solar Inverter Instant Power", 174 | native_unit_of_measurement=UnitOfPower.WATT, 175 | device_class=SensorDeviceClass.POWER, 176 | suggested_display_precision=2, 177 | state_class=SensorStateClass.MEASUREMENT, 178 | value_fn=lambda panel_data: panel_data.solar_inverter_instant_power, 179 | ), 180 | SpanPanelDataSensorEntityDescription( 181 | key="solar_inverter_energy_produced", 182 | name="Solar Inverter Energy Produced", 183 | native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, 184 | device_class=SensorDeviceClass.ENERGY, 185 | suggested_display_precision=2, 186 | state_class=SensorStateClass.TOTAL_INCREASING, 187 | value_fn=lambda panel_data: panel_data.solar_inverter_energy_produced, 188 | ), 189 | SpanPanelDataSensorEntityDescription( 190 | key="solar_inverter_energy_consumed", 191 | name="Solar Inverter Energy Consumed", 192 | native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, 193 | device_class=SensorDeviceClass.ENERGY, 194 | suggested_display_precision=2, 195 | state_class=SensorStateClass.TOTAL_INCREASING, 196 | value_fn=lambda panel_data: panel_data.solar_inverter_energy_consumed, 197 | ), 198 | ) 199 | 200 | PANEL_DATA_STATUS_SENSORS = ( 201 | SpanPanelDataSensorEntityDescription( 202 | key=CURRENT_RUN_CONFIG, 203 | name="Current Run Config", 204 | value_fn=lambda panel_data: panel_data.current_run_config, 205 | ), 206 | SpanPanelDataSensorEntityDescription( 207 | key=DSM_GRID_STATE, 208 | name="DSM Grid State", 209 | value_fn=lambda panel_data: panel_data.dsm_grid_state, 210 | ), 211 | SpanPanelDataSensorEntityDescription( 212 | key=DSM_STATE, 213 | name="DSM State", 214 | value_fn=lambda panel_data: panel_data.dsm_state, 215 | ), 216 | SpanPanelDataSensorEntityDescription( 217 | key=MAIN_RELAY_STATE, 218 | name="Main Relay State", 219 | value_fn=lambda panel_data: panel_data.main_relay_state, 220 | ), 221 | ) 222 | 223 | STATUS_SENSORS = ( 224 | SpanPanelStatusSensorEntityDescription( 225 | key=STATUS_SOFTWARE_VER, 226 | name="Software Version", 227 | value_fn=lambda status: getattr(status, "firmware_version", "unknown_version"), 228 | ), 229 | ) 230 | 231 | STORAGE_BATTERY_SENSORS = ( 232 | SpanPanelStorageBatterySensorEntityDescription( 233 | key=STORAGE_BATTERY_PERCENTAGE, 234 | name="SPAN Storage Battery Percentage", 235 | native_unit_of_measurement=PERCENTAGE, 236 | device_class=SensorDeviceClass.BATTERY, 237 | state_class=SensorStateClass.MEASUREMENT, 238 | value_fn=lambda storage_battery: (storage_battery.storage_battery_percentage), 239 | ), 240 | ) 241 | 242 | ICON = "mdi:flash" 243 | _LOGGER = logging.getLogger(__name__) 244 | 245 | T = TypeVar('T', bound=SensorEntityDescription) 246 | 247 | class SpanSensorBase(CoordinatorEntity[SpanPanelCoordinator], SensorEntity, Generic[T]): 248 | """Base class for Span Panel Sensors.""" 249 | 250 | _attr_icon = ICON 251 | entity_description: T 252 | 253 | def __init__( 254 | self, 255 | data_coordinator: SpanPanelCoordinator, 256 | description: T, 257 | span_panel: SpanPanel, 258 | ) -> None: 259 | """Initialize Span Panel Sensor base entity.""" 260 | super().__init__(data_coordinator, context=description) 261 | self.entity_description = description 262 | device_info = panel_to_device_info(span_panel) 263 | self._attr_device_info = device_info 264 | base_name = f"{description.name}" 265 | 266 | if (data_coordinator.config_entry is not None and 267 | data_coordinator.config_entry.options.get(USE_DEVICE_PREFIX, False) and 268 | device_info is not None and 269 | isinstance(device_info, dict) and 270 | "name" in device_info): 271 | self._attr_name = f"{device_info['name']} {base_name}" 272 | else: 273 | self._attr_name = base_name 274 | 275 | self._attr_unique_id = ( 276 | f"span_{span_panel.status.serial_number}_{description.key}" 277 | ) 278 | 279 | _LOGGER.debug("CREATE SENSOR SPAN [%s]", self._attr_name) 280 | 281 | @property 282 | def native_value(self) -> float | str | None: 283 | """Return the state of the sensor.""" 284 | # Get atomic snapshot of panel data 285 | span_panel: SpanPanel = self.coordinator.data 286 | value_function = getattr(self.entity_description, "value_fn", None) 287 | if value_function is not None: 288 | # Get atomic snapshot of required data source 289 | data_source = self.get_data_source(span_panel) 290 | value = value_function(data_source) 291 | else: 292 | value = None 293 | _LOGGER.debug("native_value:[%s] [%s]", self._attr_name, value) 294 | return value 295 | 296 | def get_data_source(self, span_panel: SpanPanel) -> Any: 297 | """Get the data source for the sensor.""" 298 | raise NotImplementedError("Subclasses must implement this method") 299 | 300 | 301 | class SpanPanelCircuitSensor(SpanSensorBase[SpanPanelCircuitsSensorEntityDescription]): 302 | """Initialize SpanPanelCircuitSensor""" 303 | 304 | def __init__( 305 | self, 306 | coordinator: SpanPanelCoordinator, 307 | description: SpanPanelCircuitsSensorEntityDescription, 308 | circuit_id: str, 309 | name: str, 310 | span_panel: SpanPanel, 311 | ) -> None: 312 | """Initialize Span Panel Circuit entity.""" 313 | # Create a new description with modified name including circuit name 314 | circuit_description = SpanPanelCircuitsSensorEntityDescription( 315 | **{ 316 | **vars(description), 317 | "name": f"{name} {description.name}" 318 | } 319 | ) 320 | super().__init__(coordinator, circuit_description, span_panel) 321 | self.id = circuit_id 322 | self._attr_unique_id = ( 323 | f"span_{span_panel.status.serial_number}_{circuit_id}_{description.key}" 324 | ) 325 | 326 | def get_data_source(self, span_panel: SpanPanel) -> SpanPanelCircuit: 327 | return span_panel.circuits[self.id] 328 | 329 | 330 | class SpanPanelPanel(SpanSensorBase[SpanPanelDataSensorEntityDescription]): 331 | """Initialize SpanPanelPanel""" 332 | 333 | def get_data_source(self, span_panel: SpanPanel) -> SpanPanelData: 334 | return span_panel.panel 335 | 336 | 337 | class SpanPanelPanelStatus(SpanSensorBase[SpanPanelDataSensorEntityDescription]): 338 | """Initialize SpanPanelPanelStatus""" 339 | 340 | def get_data_source(self, span_panel: SpanPanel) -> SpanPanelData: 341 | return span_panel.panel 342 | 343 | 344 | class SpanPanelStatus(SpanSensorBase[SpanPanelStatusSensorEntityDescription]): 345 | """Initialize SpanPanelStatus""" 346 | 347 | def get_data_source(self, span_panel: SpanPanel) -> SpanPanelHardwareStatus: 348 | return span_panel.status 349 | 350 | 351 | class SpanPanelStorageBatteryStatus(SpanSensorBase[SpanPanelStorageBatterySensorEntityDescription]): 352 | """Initialize SpanPanelStorageBatteryStatus""" 353 | 354 | _attr_icon = "mdi:battery" 355 | 356 | def get_data_source(self, span_panel: SpanPanel) -> SpanPanelStorageBattery: 357 | return span_panel.storage_battery 358 | 359 | 360 | async def async_setup_entry( 361 | hass: HomeAssistant, 362 | config_entry: ConfigEntry, 363 | async_add_entities: AddEntitiesCallback, 364 | ) -> None: 365 | """Set up sensor platform.""" 366 | data: dict[str, Any] = hass.data[DOMAIN][config_entry.entry_id] 367 | coordinator: SpanPanelCoordinator = data[COORDINATOR] 368 | span_panel: SpanPanel = coordinator.data 369 | 370 | entities: List[SpanSensorBase[Any]] = [] 371 | 372 | for description in PANEL_SENSORS: 373 | entities.append(SpanPanelPanelStatus(coordinator, description, span_panel)) 374 | 375 | for description in PANEL_DATA_STATUS_SENSORS: 376 | entities.append(SpanPanelPanelStatus(coordinator, description, span_panel)) 377 | 378 | if config_entry.options.get(INVERTER_ENABLE, False): 379 | for description_i in INVERTER_SENSORS: 380 | entities.append(SpanPanelPanelStatus(coordinator, description_i, span_panel)) 381 | 382 | for description_ss in STATUS_SENSORS: 383 | entities.append(SpanPanelStatus(coordinator, description_ss, span_panel)) 384 | 385 | for description_cs in CIRCUITS_SENSORS: 386 | for id_c, circuit_data in span_panel.circuits.items(): 387 | entities.append( 388 | SpanPanelCircuitSensor( 389 | coordinator, description_cs, id_c, circuit_data.name, span_panel 390 | ) 391 | ) 392 | if config_entry.options.get(BATTERY_ENABLE, False): 393 | for description_sb in STORAGE_BATTERY_SENSORS: 394 | entities.append( 395 | SpanPanelStorageBatteryStatus(coordinator, description_sb, span_panel) 396 | ) 397 | 398 | async_add_entities(entities) 399 | -------------------------------------------------------------------------------- /custom_components/span_panel/span_panel.py: -------------------------------------------------------------------------------- 1 | """Module to read production and consumption values from a Span panel.""" 2 | 3 | import logging 4 | from copy import deepcopy 5 | from typing import Dict 6 | 7 | from homeassistant.helpers.httpx_client import httpx 8 | 9 | from .exceptions import SpanPanelReturnedEmptyData 10 | from .options import Options 11 | from .span_panel_api import SpanPanelApi 12 | from .span_panel_circuit import SpanPanelCircuit 13 | from .span_panel_data import SpanPanelData 14 | from .span_panel_hardware_status import SpanPanelHardwareStatus 15 | from .span_panel_storage_battery import SpanPanelStorageBattery 16 | 17 | STATUS_URL = "http://{}/api/v1/status" 18 | SPACES_URL = "http://{}/api/v1/spaces" 19 | CIRCUITS_URL = "http://{}/api/v1/circuits" 20 | PANEL_URL = "http://{}/api/v1/panel" 21 | REGISTER_URL = "http://{}/api/v1/auth/register" 22 | STORAGE_BATTERY_URL = "http://{}/api/v1/storage/soe" 23 | 24 | _LOGGER = logging.getLogger(__name__) 25 | 26 | SPAN_CIRCUITS = "circuits" 27 | SPAN_SYSTEM = "system" 28 | PANEL_POWER = "instantGridPowerW" 29 | SYSTEM_DOOR_STATE = "doorState" 30 | SYSTEM_DOOR_STATE_CLOSED = "CLOSED" 31 | SYSTEM_DOOR_STATE_OPEN = "OPEN" 32 | SYSTEM_ETHERNET_LINK = "eth0Link" 33 | SYSTEM_CELLULAR_LINK = "wwanLink" 34 | SYSTEM_WIFI_LINK = "wlanLink" 35 | 36 | 37 | class SpanPanel: 38 | """Class to manage the Span Panel.""" 39 | 40 | def __init__( 41 | self, 42 | host: str, 43 | access_token: str | None = None, # nosec 44 | options: Options | None = None, 45 | async_client: httpx.AsyncClient | None = None, 46 | ) -> None: 47 | """Initialize the Span Panel.""" 48 | self._options = options 49 | self.api = SpanPanelApi(host, access_token, options, async_client) 50 | self._status: SpanPanelHardwareStatus | None = None 51 | self._panel: SpanPanelData | None = None 52 | self._circuits: Dict[str, SpanPanelCircuit] = {} 53 | self._storage_battery: SpanPanelStorageBattery | None = None 54 | 55 | def _get_hardware_status(self) -> SpanPanelHardwareStatus: 56 | """Get hardware status with type checking.""" 57 | if self._status is None: 58 | raise RuntimeError("Hardware status not available") 59 | return deepcopy(self._status) 60 | 61 | def _get_data(self) -> SpanPanelData: 62 | """Get data with type checking.""" 63 | if self._panel is None: 64 | raise RuntimeError("Panel data not available") 65 | return deepcopy(self._panel) 66 | 67 | def _get_storage_battery(self) -> SpanPanelStorageBattery: 68 | """Get storage battery with type checking.""" 69 | if self._storage_battery is None: 70 | raise RuntimeError("Storage battery not available") 71 | return deepcopy(self._storage_battery) 72 | 73 | @property 74 | def host(self) -> str: 75 | """Return the host of the panel.""" 76 | return self.api.host 77 | 78 | @property 79 | def options(self) -> Options | None: 80 | """Get options data atomically""" 81 | return deepcopy(self._options) if self._options else None 82 | 83 | def _update_status(self, new_status: SpanPanelHardwareStatus) -> None: 84 | """Atomic update of status data""" 85 | self._status = deepcopy(new_status) 86 | 87 | def _update_panel(self, new_panel: SpanPanelData) -> None: 88 | """Atomic update of panel data""" 89 | self._panel = deepcopy(new_panel) 90 | 91 | def _update_circuits(self, new_circuits: Dict[str, SpanPanelCircuit]) -> None: 92 | """Atomic update of circuits data""" 93 | self._circuits = deepcopy(new_circuits) 94 | 95 | def _update_storage_battery(self, new_battery: SpanPanelStorageBattery) -> None: 96 | """Atomic update of storage battery data""" 97 | self._storage_battery = deepcopy(new_battery) 98 | 99 | async def update(self) -> None: 100 | """Update all panel data atomically""" 101 | try: 102 | _LOGGER.debug("Starting panel update") 103 | # Get new data 104 | new_status = await self.api.get_status_data() 105 | _LOGGER.debug("Got status data: %s", new_status) 106 | new_panel = await self.api.get_panel_data() 107 | _LOGGER.debug("Got panel data: %s", new_panel) 108 | new_circuits = await self.api.get_circuits_data() 109 | _LOGGER.debug("Got circuits data: %s", new_circuits) 110 | 111 | # Atomic updates 112 | self._update_status(new_status) 113 | self._update_panel(new_panel) 114 | self._update_circuits(new_circuits) 115 | 116 | if self._options and self._options.enable_battery_percentage: 117 | new_battery = await self.api.get_storage_battery_data() 118 | _LOGGER.debug("Got battery data: %s", new_battery) 119 | self._update_storage_battery(new_battery) 120 | 121 | _LOGGER.debug("Panel update completed successfully") 122 | except SpanPanelReturnedEmptyData: 123 | _LOGGER.warning("Span Panel returned empty data") 124 | except Exception as err: 125 | _LOGGER.error("Error updating panel: %s", err, exc_info=True) 126 | raise 127 | 128 | @property 129 | def status(self) -> SpanPanelHardwareStatus: 130 | """Get status data atomically""" 131 | return self._get_hardware_status() 132 | 133 | @property 134 | def panel(self) -> SpanPanelData: 135 | """Get panel data atomically""" 136 | return self._get_data() 137 | 138 | @property 139 | def circuits(self) -> Dict[str, SpanPanelCircuit]: 140 | """Get circuits data atomically""" 141 | return deepcopy(self._circuits) 142 | 143 | @property 144 | def storage_battery(self) -> SpanPanelStorageBattery: 145 | """Get storage battery data atomically""" 146 | return self._get_storage_battery() 147 | -------------------------------------------------------------------------------- /custom_components/span_panel/span_panel_api.py: -------------------------------------------------------------------------------- 1 | """Span Panel API""" 2 | 3 | import logging 4 | import uuid 5 | from copy import deepcopy 6 | from typing import Any, Dict 7 | 8 | from homeassistant.helpers.httpx_client import httpx 9 | 10 | from .const import (API_TIMEOUT, PANEL_MAIN_RELAY_STATE_UNKNOWN_VALUE, 11 | SPAN_CIRCUITS, SPAN_SOE, URL_CIRCUITS, URL_PANEL, 12 | URL_REGISTER, URL_STATUS, URL_STORAGE_BATTERY, 13 | CircuitPriority, CircuitRelayState) 14 | from .exceptions import SpanPanelReturnedEmptyData 15 | from .options import Options 16 | from .span_panel_circuit import SpanPanelCircuit 17 | from .span_panel_data import SpanPanelData 18 | from .span_panel_hardware_status import SpanPanelHardwareStatus 19 | from .span_panel_storage_battery import SpanPanelStorageBattery 20 | 21 | _LOGGER = logging.getLogger(__name__) 22 | 23 | 24 | class SpanPanelApi: 25 | """Span Panel API""" 26 | 27 | def __init__( 28 | self, 29 | host: str, 30 | access_token: str | None = None, # nosec 31 | options: Options | None = None, 32 | async_client: httpx.AsyncClient | None = None, 33 | ) -> None: 34 | self.host: str = host.lower() 35 | self.access_token: str | None = access_token 36 | self.options: Options | None = options 37 | self._async_client = async_client 38 | 39 | @property 40 | def async_client(self): 41 | """Return the httpx.AsyncClient""" 42 | 43 | return self._async_client or httpx.AsyncClient(verify=True) 44 | 45 | async def ping(self) -> bool: 46 | """Ping the Span Panel API""" 47 | 48 | # status endpoint doesn't require auth. 49 | try: 50 | await self.get_status_data() 51 | return True 52 | except httpx.HTTPError: 53 | return False 54 | 55 | async def ping_with_auth(self) -> bool: 56 | """Test connection and authentication.""" 57 | try: 58 | # Use get_panel_data() since it requires authentication 59 | await self.get_panel_data() 60 | return True 61 | except httpx.HTTPStatusError as err: 62 | if err.response.status_code == httpx.codes.UNAUTHORIZED: 63 | return False 64 | raise 65 | except Exception: 66 | return False 67 | 68 | async def get_access_token(self) -> str: 69 | """Get the access token""" 70 | register_results = await self.post_data( 71 | URL_REGISTER, 72 | { 73 | "name": f"home-assistant-{uuid.uuid4()}", 74 | "description": "Home Assistant Local Span Integration", 75 | }, 76 | ) 77 | return register_results.json()["accessToken"] 78 | 79 | async def get_status_data(self) -> SpanPanelHardwareStatus: 80 | """Get the status data""" 81 | response = await self.get_data(URL_STATUS) 82 | status_data = SpanPanelHardwareStatus.from_dict(response.json()) 83 | return status_data 84 | 85 | async def get_panel_data(self) -> SpanPanelData: 86 | """Get the panel data""" 87 | response = await self.get_data(URL_PANEL) 88 | # Deep copy the raw data before processing in case cached data cleaned up 89 | raw_data = deepcopy(response.json()) 90 | panel_data = SpanPanelData.from_dict(raw_data, self.options) 91 | 92 | # Span Panel API might return empty result. 93 | # We use relay state == UNKNOWN as an indication of that scenario. 94 | if panel_data.main_relay_state == PANEL_MAIN_RELAY_STATE_UNKNOWN_VALUE: 95 | raise SpanPanelReturnedEmptyData() 96 | 97 | return panel_data 98 | 99 | async def get_circuits_data(self) -> Dict[str, SpanPanelCircuit]: 100 | """Get the circuits data""" 101 | response = await self.get_data(URL_CIRCUITS) 102 | raw_circuits_data = deepcopy(response.json()[SPAN_CIRCUITS]) 103 | 104 | if not raw_circuits_data: 105 | raise SpanPanelReturnedEmptyData() 106 | 107 | circuits_data: Dict[str, SpanPanelCircuit] = {} 108 | for circuit_id, raw_circuit_data in raw_circuits_data.items(): 109 | circuits_data[circuit_id] = SpanPanelCircuit.from_dict(raw_circuit_data) 110 | return circuits_data 111 | 112 | async def get_storage_battery_data(self) -> SpanPanelStorageBattery: 113 | """Get the storage battery data""" 114 | response = await self.get_data(URL_STORAGE_BATTERY) 115 | storage_battery_data = response.json()[SPAN_SOE] 116 | 117 | # Span Panel API might return empty result. 118 | # We use relay state == UNKNOWN as an indication of that scenario. 119 | if not storage_battery_data: 120 | raise SpanPanelReturnedEmptyData() 121 | 122 | return SpanPanelStorageBattery.from_dic(storage_battery_data) 123 | 124 | async def set_relay(self, circuit: SpanPanelCircuit, state: CircuitRelayState): 125 | """Set the relay state""" 126 | await self.post_data( 127 | f"{URL_CIRCUITS}/{circuit.circuit_id}", 128 | {"relayStateIn": {"relayState": state.name}}, 129 | ) 130 | 131 | async def set_priority(self, circuit: SpanPanelCircuit, priority: CircuitPriority): 132 | """Set the priority""" 133 | await self.post_data( 134 | f"{URL_CIRCUITS}/{circuit.circuit_id}", 135 | {"priorityIn": {"priority": priority.name}}, 136 | ) 137 | 138 | async def get_data(self, url) -> httpx.Response: 139 | """ 140 | Fetch data from the endpoint and if inverters selected default 141 | to fetching inverter data. 142 | Update from PC endpoint. 143 | """ 144 | formatted_url = url.format(self.host) 145 | response = await self._async_fetch_with_retry( 146 | formatted_url, follow_redirects=False 147 | ) 148 | return response 149 | 150 | async def post_data(self, url: str, payload: dict) -> httpx.Response: 151 | """Post data to the endpoint""" 152 | formatted_url = url.format(self.host) 153 | response = await self._async_post(formatted_url, payload) 154 | return response 155 | 156 | async def _async_fetch_with_retry(self, url, **kwargs) -> httpx.Response: 157 | """ 158 | Retry 3 times to fetch the url if there is a transport error. 159 | """ 160 | headers = {"Accept": "application/json"} 161 | if self.access_token: 162 | headers["Authorization"] = f"Bearer {self.access_token}" 163 | 164 | for attempt in range(3): 165 | _LOGGER.debug("HTTP GET Attempt #%s: %s", attempt + 1, url) 166 | try: 167 | async with self.async_client as client: 168 | resp = await client.get( 169 | url, timeout=API_TIMEOUT, headers=headers, **kwargs 170 | ) 171 | resp.raise_for_status() 172 | _LOGGER.debug("Fetched from %s: %s: %s", url, resp, resp.text) 173 | return resp 174 | except httpx.TransportError: 175 | if attempt == 2: 176 | raise 177 | raise httpx.TransportError("Too many attempts") 178 | 179 | async def _async_post( 180 | self, url: str, json: dict[str, Any] | None = None, **kwargs 181 | ) -> httpx.Response: 182 | """ 183 | POST to the url 184 | """ 185 | headers = {"accept": "application/json"} 186 | if self.access_token: 187 | headers["Authorization"] = f"Bearer {self.access_token}" 188 | 189 | _LOGGER.debug("HTTP POST Attempt: %s", url) 190 | async with self.async_client as client: 191 | resp = await client.post( 192 | url, json=json, headers=headers, timeout=API_TIMEOUT, **kwargs 193 | ) 194 | resp.raise_for_status() 195 | _LOGGER.debug("HTTP POST %s: %s: %s", url, resp, resp.text) 196 | return resp 197 | -------------------------------------------------------------------------------- /custom_components/span_panel/span_panel_circuit.py: -------------------------------------------------------------------------------- 1 | from copy import deepcopy 2 | from dataclasses import dataclass, field 3 | from typing import Any 4 | 5 | from .const import CircuitRelayState 6 | 7 | 8 | @dataclass 9 | class SpanPanelCircuit: 10 | circuit_id: str 11 | name: str 12 | relay_state: str 13 | instant_power: float 14 | instant_power_update_time: int 15 | produced_energy: float 16 | consumed_energy: float 17 | energy_accum_update_time: int 18 | tabs: list[int] 19 | priority: str 20 | is_user_controllable: bool 21 | is_sheddable: bool 22 | is_never_backup: bool 23 | breaker_positions: list = field(default_factory=list) 24 | metadata: dict = field(default_factory=dict) 25 | circuit_config: dict = field(default_factory=dict) 26 | state_config: dict = field(default_factory=dict) 27 | raw_data: dict = field(default_factory=dict) 28 | 29 | @property 30 | def is_relay_closed(self): 31 | return self.relay_state == CircuitRelayState.CLOSED.name 32 | 33 | @staticmethod 34 | def from_dict(data: dict[str, Any]): 35 | data_copy = deepcopy(data) 36 | return SpanPanelCircuit( 37 | circuit_id=data_copy["id"], 38 | name=data_copy["name"], 39 | relay_state=data_copy["relayState"], 40 | instant_power=data_copy["instantPowerW"], 41 | instant_power_update_time=data_copy["instantPowerUpdateTimeS"], 42 | produced_energy=data_copy["producedEnergyWh"], 43 | consumed_energy=data_copy["consumedEnergyWh"], 44 | energy_accum_update_time=data_copy["energyAccumUpdateTimeS"], 45 | tabs=data_copy["tabs"], 46 | priority=data_copy["priority"], 47 | is_user_controllable=data_copy["isUserControllable"], 48 | is_sheddable=data_copy["isSheddable"], 49 | is_never_backup=data_copy["isNeverBackup"], 50 | circuit_config=data_copy.get("config", {}), 51 | state_config=data_copy.get("state", {}), 52 | raw_data=data_copy 53 | ) 54 | 55 | def copy(self) -> 'SpanPanelCircuit': 56 | """Create a deep copy for atomic operations.""" 57 | # Circuit contains nested mutable objects, use deepcopy 58 | return deepcopy(self) 59 | -------------------------------------------------------------------------------- /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 | main_relay_state: str 13 | main_meter_energy_produced: float 14 | main_meter_energy_consumed: float 15 | instant_grid_power: float 16 | feedthrough_power: float 17 | feedthrough_energy_produced: float 18 | feedthrough_energy_consumed: float 19 | grid_sample_start_ms: int 20 | grid_sample_end_ms: int 21 | dsm_grid_state: str 22 | dsm_state: str 23 | current_run_config: str 24 | solar_inverter_instant_power: float 25 | solar_inverter_energy_produced: float 26 | solar_inverter_energy_consumed: float 27 | main_meter_energy: dict = field(default_factory=dict) 28 | feedthrough_energy: dict = field(default_factory=dict) 29 | solar_data: dict = field(default_factory=dict) 30 | inverter_data: dict = field(default_factory=dict) 31 | relay_states: dict = field(default_factory=dict) 32 | solar_inverter_data: dict = field(default_factory=dict) 33 | state_data: dict = field(default_factory=dict) 34 | raw_data: dict = field(default_factory=dict) 35 | 36 | @classmethod 37 | def from_dict(cls, data: dict[str, Any], options: Options | None = None) -> "SpanPanelData": 38 | """Create instance from dict with deep copy of input data""" 39 | data = deepcopy(data) 40 | common_data: dict[str, Any] = { 41 | "main_relay_state": str(data["mainRelayState"]), 42 | "main_meter_energy_produced": float( 43 | data["mainMeterEnergy"]["producedEnergyWh"] 44 | ), 45 | "main_meter_energy_consumed": float( 46 | data["mainMeterEnergy"]["consumedEnergyWh"] 47 | ), 48 | "instant_grid_power": float(data["instantGridPowerW"]), 49 | "feedthrough_power": float(data["feedthroughPowerW"]), 50 | "feedthrough_energy_produced": float( 51 | data["feedthroughEnergy"]["producedEnergyWh"] 52 | ), 53 | "feedthrough_energy_consumed": float( 54 | data["feedthroughEnergy"]["consumedEnergyWh"] 55 | ), 56 | "grid_sample_start_ms": int(data["gridSampleStartMs"]), 57 | "grid_sample_end_ms": int(data["gridSampleEndMs"]), 58 | "dsm_grid_state": str(data["dsmGridState"]), 59 | "dsm_state": str(data["dsmState"]), 60 | "current_run_config": str(data["currentRunConfig"]), 61 | "solar_inverter_instant_power": 0.0, 62 | "solar_inverter_energy_produced": 0.0, 63 | "solar_inverter_energy_consumed": 0.0, 64 | "main_meter_energy": data.get("mainMeterEnergy", {}), 65 | "feedthrough_energy": data.get("feedthroughEnergy", {}), 66 | "solar_inverter_data": data.get("solarInverter", {}), 67 | "state_data": data.get("state", {}), 68 | "raw_data": data 69 | } 70 | 71 | if options and options.enable_solar_sensors: 72 | for leg in [options.inverter_leg1, options.inverter_leg2]: 73 | if 1 <= leg <= INVERTER_MAXLEG: 74 | branch = data["branches"][leg - 1] 75 | common_data["solar_inverter_instant_power"] += float( 76 | branch["instantPowerW"] 77 | ) 78 | common_data["solar_inverter_energy_produced"] += float( 79 | branch["importedActiveEnergyWh"] 80 | ) 81 | common_data["solar_inverter_energy_consumed"] += float( 82 | branch["exportedActiveEnergyWh"] 83 | ) 84 | 85 | return cls(**common_data) 86 | 87 | def copy(self) -> 'SpanPanelData': 88 | """Create a deep copy for atomic operations.""" 89 | return deepcopy(self) 90 | -------------------------------------------------------------------------------- /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 | from typing import Any, Dict 6 | 7 | from .const import SYSTEM_DOOR_STATE_CLOSED, SYSTEM_DOOR_STATE_OPEN 8 | 9 | 10 | @dataclass 11 | class SpanPanelHardwareStatus: 12 | firmware_version: str 13 | update_status: str 14 | env: str 15 | manufacturer: str 16 | serial_number: str 17 | model: str 18 | door_state: str 19 | uptime: int 20 | is_ethernet_connected: bool 21 | is_wifi_connected: bool 22 | is_cellular_connected: bool 23 | proximity_proven: bool | None = None 24 | remaining_auth_unlock_button_presses: int = 0 25 | _system_data: Dict[str, Any] = field(default_factory=dict) 26 | 27 | # Door state has been known to return UNKNOWN if the door has not been operated recently 28 | # Sensor is a tamper sensor not a door sensor 29 | @property 30 | def is_door_closed(self) -> bool | None: 31 | if self.door_state is None: 32 | return None 33 | if self.door_state not in (SYSTEM_DOOR_STATE_OPEN, SYSTEM_DOOR_STATE_CLOSED): 34 | return None 35 | return self.door_state == SYSTEM_DOOR_STATE_CLOSED 36 | 37 | @property 38 | def system_data(self) -> Dict[str, Any]: 39 | return deepcopy(self._system_data) 40 | 41 | @classmethod 42 | def from_dict(cls, data: dict) -> 'SpanPanelHardwareStatus': 43 | """Create a new instance with deep copied data.""" 44 | data_copy = deepcopy(data) 45 | system_data = data_copy.get("system", {}) 46 | 47 | # Handle proximity authentication for both new and old firmware 48 | proximity_proven = None 49 | remaining_auth_unlock_button_presses = 0 50 | 51 | if "proximityProven" in system_data: 52 | # New firmware (r202342 and newer) 53 | proximity_proven = system_data["proximityProven"] 54 | else: 55 | # Old firmware (before r202342) 56 | remaining_auth_unlock_button_presses = system_data.get( 57 | "remainingAuthUnlockButtonPresses", 0 58 | ) 59 | 60 | return cls( 61 | firmware_version=data_copy["software"]["firmwareVersion"], 62 | update_status=data_copy["software"]["updateStatus"], 63 | env=data_copy["software"]["env"], 64 | manufacturer=data_copy["system"]["manufacturer"], 65 | serial_number=data_copy["system"]["serial"], 66 | model=data_copy["system"]["model"], 67 | door_state=data_copy["system"]["doorState"], 68 | uptime=data_copy["system"]["uptime"], 69 | is_ethernet_connected=data_copy["network"]["eth0Link"], 70 | is_wifi_connected=data_copy["network"]["wlanLink"], 71 | is_cellular_connected=data_copy["network"]["wwanLink"], 72 | proximity_proven=proximity_proven, 73 | remaining_auth_unlock_button_presses=remaining_auth_unlock_button_presses, 74 | _system_data=system_data 75 | ) 76 | 77 | def copy(self) -> 'SpanPanelHardwareStatus': 78 | """Create a deep copy of hardware status""" 79 | return deepcopy(self) 80 | -------------------------------------------------------------------------------- /custom_components/span_panel/span_panel_storage_battery.py: -------------------------------------------------------------------------------- 1 | """span_panel_storage_battery""" 2 | 3 | from copy import deepcopy 4 | from dataclasses import dataclass, field 5 | from typing import Any, Dict 6 | 7 | 8 | @dataclass 9 | class SpanPanelStorageBattery: 10 | """Class to manage the storage battery data.""" 11 | 12 | storage_battery_percentage: int 13 | # Any nested mutable structures should use field with default_factory 14 | raw_data: dict = field(default_factory=dict) 15 | 16 | @staticmethod 17 | def from_dic(data: Dict[str, Any]) -> "SpanPanelStorageBattery": 18 | """read the data from the dictionary""" 19 | return SpanPanelStorageBattery( 20 | storage_battery_percentage=data.get("percentage", 0) 21 | ) 22 | 23 | def copy(self) -> 'SpanPanelStorageBattery': 24 | """Create a deep copy of storage battery data""" 25 | return deepcopy(self) 26 | 27 | @classmethod 28 | def from_dict(cls, data: dict) -> 'SpanPanelStorageBattery': 29 | """Create instance from dict with deep copy of input data""" 30 | data = deepcopy(data) 31 | return cls( 32 | storage_battery_percentage=data.get("batteryPercentage", 0), 33 | raw_data=data 34 | ) 35 | -------------------------------------------------------------------------------- /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 | "data": { 49 | "scan_interval": "Scan interval in seconds", 50 | "enable_solar_circuit": "Enable Solar Inverter Sensors", 51 | "leg1": "Solar Leg 1 (0 is not used)", 52 | "leg2": "Solar Leg 2 (0 is not used)" 53 | } 54 | } 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /custom_components/span_panel/switch.py: -------------------------------------------------------------------------------- 1 | """Control switches.""" 2 | 3 | import logging 4 | from functools import cached_property 5 | from typing import Any 6 | 7 | from homeassistant.components.switch import SwitchEntity 8 | from homeassistant.config_entries import ConfigEntry 9 | from homeassistant.core import HomeAssistant 10 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 11 | from homeassistant.helpers.update_coordinator import CoordinatorEntity 12 | 13 | from .const import COORDINATOR, DOMAIN, USE_DEVICE_PREFIX, CircuitRelayState 14 | from .coordinator import SpanPanelCoordinator 15 | from .span_panel import SpanPanel 16 | from .util import panel_to_device_info 17 | 18 | ICON = "mdi:toggle-switch" 19 | 20 | _LOGGER = logging.getLogger(__name__) 21 | 22 | 23 | class SpanPanelCircuitsSwitch(CoordinatorEntity[SpanPanelCoordinator], SwitchEntity): 24 | """Represent a switch entity.""" 25 | 26 | def __init__(self, coordinator: SpanPanelCoordinator, id: str, name: str) -> None: 27 | """Initialize the values.""" 28 | _LOGGER.debug("CREATE SWITCH %s" % name) 29 | span_panel: SpanPanel = coordinator.data 30 | 31 | self.id = id 32 | self._attr_unique_id = f"span_{span_panel.status.serial_number}_relay_{id}" 33 | self._attr_device_info = panel_to_device_info(span_panel) 34 | super().__init__(coordinator) 35 | 36 | def turn_on(self, **kwargs: Any) -> None: 37 | """Synchronously turn the switch on.""" 38 | self.hass.create_task(self.async_turn_on(**kwargs)) 39 | 40 | def turn_off(self, **kwargs: Any) -> None: 41 | """Synchronously turn the switch off.""" 42 | self.hass.create_task(self.async_turn_off(**kwargs)) 43 | 44 | async def async_turn_on(self, **kwargs: Any) -> None: 45 | """Turn the switch on.""" 46 | span_panel: SpanPanel = self.coordinator.data 47 | circuits = span_panel.circuits # Get atomic snapshot of circuits 48 | if self.id in circuits: 49 | # Create a copy of the circuit for the operation 50 | curr_circuit = circuits[self.id].copy() 51 | # Perform the state change 52 | await span_panel.api.set_relay(curr_circuit, CircuitRelayState.CLOSED) 53 | # Request refresh to get the new state 54 | await self.coordinator.async_request_refresh() 55 | 56 | async def async_turn_off(self, **kwargs: Any) -> None: 57 | """Turn the switch off.""" 58 | span_panel: SpanPanel = self.coordinator.data 59 | circuits = span_panel.circuits # Get atomic snapshot of circuits 60 | if self.id in circuits: 61 | # Create a copy of the circuit for the operation 62 | curr_circuit = circuits[self.id].copy() 63 | # Perform the state change 64 | await span_panel.api.set_relay(curr_circuit, CircuitRelayState.OPEN) 65 | # Request refresh to get the new state 66 | await self.coordinator.async_request_refresh() 67 | 68 | @cached_property 69 | def icon(self): 70 | """Icon to use in the frontend.""" 71 | return ICON 72 | 73 | @cached_property 74 | def name(self): 75 | """Return the switch name.""" 76 | span_panel: SpanPanel = self.coordinator.data 77 | base_name = f"{span_panel.circuits[self.id].name} Breaker" 78 | if self.coordinator.config_entry.options.get(USE_DEVICE_PREFIX, False): 79 | return f"{self._attr_device_info['name']} {base_name}" 80 | return base_name 81 | 82 | @property 83 | def is_on(self) -> bool | None: 84 | """Return true if the switch is on.""" 85 | span_panel: SpanPanel = self.coordinator.data 86 | # Get atomic snapshot of circuits data 87 | circuits = span_panel.circuits 88 | circuit = circuits.get(self.id) 89 | if circuit: 90 | # Use copy to ensure atomic state 91 | circuit = circuit.copy() 92 | return circuit.relay_state == CircuitRelayState.CLOSED.name 93 | return None 94 | 95 | 96 | async def async_setup_entry( 97 | hass: HomeAssistant, 98 | config_entry: ConfigEntry, 99 | async_add_entities: AddEntitiesCallback, 100 | ) -> None: 101 | """ 102 | Set up envoy sensor platform. 103 | """ 104 | 105 | _LOGGER.debug("ASYNC SETUP ENTRY SWITCH") 106 | data: dict[str, Any] = hass.data[DOMAIN][config_entry.entry_id] 107 | 108 | coordinator: SpanPanelCoordinator = data[COORDINATOR] 109 | span_panel: SpanPanel = coordinator.data 110 | 111 | entities: list[SpanPanelCircuitsSwitch] = [] 112 | 113 | for circuit_id, circuit_data in span_panel.circuits.items(): 114 | if circuit_data.is_user_controllable: 115 | entities.append(SpanPanelCircuitsSwitch(coordinator, circuit_id, circuit_data.name)) 116 | 117 | async_add_entities(entities) 118 | -------------------------------------------------------------------------------- /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 | "data": { 49 | "scan_interval": "Scan interval in seconds", 50 | "enable_solar_circuit": "Enable Solar Inverter Sensors", 51 | "leg1": "Solar Leg 1 (0 is not used)", 52 | "leg2": "Solar Leg 2 (0 is not used)", 53 | "enable_battery_percentage": "Enable Storage Battery Percentage Sensor (Must be physically connected)" 54 | } 55 | } 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /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 | "auth_menu": { 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 | "data": { 49 | "scan_interval": "Intervalo de escaneo en segundos", 50 | "enable_solar_circuit": "Habilitar Sensores de Inversor Solar", 51 | "leg1": "Pata solar 1 (0 no se utiliza)", 52 | "leg2": "Pata solar 2 (0 no se utiliza)", 53 | "enable_battery_percentage": "Habilitar el sensor de porcentaje de batería de almacenamiento (debe estar conectado físicamente)" 54 | } 55 | } 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /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 | "auth_menu": { 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 | "data": { 49 | "scan_interval": "Intervalle d'analyse en secondes", 50 | "enable_solar_circuit": "Activer les capteurs de l'onduleur solaire", 51 | "leg1": "Jambe solaire 1 (0 n'est pas utilisé)", 52 | "leg2": "Jambe solaire 2 (0 n'est pas utilisé)", 53 | "enable_battery_percentage": "Activer le capteur de pourcentage de batterie de stockage (doit être physiquement connecté)" 54 | } 55 | } 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /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 | "auth_menu": { 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 | "data": { 49 | "scan_interval": "スキャンインターバル(秒)", 50 | "enable_solar_circuit": "ソーラーインバーターセンサーを有効にする", 51 | "leg1": "ソーラーレッグ1(0は使用されません)", 52 | "leg2": "ソーラーレッグ2(0は使用されません)", 53 | "enable_battery_percentage": "蓄電池パーセントセンサーを有効にする (物理的に接続されている必要があります)" 54 | } 55 | } 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /custom_components/span_panel/util.py: -------------------------------------------------------------------------------- 1 | from homeassistant.helpers.entity import DeviceInfo 2 | 3 | from .const import DOMAIN 4 | from .span_panel import SpanPanel 5 | 6 | 7 | def panel_to_device_info(panel: SpanPanel): 8 | return DeviceInfo( 9 | identifiers={(DOMAIN, panel.status.serial_number)}, 10 | manufacturer="Span", 11 | model=f"Span Panel ({panel.status.model})", 12 | name="Span Panel", 13 | sw_version=panel.status.firmware_version, 14 | configuration_url=f"http://{panel.host}", 15 | ) 16 | -------------------------------------------------------------------------------- /custom_components/span_panel/version.py: -------------------------------------------------------------------------------- 1 | """Version for span.""" 2 | 3 | __version__ = "1.0.4" 4 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | mypy_path = ${HA_CORE_PATH} 3 | 4 | [mypy-homeassistant.*] 5 | ignore_errors = true 6 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. 2 | 3 | [[package]] 4 | name = "annotated-types" 5 | version = "0.7.0" 6 | description = "Reusable constraint types to use with typing.Annotated" 7 | optional = false 8 | python-versions = ">=3.8" 9 | files = [ 10 | {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, 11 | {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, 12 | ] 13 | 14 | [[package]] 15 | name = "bandit" 16 | version = "1.7.9" 17 | description = "Security oriented static analyser for python code." 18 | optional = false 19 | python-versions = ">=3.8" 20 | files = [ 21 | {file = "bandit-1.7.9-py3-none-any.whl", hash = "sha256:52077cb339000f337fb25f7e045995c4ad01511e716e5daac37014b9752de8ec"}, 22 | {file = "bandit-1.7.9.tar.gz", hash = "sha256:7c395a436743018f7be0a4cbb0a4ea9b902b6d87264ddecf8cfdc73b4f78ff61"}, 23 | ] 24 | 25 | [package.dependencies] 26 | colorama = {version = ">=0.3.9", markers = "platform_system == \"Windows\""} 27 | PyYAML = ">=5.3.1" 28 | rich = "*" 29 | stevedore = ">=1.20.0" 30 | 31 | [package.extras] 32 | baseline = ["GitPython (>=3.1.30)"] 33 | sarif = ["jschema-to-python (>=1.2.3)", "sarif-om (>=1.0.4)"] 34 | test = ["beautifulsoup4 (>=4.8.0)", "coverage (>=4.5.4)", "fixtures (>=3.0.0)", "flake8 (>=4.0.0)", "pylint (==1.9.4)", "stestr (>=2.5.0)", "testscenarios (>=0.5.0)", "testtools (>=2.3.0)"] 35 | toml = ["tomli (>=1.1.0)"] 36 | yaml = ["PyYAML"] 37 | 38 | [[package]] 39 | name = "cfgv" 40 | version = "3.4.0" 41 | description = "Validate configuration and produce human readable error messages." 42 | optional = false 43 | python-versions = ">=3.8" 44 | files = [ 45 | {file = "cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9"}, 46 | {file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"}, 47 | ] 48 | 49 | [[package]] 50 | name = "colorama" 51 | version = "0.4.6" 52 | description = "Cross-platform colored terminal text." 53 | optional = false 54 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" 55 | files = [ 56 | {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, 57 | {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, 58 | ] 59 | 60 | [[package]] 61 | name = "distlib" 62 | version = "0.3.8" 63 | description = "Distribution utilities" 64 | optional = false 65 | python-versions = "*" 66 | files = [ 67 | {file = "distlib-0.3.8-py2.py3-none-any.whl", hash = "sha256:034db59a0b96f8ca18035f36290806a9a6e6bd9d1ff91e45a7f172eb17e51784"}, 68 | {file = "distlib-0.3.8.tar.gz", hash = "sha256:1530ea13e350031b6312d8580ddb6b27a104275a31106523b8f123787f494f64"}, 69 | ] 70 | 71 | [[package]] 72 | name = "filelock" 73 | version = "3.16.0" 74 | description = "A platform independent file lock." 75 | optional = false 76 | python-versions = ">=3.8" 77 | files = [ 78 | {file = "filelock-3.16.0-py3-none-any.whl", hash = "sha256:f6ed4c963184f4c84dd5557ce8fece759a3724b37b80c6c4f20a2f63a4dc6609"}, 79 | {file = "filelock-3.16.0.tar.gz", hash = "sha256:81de9eb8453c769b63369f87f11131a7ab04e367f8d97ad39dc230daa07e3bec"}, 80 | ] 81 | 82 | [package.extras] 83 | docs = ["furo (>=2024.8.6)", "sphinx (>=8.0.2)", "sphinx-autodoc-typehints (>=2.4)"] 84 | testing = ["covdefaults (>=2.3)", "coverage (>=7.6.1)", "diff-cover (>=9.1.1)", "pytest (>=8.3.2)", "pytest-asyncio (>=0.24)", "pytest-cov (>=5)", "pytest-mock (>=3.14)", "pytest-timeout (>=2.3.1)", "virtualenv (>=20.26.3)"] 85 | typing = ["typing-extensions (>=4.12.2)"] 86 | 87 | [[package]] 88 | name = "identify" 89 | version = "2.6.0" 90 | description = "File identification library for Python" 91 | optional = false 92 | python-versions = ">=3.8" 93 | files = [ 94 | {file = "identify-2.6.0-py2.py3-none-any.whl", hash = "sha256:e79ae4406387a9d300332b5fd366d8994f1525e8414984e1a59e058b2eda2dd0"}, 95 | {file = "identify-2.6.0.tar.gz", hash = "sha256:cb171c685bdc31bcc4c1734698736a7d5b6c8bf2e0c15117f4d469c8640ae5cf"}, 96 | ] 97 | 98 | [package.extras] 99 | license = ["ukkonen"] 100 | 101 | [[package]] 102 | name = "isort" 103 | version = "5.13.2" 104 | description = "A Python utility / library to sort Python imports." 105 | optional = false 106 | python-versions = ">=3.8.0" 107 | files = [ 108 | {file = "isort-5.13.2-py3-none-any.whl", hash = "sha256:8ca5e72a8d85860d5a3fa69b8745237f2939afe12dbf656afbcb47fe72d947a6"}, 109 | {file = "isort-5.13.2.tar.gz", hash = "sha256:48fdfcb9face5d58a4f6dde2e72a1fb8dcaf8ab26f95ab49fab84c2ddefb0109"}, 110 | ] 111 | 112 | [package.extras] 113 | colors = ["colorama (>=0.4.6)"] 114 | 115 | [[package]] 116 | name = "markdown-it-py" 117 | version = "3.0.0" 118 | description = "Python port of markdown-it. Markdown parsing, done right!" 119 | optional = false 120 | python-versions = ">=3.8" 121 | files = [ 122 | {file = "markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb"}, 123 | {file = "markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1"}, 124 | ] 125 | 126 | [package.dependencies] 127 | mdurl = ">=0.1,<1.0" 128 | 129 | [package.extras] 130 | benchmarking = ["psutil", "pytest", "pytest-benchmark"] 131 | code-style = ["pre-commit (>=3.0,<4.0)"] 132 | compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "mistletoe (>=1.0,<2.0)", "mistune (>=2.0,<3.0)", "panflute (>=2.3,<3.0)"] 133 | linkify = ["linkify-it-py (>=1,<3)"] 134 | plugins = ["mdit-py-plugins"] 135 | profiling = ["gprof2dot"] 136 | rtd = ["jupyter_sphinx", "mdit-py-plugins", "myst-parser", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinx_book_theme"] 137 | testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] 138 | 139 | [[package]] 140 | name = "mdurl" 141 | version = "0.1.2" 142 | description = "Markdown URL utilities" 143 | optional = false 144 | python-versions = ">=3.7" 145 | files = [ 146 | {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, 147 | {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, 148 | ] 149 | 150 | [[package]] 151 | name = "mypy" 152 | version = "1.11.2" 153 | description = "Optional static typing for Python" 154 | optional = false 155 | python-versions = ">=3.8" 156 | files = [ 157 | {file = "mypy-1.11.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d42a6dd818ffce7be66cce644f1dff482f1d97c53ca70908dff0b9ddc120b77a"}, 158 | {file = "mypy-1.11.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:801780c56d1cdb896eacd5619a83e427ce436d86a3bdf9112527f24a66618fef"}, 159 | {file = "mypy-1.11.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:41ea707d036a5307ac674ea172875f40c9d55c5394f888b168033177fce47383"}, 160 | {file = "mypy-1.11.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6e658bd2d20565ea86da7d91331b0eed6d2eee22dc031579e6297f3e12c758c8"}, 161 | {file = "mypy-1.11.2-cp310-cp310-win_amd64.whl", hash = "sha256:478db5f5036817fe45adb7332d927daa62417159d49783041338921dcf646fc7"}, 162 | {file = "mypy-1.11.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:75746e06d5fa1e91bfd5432448d00d34593b52e7e91a187d981d08d1f33d4385"}, 163 | {file = "mypy-1.11.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a976775ab2256aadc6add633d44f100a2517d2388906ec4f13231fafbb0eccca"}, 164 | {file = "mypy-1.11.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd953f221ac1379050a8a646585a29574488974f79d8082cedef62744f0a0104"}, 165 | {file = "mypy-1.11.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:57555a7715c0a34421013144a33d280e73c08df70f3a18a552938587ce9274f4"}, 166 | {file = "mypy-1.11.2-cp311-cp311-win_amd64.whl", hash = "sha256:36383a4fcbad95f2657642a07ba22ff797de26277158f1cc7bd234821468b1b6"}, 167 | {file = "mypy-1.11.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:e8960dbbbf36906c5c0b7f4fbf2f0c7ffb20f4898e6a879fcf56a41a08b0d318"}, 168 | {file = "mypy-1.11.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:06d26c277962f3fb50e13044674aa10553981ae514288cb7d0a738f495550b36"}, 169 | {file = "mypy-1.11.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6e7184632d89d677973a14d00ae4d03214c8bc301ceefcdaf5c474866814c987"}, 170 | {file = "mypy-1.11.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:3a66169b92452f72117e2da3a576087025449018afc2d8e9bfe5ffab865709ca"}, 171 | {file = "mypy-1.11.2-cp312-cp312-win_amd64.whl", hash = "sha256:969ea3ef09617aff826885a22ece0ddef69d95852cdad2f60c8bb06bf1f71f70"}, 172 | {file = "mypy-1.11.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:37c7fa6121c1cdfcaac97ce3d3b5588e847aa79b580c1e922bb5d5d2902df19b"}, 173 | {file = "mypy-1.11.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4a8a53bc3ffbd161b5b2a4fff2f0f1e23a33b0168f1c0778ec70e1a3d66deb86"}, 174 | {file = "mypy-1.11.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ff93107f01968ed834f4256bc1fc4475e2fecf6c661260066a985b52741ddce"}, 175 | {file = "mypy-1.11.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:edb91dded4df17eae4537668b23f0ff6baf3707683734b6a818d5b9d0c0c31a1"}, 176 | {file = "mypy-1.11.2-cp38-cp38-win_amd64.whl", hash = "sha256:ee23de8530d99b6db0573c4ef4bd8f39a2a6f9b60655bf7a1357e585a3486f2b"}, 177 | {file = "mypy-1.11.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:801ca29f43d5acce85f8e999b1e431fb479cb02d0e11deb7d2abb56bdaf24fd6"}, 178 | {file = "mypy-1.11.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:af8d155170fcf87a2afb55b35dc1a0ac21df4431e7d96717621962e4b9192e70"}, 179 | {file = "mypy-1.11.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7821776e5c4286b6a13138cc935e2e9b6fde05e081bdebf5cdb2bb97c9df81d"}, 180 | {file = "mypy-1.11.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:539c570477a96a4e6fb718b8d5c3e0c0eba1f485df13f86d2970c91f0673148d"}, 181 | {file = "mypy-1.11.2-cp39-cp39-win_amd64.whl", hash = "sha256:3f14cd3d386ac4d05c5a39a51b84387403dadbd936e17cb35882134d4f8f0d24"}, 182 | {file = "mypy-1.11.2-py3-none-any.whl", hash = "sha256:b499bc07dbdcd3de92b0a8b29fdf592c111276f6a12fe29c30f6c417dd546d12"}, 183 | {file = "mypy-1.11.2.tar.gz", hash = "sha256:7f9993ad3e0ffdc95c2a14b66dee63729f021968bff8ad911867579c65d13a79"}, 184 | ] 185 | 186 | [package.dependencies] 187 | mypy-extensions = ">=1.0.0" 188 | typing-extensions = ">=4.6.0" 189 | 190 | [package.extras] 191 | dmypy = ["psutil (>=4.0)"] 192 | install-types = ["pip"] 193 | mypyc = ["setuptools (>=50)"] 194 | reports = ["lxml"] 195 | 196 | [[package]] 197 | name = "mypy-extensions" 198 | version = "1.0.0" 199 | description = "Type system extensions for programs checked with the mypy type checker." 200 | optional = false 201 | python-versions = ">=3.5" 202 | files = [ 203 | {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, 204 | {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, 205 | ] 206 | 207 | [[package]] 208 | name = "nodeenv" 209 | version = "1.9.1" 210 | description = "Node.js virtual environment builder" 211 | optional = false 212 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" 213 | files = [ 214 | {file = "nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9"}, 215 | {file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"}, 216 | ] 217 | 218 | [[package]] 219 | name = "pbr" 220 | version = "6.1.0" 221 | description = "Python Build Reasonableness" 222 | optional = false 223 | python-versions = ">=2.6" 224 | files = [ 225 | {file = "pbr-6.1.0-py2.py3-none-any.whl", hash = "sha256:a776ae228892d8013649c0aeccbb3d5f99ee15e005a4cbb7e61d55a067b28a2a"}, 226 | {file = "pbr-6.1.0.tar.gz", hash = "sha256:788183e382e3d1d7707db08978239965e8b9e4e5ed42669bf4758186734d5f24"}, 227 | ] 228 | 229 | [[package]] 230 | name = "platformdirs" 231 | version = "4.3.3" 232 | description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." 233 | optional = false 234 | python-versions = ">=3.8" 235 | files = [ 236 | {file = "platformdirs-4.3.3-py3-none-any.whl", hash = "sha256:50a5450e2e84f44539718293cbb1da0a0885c9d14adf21b77bae4e66fc99d9b5"}, 237 | {file = "platformdirs-4.3.3.tar.gz", hash = "sha256:d4e0b7d8ec176b341fb03cb11ca12d0276faa8c485f9cd218f613840463fc2c0"}, 238 | ] 239 | 240 | [package.extras] 241 | docs = ["furo (>=2024.8.6)", "proselint (>=0.14)", "sphinx (>=8.0.2)", "sphinx-autodoc-typehints (>=2.4)"] 242 | test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.3.2)", "pytest-cov (>=5)", "pytest-mock (>=3.14)"] 243 | type = ["mypy (>=1.11.2)"] 244 | 245 | [[package]] 246 | name = "pre-commit" 247 | version = "3.8.0" 248 | description = "A framework for managing and maintaining multi-language pre-commit hooks." 249 | optional = false 250 | python-versions = ">=3.9" 251 | files = [ 252 | {file = "pre_commit-3.8.0-py2.py3-none-any.whl", hash = "sha256:9a90a53bf82fdd8778d58085faf8d83df56e40dfe18f45b19446e26bf1b3a63f"}, 253 | {file = "pre_commit-3.8.0.tar.gz", hash = "sha256:8bb6494d4a20423842e198980c9ecf9f96607a07ea29549e180eef9ae80fe7af"}, 254 | ] 255 | 256 | [package.dependencies] 257 | cfgv = ">=2.0.0" 258 | identify = ">=1.0.0" 259 | nodeenv = ">=0.11.1" 260 | pyyaml = ">=5.1" 261 | virtualenv = ">=20.10.0" 262 | 263 | [[package]] 264 | name = "pydantic" 265 | version = "2.9.1" 266 | description = "Data validation using Python type hints" 267 | optional = false 268 | python-versions = ">=3.8" 269 | files = [ 270 | {file = "pydantic-2.9.1-py3-none-any.whl", hash = "sha256:7aff4db5fdf3cf573d4b3c30926a510a10e19a0774d38fc4967f78beb6deb612"}, 271 | {file = "pydantic-2.9.1.tar.gz", hash = "sha256:1363c7d975c7036df0db2b4a61f2e062fbc0aa5ab5f2772e0ffc7191a4f4bce2"}, 272 | ] 273 | 274 | [package.dependencies] 275 | annotated-types = ">=0.6.0" 276 | pydantic-core = "2.23.3" 277 | typing-extensions = [ 278 | {version = ">=4.12.2", markers = "python_version >= \"3.13\""}, 279 | {version = ">=4.6.1", markers = "python_version < \"3.13\""}, 280 | ] 281 | 282 | [package.extras] 283 | email = ["email-validator (>=2.0.0)"] 284 | timezone = ["tzdata"] 285 | 286 | [[package]] 287 | name = "pydantic-core" 288 | version = "2.23.3" 289 | description = "Core functionality for Pydantic validation and serialization" 290 | optional = false 291 | python-versions = ">=3.8" 292 | files = [ 293 | {file = "pydantic_core-2.23.3-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:7f10a5d1b9281392f1bf507d16ac720e78285dfd635b05737c3911637601bae6"}, 294 | {file = "pydantic_core-2.23.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:3c09a7885dd33ee8c65266e5aa7fb7e2f23d49d8043f089989726391dd7350c5"}, 295 | {file = "pydantic_core-2.23.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6470b5a1ec4d1c2e9afe928c6cb37eb33381cab99292a708b8cb9aa89e62429b"}, 296 | {file = "pydantic_core-2.23.3-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9172d2088e27d9a185ea0a6c8cebe227a9139fd90295221d7d495944d2367700"}, 297 | {file = "pydantic_core-2.23.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:86fc6c762ca7ac8fbbdff80d61b2c59fb6b7d144aa46e2d54d9e1b7b0e780e01"}, 298 | {file = "pydantic_core-2.23.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f0cb80fd5c2df4898693aa841425ea1727b1b6d2167448253077d2a49003e0ed"}, 299 | {file = "pydantic_core-2.23.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:03667cec5daf43ac4995cefa8aaf58f99de036204a37b889c24a80927b629cec"}, 300 | {file = "pydantic_core-2.23.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:047531242f8e9c2db733599f1c612925de095e93c9cc0e599e96cf536aaf56ba"}, 301 | {file = "pydantic_core-2.23.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:5499798317fff7f25dbef9347f4451b91ac2a4330c6669821c8202fd354c7bee"}, 302 | {file = "pydantic_core-2.23.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:bbb5e45eab7624440516ee3722a3044b83fff4c0372efe183fd6ba678ff681fe"}, 303 | {file = "pydantic_core-2.23.3-cp310-none-win32.whl", hash = "sha256:8b5b3ed73abb147704a6e9f556d8c5cb078f8c095be4588e669d315e0d11893b"}, 304 | {file = "pydantic_core-2.23.3-cp310-none-win_amd64.whl", hash = "sha256:2b603cde285322758a0279995b5796d64b63060bfbe214b50a3ca23b5cee3e83"}, 305 | {file = "pydantic_core-2.23.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:c889fd87e1f1bbeb877c2ee56b63bb297de4636661cc9bbfcf4b34e5e925bc27"}, 306 | {file = "pydantic_core-2.23.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ea85bda3189fb27503af4c45273735bcde3dd31c1ab17d11f37b04877859ef45"}, 307 | {file = "pydantic_core-2.23.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a7f7f72f721223f33d3dc98a791666ebc6a91fa023ce63733709f4894a7dc611"}, 308 | {file = "pydantic_core-2.23.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b2b55b0448e9da68f56b696f313949cda1039e8ec7b5d294285335b53104b61"}, 309 | {file = "pydantic_core-2.23.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c24574c7e92e2c56379706b9a3f07c1e0c7f2f87a41b6ee86653100c4ce343e5"}, 310 | {file = "pydantic_core-2.23.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f2b05e6ccbee333a8f4b8f4d7c244fdb7a979e90977ad9c51ea31261e2085ce0"}, 311 | {file = "pydantic_core-2.23.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e2c409ce1c219c091e47cb03feb3c4ed8c2b8e004efc940da0166aaee8f9d6c8"}, 312 | {file = "pydantic_core-2.23.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d965e8b325f443ed3196db890d85dfebbb09f7384486a77461347f4adb1fa7f8"}, 313 | {file = "pydantic_core-2.23.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:f56af3a420fb1ffaf43ece3ea09c2d27c444e7c40dcb7c6e7cf57aae764f2b48"}, 314 | {file = "pydantic_core-2.23.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:5b01a078dd4f9a52494370af21aa52964e0a96d4862ac64ff7cea06e0f12d2c5"}, 315 | {file = "pydantic_core-2.23.3-cp311-none-win32.whl", hash = "sha256:560e32f0df04ac69b3dd818f71339983f6d1f70eb99d4d1f8e9705fb6c34a5c1"}, 316 | {file = "pydantic_core-2.23.3-cp311-none-win_amd64.whl", hash = "sha256:c744fa100fdea0d000d8bcddee95213d2de2e95b9c12be083370b2072333a0fa"}, 317 | {file = "pydantic_core-2.23.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:e0ec50663feedf64d21bad0809f5857bac1ce91deded203efc4a84b31b2e4305"}, 318 | {file = "pydantic_core-2.23.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:db6e6afcb95edbe6b357786684b71008499836e91f2a4a1e55b840955b341dbb"}, 319 | {file = "pydantic_core-2.23.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:98ccd69edcf49f0875d86942f4418a4e83eb3047f20eb897bffa62a5d419c8fa"}, 320 | {file = "pydantic_core-2.23.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a678c1ac5c5ec5685af0133262103defb427114e62eafeda12f1357a12140162"}, 321 | {file = "pydantic_core-2.23.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:01491d8b4d8db9f3391d93b0df60701e644ff0894352947f31fff3e52bd5c801"}, 322 | {file = "pydantic_core-2.23.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fcf31facf2796a2d3b7fe338fe8640aa0166e4e55b4cb108dbfd1058049bf4cb"}, 323 | {file = "pydantic_core-2.23.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7200fd561fb3be06827340da066df4311d0b6b8eb0c2116a110be5245dceb326"}, 324 | {file = "pydantic_core-2.23.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:dc1636770a809dee2bd44dd74b89cc80eb41172bcad8af75dd0bc182c2666d4c"}, 325 | {file = "pydantic_core-2.23.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:67a5def279309f2e23014b608c4150b0c2d323bd7bccd27ff07b001c12c2415c"}, 326 | {file = "pydantic_core-2.23.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:748bdf985014c6dd3e1e4cc3db90f1c3ecc7246ff5a3cd4ddab20c768b2f1dab"}, 327 | {file = "pydantic_core-2.23.3-cp312-none-win32.whl", hash = "sha256:255ec6dcb899c115f1e2a64bc9ebc24cc0e3ab097775755244f77360d1f3c06c"}, 328 | {file = "pydantic_core-2.23.3-cp312-none-win_amd64.whl", hash = "sha256:40b8441be16c1e940abebed83cd006ddb9e3737a279e339dbd6d31578b802f7b"}, 329 | {file = "pydantic_core-2.23.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:6daaf5b1ba1369a22c8b050b643250e3e5efc6a78366d323294aee54953a4d5f"}, 330 | {file = "pydantic_core-2.23.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d015e63b985a78a3d4ccffd3bdf22b7c20b3bbd4b8227809b3e8e75bc37f9cb2"}, 331 | {file = "pydantic_core-2.23.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3fc572d9b5b5cfe13f8e8a6e26271d5d13f80173724b738557a8c7f3a8a3791"}, 332 | {file = "pydantic_core-2.23.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f6bd91345b5163ee7448bee201ed7dd601ca24f43f439109b0212e296eb5b423"}, 333 | {file = "pydantic_core-2.23.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc379c73fd66606628b866f661e8785088afe2adaba78e6bbe80796baf708a63"}, 334 | {file = "pydantic_core-2.23.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fbdce4b47592f9e296e19ac31667daed8753c8367ebb34b9a9bd89dacaa299c9"}, 335 | {file = "pydantic_core-2.23.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc3cf31edf405a161a0adad83246568647c54404739b614b1ff43dad2b02e6d5"}, 336 | {file = "pydantic_core-2.23.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8e22b477bf90db71c156f89a55bfe4d25177b81fce4aa09294d9e805eec13855"}, 337 | {file = "pydantic_core-2.23.3-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:0a0137ddf462575d9bce863c4c95bac3493ba8e22f8c28ca94634b4a1d3e2bb4"}, 338 | {file = "pydantic_core-2.23.3-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:203171e48946c3164fe7691fc349c79241ff8f28306abd4cad5f4f75ed80bc8d"}, 339 | {file = "pydantic_core-2.23.3-cp313-none-win32.whl", hash = "sha256:76bdab0de4acb3f119c2a4bff740e0c7dc2e6de7692774620f7452ce11ca76c8"}, 340 | {file = "pydantic_core-2.23.3-cp313-none-win_amd64.whl", hash = "sha256:37ba321ac2a46100c578a92e9a6aa33afe9ec99ffa084424291d84e456f490c1"}, 341 | {file = "pydantic_core-2.23.3-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:d063c6b9fed7d992bcbebfc9133f4c24b7a7f215d6b102f3e082b1117cddb72c"}, 342 | {file = "pydantic_core-2.23.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:6cb968da9a0746a0cf521b2b5ef25fc5a0bee9b9a1a8214e0a1cfaea5be7e8a4"}, 343 | {file = "pydantic_core-2.23.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:edbefe079a520c5984e30e1f1f29325054b59534729c25b874a16a5048028d16"}, 344 | {file = "pydantic_core-2.23.3-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cbaaf2ef20d282659093913da9d402108203f7cb5955020bd8d1ae5a2325d1c4"}, 345 | {file = "pydantic_core-2.23.3-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fb539d7e5dc4aac345846f290cf504d2fd3c1be26ac4e8b5e4c2b688069ff4cf"}, 346 | {file = "pydantic_core-2.23.3-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7e6f33503c5495059148cc486867e1d24ca35df5fc064686e631e314d959ad5b"}, 347 | {file = "pydantic_core-2.23.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:04b07490bc2f6f2717b10c3969e1b830f5720b632f8ae2f3b8b1542394c47a8e"}, 348 | {file = "pydantic_core-2.23.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:03795b9e8a5d7fda05f3873efc3f59105e2dcff14231680296b87b80bb327295"}, 349 | {file = "pydantic_core-2.23.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:c483dab0f14b8d3f0df0c6c18d70b21b086f74c87ab03c59250dbf6d3c89baba"}, 350 | {file = "pydantic_core-2.23.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8b2682038e255e94baf2c473dca914a7460069171ff5cdd4080be18ab8a7fd6e"}, 351 | {file = "pydantic_core-2.23.3-cp38-none-win32.whl", hash = "sha256:f4a57db8966b3a1d1a350012839c6a0099f0898c56512dfade8a1fe5fb278710"}, 352 | {file = "pydantic_core-2.23.3-cp38-none-win_amd64.whl", hash = "sha256:13dd45ba2561603681a2676ca56006d6dee94493f03d5cadc055d2055615c3ea"}, 353 | {file = "pydantic_core-2.23.3-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:82da2f4703894134a9f000e24965df73cc103e31e8c31906cc1ee89fde72cbd8"}, 354 | {file = "pydantic_core-2.23.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:dd9be0a42de08f4b58a3cc73a123f124f65c24698b95a54c1543065baca8cf0e"}, 355 | {file = "pydantic_core-2.23.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:89b731f25c80830c76fdb13705c68fef6a2b6dc494402987c7ea9584fe189f5d"}, 356 | {file = "pydantic_core-2.23.3-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c6de1ec30c4bb94f3a69c9f5f2182baeda5b809f806676675e9ef6b8dc936f28"}, 357 | {file = "pydantic_core-2.23.3-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bb68b41c3fa64587412b104294b9cbb027509dc2f6958446c502638d481525ef"}, 358 | {file = "pydantic_core-2.23.3-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1c3980f2843de5184656aab58698011b42763ccba11c4a8c35936c8dd6c7068c"}, 359 | {file = "pydantic_core-2.23.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:94f85614f2cba13f62c3c6481716e4adeae48e1eaa7e8bac379b9d177d93947a"}, 360 | {file = "pydantic_core-2.23.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:510b7fb0a86dc8f10a8bb43bd2f97beb63cffad1203071dc434dac26453955cd"}, 361 | {file = "pydantic_core-2.23.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:1eba2f7ce3e30ee2170410e2171867ea73dbd692433b81a93758ab2de6c64835"}, 362 | {file = "pydantic_core-2.23.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:4b259fd8409ab84b4041b7b3f24dcc41e4696f180b775961ca8142b5b21d0e70"}, 363 | {file = "pydantic_core-2.23.3-cp39-none-win32.whl", hash = "sha256:40d9bd259538dba2f40963286009bf7caf18b5112b19d2b55b09c14dde6db6a7"}, 364 | {file = "pydantic_core-2.23.3-cp39-none-win_amd64.whl", hash = "sha256:5a8cd3074a98ee70173a8633ad3c10e00dcb991ecec57263aacb4095c5efb958"}, 365 | {file = "pydantic_core-2.23.3-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:f399e8657c67313476a121a6944311fab377085ca7f490648c9af97fc732732d"}, 366 | {file = "pydantic_core-2.23.3-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:6b5547d098c76e1694ba85f05b595720d7c60d342f24d5aad32c3049131fa5c4"}, 367 | {file = "pydantic_core-2.23.3-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0dda0290a6f608504882d9f7650975b4651ff91c85673341789a476b1159f211"}, 368 | {file = "pydantic_core-2.23.3-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:65b6e5da855e9c55a0c67f4db8a492bf13d8d3316a59999cfbaf98cc6e401961"}, 369 | {file = "pydantic_core-2.23.3-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:09e926397f392059ce0afdcac920df29d9c833256354d0c55f1584b0b70cf07e"}, 370 | {file = "pydantic_core-2.23.3-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:87cfa0ed6b8c5bd6ae8b66de941cece179281239d482f363814d2b986b79cedc"}, 371 | {file = "pydantic_core-2.23.3-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:e61328920154b6a44d98cabcb709f10e8b74276bc709c9a513a8c37a18786cc4"}, 372 | {file = "pydantic_core-2.23.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:ce3317d155628301d649fe5e16a99528d5680af4ec7aa70b90b8dacd2d725c9b"}, 373 | {file = "pydantic_core-2.23.3-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:e89513f014c6be0d17b00a9a7c81b1c426f4eb9224b15433f3d98c1a071f8433"}, 374 | {file = "pydantic_core-2.23.3-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:4f62c1c953d7ee375df5eb2e44ad50ce2f5aff931723b398b8bc6f0ac159791a"}, 375 | {file = "pydantic_core-2.23.3-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2718443bc671c7ac331de4eef9b673063b10af32a0bb385019ad61dcf2cc8f6c"}, 376 | {file = "pydantic_core-2.23.3-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a0d90e08b2727c5d01af1b5ef4121d2f0c99fbee692c762f4d9d0409c9da6541"}, 377 | {file = "pydantic_core-2.23.3-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2b676583fc459c64146debea14ba3af54e540b61762dfc0613dc4e98c3f66eeb"}, 378 | {file = "pydantic_core-2.23.3-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:50e4661f3337977740fdbfbae084ae5693e505ca2b3130a6d4eb0f2281dc43b8"}, 379 | {file = "pydantic_core-2.23.3-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:68f4cf373f0de6abfe599a38307f4417c1c867ca381c03df27c873a9069cda25"}, 380 | {file = "pydantic_core-2.23.3-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:59d52cf01854cb26c46958552a21acb10dd78a52aa34c86f284e66b209db8cab"}, 381 | {file = "pydantic_core-2.23.3.tar.gz", hash = "sha256:3cb0f65d8b4121c1b015c60104a685feb929a29d7cf204387c7f2688c7974690"}, 382 | ] 383 | 384 | [package.dependencies] 385 | typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" 386 | 387 | [[package]] 388 | name = "pygments" 389 | version = "2.18.0" 390 | description = "Pygments is a syntax highlighting package written in Python." 391 | optional = false 392 | python-versions = ">=3.8" 393 | files = [ 394 | {file = "pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a"}, 395 | {file = "pygments-2.18.0.tar.gz", hash = "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199"}, 396 | ] 397 | 398 | [package.extras] 399 | windows-terminal = ["colorama (>=0.4.6)"] 400 | 401 | [[package]] 402 | name = "python-direnv" 403 | version = "0.2.2" 404 | description = "Loads environment variables from a direnv .envrc file." 405 | optional = false 406 | python-versions = ">=3.8" 407 | files = [ 408 | {file = "python_direnv-0.2.2-py3-none-any.whl", hash = "sha256:a617d14f093f13dd9a858e88c2914bdb16edee992b5148efd8c23c10ca1b50d9"}, 409 | {file = "python_direnv-0.2.2.tar.gz", hash = "sha256:0fe2fb834c901d675edcacc688689cfcf55cf06d9cf27dc7d3768a6c38c35f00"}, 410 | ] 411 | 412 | [[package]] 413 | name = "pyyaml" 414 | version = "6.0.2" 415 | description = "YAML parser and emitter for Python" 416 | optional = false 417 | python-versions = ">=3.8" 418 | files = [ 419 | {file = "PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086"}, 420 | {file = "PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf"}, 421 | {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237"}, 422 | {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b"}, 423 | {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed"}, 424 | {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180"}, 425 | {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68"}, 426 | {file = "PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99"}, 427 | {file = "PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e"}, 428 | {file = "PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774"}, 429 | {file = "PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee"}, 430 | {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c"}, 431 | {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317"}, 432 | {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85"}, 433 | {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4"}, 434 | {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e"}, 435 | {file = "PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5"}, 436 | {file = "PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44"}, 437 | {file = "PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab"}, 438 | {file = "PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725"}, 439 | {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5"}, 440 | {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425"}, 441 | {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476"}, 442 | {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48"}, 443 | {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b"}, 444 | {file = "PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4"}, 445 | {file = "PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8"}, 446 | {file = "PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba"}, 447 | {file = "PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1"}, 448 | {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133"}, 449 | {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484"}, 450 | {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5"}, 451 | {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc"}, 452 | {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652"}, 453 | {file = "PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183"}, 454 | {file = "PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563"}, 455 | {file = "PyYAML-6.0.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:24471b829b3bf607e04e88d79542a9d48bb037c2267d7927a874e6c205ca7e9a"}, 456 | {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7fded462629cfa4b685c5416b949ebad6cec74af5e2d42905d41e257e0869f5"}, 457 | {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d84a1718ee396f54f3a086ea0a66d8e552b2ab2017ef8b420e92edbc841c352d"}, 458 | {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9056c1ecd25795207ad294bcf39f2db3d845767be0ea6e6a34d856f006006083"}, 459 | {file = "PyYAML-6.0.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:82d09873e40955485746739bcb8b4586983670466c23382c19cffecbf1fd8706"}, 460 | {file = "PyYAML-6.0.2-cp38-cp38-win32.whl", hash = "sha256:43fa96a3ca0d6b1812e01ced1044a003533c47f6ee8aca31724f78e93ccc089a"}, 461 | {file = "PyYAML-6.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:01179a4a8559ab5de078078f37e5c1a30d76bb88519906844fd7bdea1b7729ff"}, 462 | {file = "PyYAML-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d"}, 463 | {file = "PyYAML-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f"}, 464 | {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290"}, 465 | {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12"}, 466 | {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19"}, 467 | {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e"}, 468 | {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725"}, 469 | {file = "PyYAML-6.0.2-cp39-cp39-win32.whl", hash = "sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631"}, 470 | {file = "PyYAML-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8"}, 471 | {file = "pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e"}, 472 | ] 473 | 474 | [[package]] 475 | name = "rich" 476 | version = "13.8.1" 477 | description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" 478 | optional = false 479 | python-versions = ">=3.7.0" 480 | files = [ 481 | {file = "rich-13.8.1-py3-none-any.whl", hash = "sha256:1760a3c0848469b97b558fc61c85233e3dafb69c7a071b4d60c38099d3cd4c06"}, 482 | {file = "rich-13.8.1.tar.gz", hash = "sha256:8260cda28e3db6bf04d2d1ef4dbc03ba80a824c88b0e7668a0f23126a424844a"}, 483 | ] 484 | 485 | [package.dependencies] 486 | markdown-it-py = ">=2.2.0" 487 | pygments = ">=2.13.0,<3.0.0" 488 | 489 | [package.extras] 490 | jupyter = ["ipywidgets (>=7.5.1,<9)"] 491 | 492 | [[package]] 493 | name = "ruff" 494 | version = "0.6.5" 495 | description = "An extremely fast Python linter and code formatter, written in Rust." 496 | optional = false 497 | python-versions = ">=3.7" 498 | files = [ 499 | {file = "ruff-0.6.5-py3-none-linux_armv6l.whl", hash = "sha256:7e4e308f16e07c95fc7753fc1aaac690a323b2bb9f4ec5e844a97bb7fbebd748"}, 500 | {file = "ruff-0.6.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:932cd69eefe4daf8c7d92bd6689f7e8182571cb934ea720af218929da7bd7d69"}, 501 | {file = "ruff-0.6.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3a8d42d11fff8d3143ff4da41742a98f8f233bf8890e9fe23077826818f8d680"}, 502 | {file = "ruff-0.6.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a50af6e828ee692fb10ff2dfe53f05caecf077f4210fae9677e06a808275754f"}, 503 | {file = "ruff-0.6.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:794ada3400a0d0b89e3015f1a7e01f4c97320ac665b7bc3ade24b50b54cb2972"}, 504 | {file = "ruff-0.6.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:381413ec47f71ce1d1c614f7779d88886f406f1fd53d289c77e4e533dc6ea200"}, 505 | {file = "ruff-0.6.5-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:52e75a82bbc9b42e63c08d22ad0ac525117e72aee9729a069d7c4f235fc4d276"}, 506 | {file = "ruff-0.6.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09c72a833fd3551135ceddcba5ebdb68ff89225d30758027280968c9acdc7810"}, 507 | {file = "ruff-0.6.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:800c50371bdcb99b3c1551d5691e14d16d6f07063a518770254227f7f6e8c178"}, 508 | {file = "ruff-0.6.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e25ddd9cd63ba1f3bd51c1f09903904a6adf8429df34f17d728a8fa11174253"}, 509 | {file = "ruff-0.6.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:7291e64d7129f24d1b0c947ec3ec4c0076e958d1475c61202497c6aced35dd19"}, 510 | {file = "ruff-0.6.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:9ad7dfbd138d09d9a7e6931e6a7e797651ce29becd688be8a0d4d5f8177b4b0c"}, 511 | {file = "ruff-0.6.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:005256d977021790cc52aa23d78f06bb5090dc0bfbd42de46d49c201533982ae"}, 512 | {file = "ruff-0.6.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:482c1e6bfeb615eafc5899127b805d28e387bd87db38b2c0c41d271f5e58d8cc"}, 513 | {file = "ruff-0.6.5-py3-none-win32.whl", hash = "sha256:cf4d3fa53644137f6a4a27a2b397381d16454a1566ae5335855c187fbf67e4f5"}, 514 | {file = "ruff-0.6.5-py3-none-win_amd64.whl", hash = "sha256:3e42a57b58e3612051a636bc1ac4e6b838679530235520e8f095f7c44f706ff9"}, 515 | {file = "ruff-0.6.5-py3-none-win_arm64.whl", hash = "sha256:51935067740773afdf97493ba9b8231279e9beef0f2a8079188c4776c25688e0"}, 516 | {file = "ruff-0.6.5.tar.gz", hash = "sha256:4d32d87fab433c0cf285c3683dd4dae63be05fd7a1d65b3f5bf7cdd05a6b96fb"}, 517 | ] 518 | 519 | [[package]] 520 | name = "stevedore" 521 | version = "5.3.0" 522 | description = "Manage dynamic plugins for Python applications" 523 | optional = false 524 | python-versions = ">=3.8" 525 | files = [ 526 | {file = "stevedore-5.3.0-py3-none-any.whl", hash = "sha256:1efd34ca08f474dad08d9b19e934a22c68bb6fe416926479ba29e5013bcc8f78"}, 527 | {file = "stevedore-5.3.0.tar.gz", hash = "sha256:9a64265f4060312828151c204efbe9b7a9852a0d9228756344dbc7e4023e375a"}, 528 | ] 529 | 530 | [package.dependencies] 531 | pbr = ">=2.0.0" 532 | 533 | [[package]] 534 | name = "typing-extensions" 535 | version = "4.12.2" 536 | description = "Backported and Experimental Type Hints for Python 3.8+" 537 | optional = false 538 | python-versions = ">=3.8" 539 | files = [ 540 | {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, 541 | {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, 542 | ] 543 | 544 | [[package]] 545 | name = "virtualenv" 546 | version = "20.26.4" 547 | description = "Virtual Python Environment builder" 548 | optional = false 549 | python-versions = ">=3.7" 550 | files = [ 551 | {file = "virtualenv-20.26.4-py3-none-any.whl", hash = "sha256:48f2695d9809277003f30776d155615ffc11328e6a0a8c1f0ec80188d7874a55"}, 552 | {file = "virtualenv-20.26.4.tar.gz", hash = "sha256:c17f4e0f3e6036e9f26700446f85c76ab11df65ff6d8a9cbfad9f71aabfcf23c"}, 553 | ] 554 | 555 | [package.dependencies] 556 | distlib = ">=0.3.7,<1" 557 | filelock = ">=3.12.2,<4" 558 | platformdirs = ">=3.9.1,<5" 559 | 560 | [package.extras] 561 | docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] 562 | test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10)"] 563 | 564 | [[package]] 565 | name = "voluptuous" 566 | version = "0.15.2" 567 | description = "Python data validation library" 568 | optional = false 569 | python-versions = ">=3.9" 570 | files = [ 571 | {file = "voluptuous-0.15.2-py3-none-any.whl", hash = "sha256:016348bc7788a9af9520b1764ebd4de0df41fe2138ebe9e06fa036bf86a65566"}, 572 | {file = "voluptuous-0.15.2.tar.gz", hash = "sha256:6ffcab32c4d3230b4d2af3a577c87e1908a714a11f6f95570456b1849b0279aa"}, 573 | ] 574 | 575 | [[package]] 576 | name = "voluptuous-stubs" 577 | version = "0.1.1" 578 | description = "voluptuous stubs" 579 | optional = false 580 | python-versions = "*" 581 | files = [ 582 | {file = "voluptuous-stubs-0.1.1.tar.gz", hash = "sha256:70fb1c088242f20e11023252b5648cd77f831f692cd910c8f9713cc135cf8cc8"}, 583 | {file = "voluptuous_stubs-0.1.1-py3-none-any.whl", hash = "sha256:f216c427ed7e190b8413e26cf4f67e1bda692ea8225ed0d875f7724d10b7cb10"}, 584 | ] 585 | 586 | [package.dependencies] 587 | mypy = ">=0.720" 588 | typing-extensions = ">=3.7.4" 589 | 590 | [metadata] 591 | lock-version = "2.0" 592 | python-versions = "^3.12" 593 | content-hash = "2ead8382ff5e9728ad6020835181764f8bb26dafa2acd209cff8378ffcb9035c" 594 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "span" 3 | version = "0.1.1" 4 | description = "Span Panel Custom Integration for Home Assistant" 5 | authors = ["SpanPanel"] 6 | license = "MIT" 7 | readme = "README.md" 8 | package-mode = false 9 | 10 | [tool.poetry.dependencies] 11 | python = "^3.12" 12 | 13 | [tool.poetry.dev-dependencies] 14 | ruff = "^0.6.4" 15 | isort = "^5.13.2" 16 | mypy = "^1.0.0" 17 | bandit = "^1.7.4" 18 | pre-commit = "^3.8.0" 19 | pydantic = "^2.9.1" 20 | voluptuous = "^0.15.2" 21 | voluptuous-stubs = "^0.1.1" 22 | python-direnv = "^0.2.2" 23 | 24 | [build-system] 25 | requires = ["poetry-core"] 26 | build-backend = "poetry.core.masonry.api" 27 | 28 | [tool.jscpd] 29 | path = ["custom_components/span_panel", "./*.{html,md}"] 30 | format = ["python", "javascript", "json", "markup", "markdown"] 31 | ignore = "custom_components/span_panel/translations/**|**/translations/**|.github/**|env/**|**/site-packages/**|**/.direnv/**" 32 | reporters = ["console"] 33 | output = "./jscpdReport" 34 | gitignore = true 35 | -------------------------------------------------------------------------------- /scripts/run_mypy.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import subprocess # nosec B404 4 | import sys 5 | 6 | 7 | def main(): 8 | ha_core_path = os.environ.get('HA_CORE_PATH') 9 | if not ha_core_path: 10 | print("Error: HA_CORE_PATH is not set. Please set it to your Home Assistant core directory.", file=sys.stderr) 11 | sys.exit(1) 12 | else: 13 | # Run mypy with the provided arguments 14 | result = subprocess.check_call(['poetry', 'run', 'mypy'] + sys.argv[1:]) # nosec B603 15 | sys.exit(result) 16 | 17 | if __name__ == '__main__': 18 | main() 19 | -------------------------------------------------------------------------------- /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 | --------------------------------------------------------------------------------