diff --git a/.markdownlint-cli2.jsonc b/.markdownlint-cli2.jsonc index 32e4230..c759e95 100644 --- a/.markdownlint-cli2.jsonc +++ b/.markdownlint-cli2.jsonc @@ -2,9 +2,9 @@ "config": { "default": true, "MD013": { - "line_length": 125, - "heading_line_length": 125, - "code_block_line_length": 125, + "line_length": 256, + "heading_line_length": 256, + "code_block_line_length": 256, "tables": false }, "MD025": false, @@ -27,12 +27,7 @@ "MD031": { "list_items": true }, - "MD032": { - "ul_single": 3, - "ol_single": 2, - "ul_multi": 1, - "ol_multi": 1 - }, + "MD032": true, "MD036": false, "MD040": { "allowed_languages": ["yaml", "python", "bash", "shell", "json", "toml", "markdown", "text", "console", "diff"], diff --git a/.markdownlint.json b/.markdownlint.json new file mode 100644 index 0000000..e499038 --- /dev/null +++ b/.markdownlint.json @@ -0,0 +1,8 @@ +{ + "default": true, + "MD013": { + "line_length": 256 + }, + "MD033": false, + "MD041": false +} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5a40868..28a8cd9 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -50,11 +50,11 @@ repos: exclude: '^src/span_panel_api/generated_client/.*|generate_client\.py|scripts/coverage\.py|\..*_cache/.*|dist/.*|venv/.*|\.venv/.*' # Markdownlint for markdown files - - repo: https://github.com/jackdewinter/pymarkdown - rev: v0.9.30 + - repo: https://github.com/DavidAnson/markdownlint-cli2 + rev: v0.15.0 hooks: - - id: pymarkdown - args: ['--config', '.pymarkdown.json', 'scan'] + - id: markdownlint-cli2 + args: ['--config', '.markdownlint.json'] exclude: '^src/span_panel_api/generated_client/.*|\..*_cache/.*|dist/.*|venv/.*|\.venv/.*|node_modules/.*|htmlcov/.*' # MyPy for type checking diff --git a/.pymarkdown.json b/.pymarkdown.json deleted file mode 100644 index ba313df..0000000 --- a/.pymarkdown.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "plugins": { - "line-length": { - "enabled": false - } - } -} diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 9e4913a..67f3166 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -80,8 +80,8 @@ { "label": "Lint Markdown", "type": "shell", - "command": "poetry", - "args": ["run", "pymarkdown", "scan", "**/*.md"], + "command": "npx", + "args": ["markdownlint-cli2", "--config", ".markdownlint.json", "**/*.md"], "group": "build", "presentation": { "echo": true, @@ -90,7 +90,7 @@ "panel": "shared" }, "problemMatcher": [], - "detail": "Lint all markdown files using pymarkdown (Python-based markdown linter)" + "detail": "Lint all markdown files using markdownlint (David Anson)" }, { "label": "Format Code", diff --git a/README.md b/README.md index 7386b61..7ea1a2f 100644 --- a/README.md +++ b/README.md @@ -176,9 +176,9 @@ asyncio.run(manual_example()) | Pattern | Use Case | Pros | Cons | | ------------------- | ---------------------------------------- | ---------------------------------------------------------------------------- | ------------------------------------------------------------------------ | -| **Context Manager** | Scripts, one-off tasks, testing | Automatic cleanup • Exception safe • Simple code | Creates/destroys connection each time • Not efficient for frequent calls | -| **Long-Lived** | Services, daemons, integration platforms | Efficient connection reuse • Better performance • Authentication persistence | Manual lifecycle management • Must handle cleanup | -| **Manual** | Custom requirements, debugging | Full control • Custom error handling | Must remember to call close() • More error-prone | +| **Context Manager** | Scripts, one-off tasks, testing | Automatic cleanup • Exception safe • Simple code | Creates/destroys connection each time | +| **Long-Lived** | Services, daemons, integration platforms | Efficient connection reuse Authentication persistence | Manual lifecycle management • Must handle cleanup | +| **Manual** | Custom requirements, debugging | Full control handling | Must remember to call close() • More error-prone | ## Error Handling @@ -202,17 +202,17 @@ from span_panel_api.exceptions import ( | Status Code | Exception | Retry? | Description | Action | | ------------------------------- | -------------------------- | -------------------- | -------------------------------- | ------------------------------ | -| **Authentication Errors** | +| **Authentication Errors** | - | - | - | - | | 401, 403 | `SpanPanelAuthError` | Once (after re-auth) | Authentication required/failed | Re-authenticate and retry once | -| **Non-Retriable Server Errors** | +| **Non-Retriable Server Errors** | - | - | - | - | | 500 | `SpanPanelServerError` | **NO** | Internal server error (SPAN bug) | Show error, do not retry | -| **Retriable Server Errors** | +| **Retriable Server Errors** | - | - | - | - | | 502 | `SpanPanelRetriableError` | Yes | Bad Gateway (proxy error) | Retry with exponential backoff | | 503 | `SpanPanelRetriableError` | Yes | Service Unavailable | Retry with exponential backoff | | 504 | `SpanPanelRetriableError` | Yes | Gateway Timeout | Retry with exponential backoff | -| **Other HTTP Errors** | +| **Other HTTP Errors** | - | - | - | - | | 404, 400, etc | `SpanPanelAPIError` | Case by case | Client/request errors | Check request parameters | -| **Network Errors** | +| **Network Errors** | - | - | - | - | | Connection failures | `SpanPanelConnectionError` | Yes | Network connectivity issues | Retry with backoff | | Timeouts | `SpanPanelTimeoutError` | Yes | Request timed out | Retry with backoff | @@ -257,7 +257,8 @@ client = SpanPanelClient( host="192.168.1.100", # Required: SPAN Panel IP port=80, # Optional: default 80 timeout=30.0, # Optional: request timeout - use_ssl=False # Optional: HTTPS (usually False for local) + use_ssl=False, # Optional: HTTPS (usually False for local) + cache_window=1.0 # Optional: cache window in seconds (0 to disable) ) ``` @@ -311,6 +312,28 @@ await client.set_circuit_priority("circuit-1", "MUST_HAVE") await client.set_circuit_priority("circuit-1", "NICE_TO_HAVE") ``` +### Complete Circuit Data + +The `get_circuits()` method includes virtual circuits for unmapped panel tabs, +providing complete panel visibility including non-user controlled tabs. + +- Virtual circuits have IDs like `unmapped_tab_1`, `unmapped_tab_2` +- All energy values are correctly mapped from panel branches + +**Example Output:** + +```python +circuits = await client.get_circuits() + +# Standard configured circuits +print(circuits.circuits.additional_properties["1"].name) # "Main Kitchen" +print(circuits.circuits.additional_properties["1"].instant_power_w) # 150 + +# Virtual circuits for unmapped tabs (e.g., solar) +print(circuits.circuits.additional_properties["unmapped_tab_5"].name) # "Unmapped Tab 5" +print(circuits.circuits.additional_properties["unmapped_tab_5"].instant_power_w) # -2500 (solar production) +``` + ## Timeout and Retry Control The SPAN Panel API client provides timeout and retry configuration: @@ -355,6 +378,31 @@ client.retry_backoff_multiplier = 1.5 Retry and timeout settings can be queried and changed at runtime. +## Performance Features + +### Caching + +The client includes a time-based cache that prevents redundant API calls within a +configurable window. This feature is particularly useful when multiple operations need the same data. +The package itself makes multiple calls to create virtual circuits for tabs not represented in circuits data so the cache avoid unecessary calls when the user also makes requests the same data. + +**Cache Behavior:** + +- Each API endpoint (status, panel_state, circuits, storage) has independent cache +- Cache window starts when successful data is obtained +- Subsequent calls within the window return cached data +- After expiration, next call makes fresh network request +- Failed requests don't affect cache timing + +**Example Benefits:** + +```python +# These calls demonstrate cache efficiency: +panel_state = await client.get_panel_state() # Network call +circuits = await client.get_circuits() # Uses cached panel_state data internally +panel_state2 = await client.get_panel_state() # Returns cached data (within window) +``` + ## Development Setup ### Prerequisites @@ -430,7 +478,7 @@ python scripts/coverage.py --full poetry run pytest tests/test_context_manager.py -v # Check coverage meets threshold -python scripts/coverage.py --check --threshold 95 +python scripts/coverage.py --check --threshold 90 # Run with coverage poetry run pytest --cov=span_panel_api tests/ diff --git a/poetry.lock b/poetry.lock index 1375916..d1e66a5 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.1.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand. [[package]] name = "annotated-types" @@ -34,23 +34,6 @@ doc = ["Sphinx (>=8.2,<9.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", test = ["anyio[trio]", "blockbuster (>=1.5.23)", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "trustme", "truststore (>=0.9.1) ; python_version >= \"3.10\"", "uvloop (>=0.21) ; platform_python_implementation == \"CPython\" and platform_system != \"Windows\" and python_version < \"3.14\""] trio = ["trio (>=0.26.1)"] -[[package]] -name = "application-properties" -version = "0.8.2" -description = "A simple, easy to use, unified manner of accessing program properties." -optional = false -python-versions = ">=3.8.0" -groups = ["dev"] -files = [ - {file = "application_properties-0.8.2-py3-none-any.whl", hash = "sha256:a4fe684e4d95fc45054d3316acf763a7b0f29342ccea02eee09de53004f0139c"}, - {file = "application_properties-0.8.2.tar.gz", hash = "sha256:e5e6918c8e29ab57175567d51dfa39c00a1d75b3205625559bb02250f50f0420"}, -] - -[package.dependencies] -pyyaml = ">=5.4.1" -tomli = ">=2.0.1" -typing-extensions = ">=4.5.0" - [[package]] name = "attrs" version = "25.3.0" @@ -376,22 +359,6 @@ files = [ ] markers = {main = "platform_system == \"Windows\"", dev = "platform_system == \"Windows\" or sys_platform == \"win32\"", generate = "platform_system == \"Windows\" or sys_platform == \"win32\""} -[[package]] -name = "columnar" -version = "1.4.1" -description = "A tool for printing data in a columnar format." -optional = false -python-versions = "*" -groups = ["dev"] -files = [ - {file = "Columnar-1.4.1-py3-none-any.whl", hash = "sha256:8efb692a7e6ca07dcc8f4ea889960421331a5dffa8e5af81f0a67ad8ea1fc798"}, - {file = "Columnar-1.4.1.tar.gz", hash = "sha256:c3cb57273333b2ff9cfaafc86f09307419330c97faa88dcfe23df05e6fbb9c72"}, -] - -[package.dependencies] -toolz = "*" -wcwidth = "*" - [[package]] name = "coverage" version = "7.9.1" @@ -1332,23 +1299,6 @@ files = [ [package.extras] windows-terminal = ["colorama (>=0.4.6)"] -[[package]] -name = "pymarkdownlnt" -version = "0.9.30" -description = "A GitHub Flavored Markdown compliant Markdown linter." -optional = false -python-versions = ">=3.9.0" -groups = ["dev"] -files = [ - {file = "pymarkdownlnt-0.9.30-py3-none-any.whl", hash = "sha256:29b881434def9d3796be4a89cb277ab9f8f4fbb558379ce7836c653c9c351d2c"}, - {file = "pymarkdownlnt-0.9.30.tar.gz", hash = "sha256:cf274935b128abd7f30c44314510d0f4d36965149bb9dae84f6cac9491dc1f58"}, -] - -[package.dependencies] -application-properties = ">=0.8.2" -columnar = ">=1.4.0" -typing-extensions = ">=4.7.0" - [[package]] name = "pytest" version = "8.4.0" @@ -1785,60 +1735,6 @@ files = [ [package.dependencies] pbr = ">=2.0.0" -[[package]] -name = "tomli" -version = "2.2.1" -description = "A lil' TOML parser" -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"}, - {file = "tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6"}, - {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a"}, - {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee"}, - {file = "tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e"}, - {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4"}, - {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106"}, - {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8"}, - {file = "tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff"}, - {file = "tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b"}, - {file = "tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea"}, - {file = "tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8"}, - {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192"}, - {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222"}, - {file = "tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77"}, - {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6"}, - {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd"}, - {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e"}, - {file = "tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98"}, - {file = "tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4"}, - {file = "tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7"}, - {file = "tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c"}, - {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13"}, - {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281"}, - {file = "tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272"}, - {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140"}, - {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2"}, - {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744"}, - {file = "tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec"}, - {file = "tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69"}, - {file = "tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc"}, - {file = "tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff"}, -] - -[[package]] -name = "toolz" -version = "1.0.0" -description = "List processing tools and functional utilities" -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "toolz-1.0.0-py3-none-any.whl", hash = "sha256:292c8f1c4e7516bf9086f8850935c799a874039c8bcf959d47b600e4c44a6236"}, - {file = "toolz-1.0.0.tar.gz", hash = "sha256:2c86e3d9a04798ac556793bced838816296a2f085017664e4995cb40a1047a02"}, -] - [[package]] name = "twine" version = "6.1.0" @@ -1894,7 +1790,7 @@ files = [ {file = "typing_extensions-4.14.0-py3-none-any.whl", hash = "sha256:a1514509136dd0b477638fc68d6a91497af5076466ad0fa6c338e44e359944af"}, {file = "typing_extensions-4.14.0.tar.gz", hash = "sha256:8676b788e32f02ab42d9e7c61324048ae4c6d844a399eebace3d4979d75ceef4"}, ] -markers = {main = "python_version < \"3.13\""} +markers = {main = "python_version == \"3.12\""} [[package]] name = "typing-inspection" @@ -1950,19 +1846,7 @@ platformdirs = ">=3.9.1,<5" docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8) ; platform_python_implementation == \"PyPy\" or platform_python_implementation == \"GraalVM\" or platform_python_implementation == \"CPython\" and sys_platform == \"win32\" and python_version >= \"3.13\"", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10) ; platform_python_implementation == \"CPython\""] -[[package]] -name = "wcwidth" -version = "0.2.13" -description = "Measures the displayed width of unicode strings in a terminal" -optional = false -python-versions = "*" -groups = ["dev"] -files = [ - {file = "wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859"}, - {file = "wcwidth-0.2.13.tar.gz", hash = "sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5"}, -] - [metadata] lock-version = "2.1" python-versions = ">=3.12,<3.14" -content-hash = "4d0ef5883835a47d2fe43786e294b51251c5499ca41e975ee9aac4ea0c89bd0a" +content-hash = "69fdd91ef5718ded7e09634f9d71e85cb6c45a2d0bb5f6921dbd95765c8729ea" diff --git a/pyproject.toml b/pyproject.toml index ef909a7..77949dd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "span-panel-api" -version = "1.0.0" +version = "1.1.0" description = "A client library for SPAN Panel API" authors = ["SpanPanel"] readme = "README.md" @@ -29,7 +29,6 @@ pre-commit = "^4.0.0" black = "^25.1.0" ruff = "^0.11.0" mypy = "^1.15.0" -pymarkdownlnt = "^0.9.30" [tool.poetry.group.generate.dependencies] openapi-python-client = "^0.25.0" diff --git a/src/span_panel_api/client.py b/src/span_panel_api/client.py index 66ddada..f8a258e 100644 --- a/src/span_panel_api/client.py +++ b/src/span_panel_api/client.py @@ -8,6 +8,7 @@ import asyncio from collections.abc import Awaitable +import time from typing import Any, Callable, NoReturn, TypeVar, cast import httpx @@ -40,6 +41,8 @@ AuthOut, BatteryStorage, BodySetCircuitStateApiV1CircuitsCircuitIdPost, + Branch, + Circuit, CircuitsOut, PanelState, Priority, @@ -59,6 +62,87 @@ # Remove the RetryConfig class - using simple parameters instead +class TimeWindowCache: + """Time-based cache for API data to avoid redundant API calls. + + This cache implements a simple time-window based caching strategy: + + Cache Window Behavior: + 1. Cache window is created only when successful data is obtained from an API call + 2. During an active cache window, all requests return cached data (no network calls) + 3. Cache window expires after the configured duration (default 1 second) + 4. After expiration, there is a gap with no active cache window + 5. Next request goes to the network, and if successful, creates a new cache window + + Cache Lifecycle: + - Active Window: [successful_response] ----window_duration----> [expires] + - Gap Period: [no cache exists - network calls required] + - New Window: [successful_response] ----window_duration----> [expires] + + Retry Interaction: + - If network calls fail and retry, the cache window may expire during retries + - When retry eventually succeeds, it creates a fresh cache window + - This is acceptable behavior - slow networks may cause cache expiration + + Thread Safety: + - This implementation is not thread-safe + - Intended for single-threaded async usage + """ + + def __init__(self, window_duration: float = 1.0) -> None: + """Initialize the cache. + + Args: + window_duration: Cache window duration in seconds (default: 1.0) + Set to 0 to disable caching entirely + """ + if window_duration < 0: + raise ValueError("Cache window duration must be non-negative") + + self._window_duration = window_duration + self._cache_entries: dict[str, tuple[Any, float]] = {} + + def get_cached_data(self, cache_key: str) -> Any | None: + """Get cached data if within the cache window, otherwise None. + + Args: + cache_key: Unique identifier for the cached data + + Returns: + Cached data if valid, None if expired or not found + """ + # If cache window is 0, caching is disabled + if self._window_duration == 0: + return None + + if cache_key not in self._cache_entries: + return None + + cached_data, cache_timestamp = self._cache_entries[cache_key] + + # Check if cache window has expired + elapsed = time.time() - cache_timestamp + if elapsed > self._window_duration: + # Cache expired - remove it and return None + del self._cache_entries[cache_key] + return None + + return cached_data + + def set_cached_data(self, cache_key: str, data: Any) -> None: + """Store successful response data and start a new cache window. + + Args: + cache_key: Unique identifier for the cached data + data: Data to cache + """ + # If cache window is 0, caching is disabled - don't store anything + if self._window_duration == 0: + return + + self._cache_entries[cache_key] = (data, time.time()) + + class SpanPanelClient: """Modern async client for SPAN Panel REST API. @@ -90,6 +174,8 @@ def __init__( retries: int = 0, # Default to 0 retries for simplicity retry_timeout: float = 0.5, # How long to wait between retry attempts retry_backoff_multiplier: float = 2.0, + # Cache configuration + cache_window: float = 1.0, # Panel data cache window in seconds ) -> None: """Initialize the SPAN Panel client. @@ -101,6 +187,7 @@ def __init__( retries: Number of retries (0 = no retries, 1 = 1 retry, etc.) retry_timeout: Timeout between retry attempts in seconds retry_backoff_multiplier: Exponential backoff multiplier + cache_window: Panel data cache window duration in seconds (default: 1.0) """ self._host = host self._port = port @@ -119,6 +206,9 @@ def __init__( self._retry_timeout = retry_timeout self._retry_backoff_multiplier = retry_backoff_multiplier + # Initialize API data cache + self._api_cache = TimeWindowCache(cache_window) + # Build base URL scheme = "https" if use_ssl else "http" self._base_url = f"{scheme}://{host}:{port}" @@ -471,8 +561,16 @@ async def _get_status_operation() -> StatusOut: raise SpanPanelAPIError("API result is None despite raise_on_unexpected_status=True") return result + # Check cache first + cached_status = self._api_cache.get_cached_data("status") + if cached_status is not None: + return cached_status + try: - return await self._retry_with_backoff(_get_status_operation) + status = await self._retry_with_backoff(_get_status_operation) + # Cache the successful response + self._api_cache.set_cached_data("status", status) + return status except UnexpectedStatus as e: self._handle_unexpected_status(e) except httpx.HTTPStatusError as e: @@ -501,8 +599,16 @@ async def _get_panel_state_operation() -> PanelState: raise SpanPanelAPIError("API result is None despite raise_on_unexpected_status=True") return result + # Check cache first + cached_state = self._api_cache.get_cached_data("panel_state") + if cached_state is not None: + return cached_state + try: - return await self._retry_with_backoff(_get_panel_state_operation) + state = await self._retry_with_backoff(_get_panel_state_operation) + # Cache the successful response + self._api_cache.set_cached_data("panel_state", state) + return state except SpanPanelAuthError: # Pass through auth errors directly raise @@ -525,19 +631,54 @@ async def _get_panel_state_operation() -> PanelState: raise SpanPanelAPIError(f"Unexpected error: {e}") from e async def get_circuits(self) -> CircuitsOut: - """Get all circuits and their current state.""" + """Get all circuits and their current state, including virtual circuits for unmapped tabs.""" async def _get_circuits_operation() -> CircuitsOut: + # Get standard circuits response client = self._get_client_for_endpoint(requires_auth=True) - # Type cast needed because generated API has overly strict type hints result = await get_circuits_api_v1_circuits_get.asyncio(client=cast(AuthenticatedClient, client)) - # Since raise_on_unexpected_status=True, result should never be None if result is None: raise SpanPanelAPIError("API result is None despite raise_on_unexpected_status=True") + + # Get panel state for branches data + panel_state = await self.get_panel_state() + + # Find tabs already mapped to circuits + mapped_tabs: set[int] = set() + if hasattr(result, "circuits") and hasattr(result.circuits, "additional_properties"): + for circuit in result.circuits.additional_properties.values(): + if hasattr(circuit, "tabs") and circuit.tabs is not None and str(circuit.tabs) != "UNSET": + if isinstance(circuit.tabs, (list, tuple)): + mapped_tabs.update(circuit.tabs) + elif isinstance(circuit.tabs, int): + mapped_tabs.add(circuit.tabs) + + # Create virtual circuits for unmapped tabs + if hasattr(panel_state, "branches") and panel_state.branches: + total_tabs = len(panel_state.branches) + all_tabs = set(range(1, total_tabs + 1)) + unmapped_tabs = all_tabs - mapped_tabs + + for tab_num in unmapped_tabs: + branch_idx = tab_num - 1 + if branch_idx < len(panel_state.branches): + branch = panel_state.branches[branch_idx] + virtual_circuit = self._create_unmapped_tab_circuit(branch, tab_num) + circuit_id = f"unmapped_tab_{tab_num}" + result.circuits.additional_properties[circuit_id] = virtual_circuit + return result + # Check cache first + cached_circuits = self._api_cache.get_cached_data("circuits") + if cached_circuits is not None: + return cached_circuits + try: - return await self._retry_with_backoff(_get_circuits_operation) + circuits = await self._retry_with_backoff(_get_circuits_operation) + # Cache the successful response + self._api_cache.set_cached_data("circuits", circuits) + return circuits except UnexpectedStatus as e: self._handle_unexpected_status(e) except httpx.HTTPStatusError as e: @@ -554,6 +695,52 @@ async def _get_circuits_operation() -> CircuitsOut: # Catch and wrap all other exceptions raise SpanPanelAPIError(f"Unexpected error: {e}") from e + def _create_unmapped_tab_circuit(self, branch: Branch, tab_number: int) -> Circuit: + """Create a virtual circuit for an unmapped tab. + + Args: + branch: The Branch object from panel state + tab_number: The tab number (1-based) + + Returns: + Circuit: A virtual circuit representing the unmapped tab + """ + # Map branch data to circuit data + # For solar inverters: imported energy = solar production, exported energy = grid export + instant_power_w = getattr(branch, "instant_power_w", 0.0) + imported_energy = getattr(branch, "imported_active_energy_wh", 0.0) + exported_energy = getattr(branch, "exported_active_energy_wh", 0.0) + + # For solar tabs, imported energy represents production + produced_energy_wh = imported_energy + consumed_energy_wh = exported_energy + + # Get timestamps (use current time as fallback) + import time + + current_time = int(time.time()) + instant_power_update_time_s = current_time + energy_accum_update_time_s = current_time + + # Create the virtual circuit + circuit = Circuit( + id=f"unmapped_tab_{tab_number}", + name=f"Unmapped Tab {tab_number}", + relay_state=RelayState.UNKNOWN, + instant_power_w=instant_power_w, + instant_power_update_time_s=instant_power_update_time_s, + produced_energy_wh=produced_energy_wh, + consumed_energy_wh=consumed_energy_wh, + energy_accum_update_time_s=energy_accum_update_time_s, + priority=Priority.UNKNOWN, + is_user_controllable=False, + is_sheddable=False, + is_never_backup=False, + tabs=[tab_number], + ) + + return circuit + async def get_storage_soe(self) -> BatteryStorage: """Get storage state of energy (SOE) data.""" @@ -566,8 +753,16 @@ async def _get_storage_soe_operation() -> BatteryStorage: raise SpanPanelAPIError("API result is None despite raise_on_unexpected_status=True") return result + # Check cache first + cached_storage = self._api_cache.get_cached_data("storage_soe") + if cached_storage is not None: + return cached_storage + try: - return await self._retry_with_backoff(_get_storage_soe_operation) + storage = await self._retry_with_backoff(_get_storage_soe_operation) + # Cache the successful response + self._api_cache.set_cached_data("storage_soe", storage) + return storage except UnexpectedStatus as e: self._handle_unexpected_status(e) except httpx.HTTPStatusError as e: diff --git a/test_cache_demo.py b/test_cache_demo.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/investigate_live_panel.py b/tests/investigate_live_panel.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_cache_functionality.py b/tests/test_cache_functionality.py new file mode 100644 index 0000000..0c77f2f --- /dev/null +++ b/tests/test_cache_functionality.py @@ -0,0 +1,114 @@ +"""Test the TimeWindowCache functionality.""" + +import time +from unittest.mock import AsyncMock, patch + +import pytest + +from span_panel_api.client import SpanPanelClient, TimeWindowCache + + +def test_cache_basic_functionality(): + """Test basic cache window behavior.""" + cache = TimeWindowCache(window_duration=1.0) + + # Initially empty + assert cache.get_cached_data("test_key") is None + + # Set data + test_data = {"test": "data"} + cache.set_cached_data("test_key", test_data) + + # Should return cached data immediately + assert cache.get_cached_data("test_key") == test_data + + # Should still be cached after short delay + time.sleep(0.1) + assert cache.get_cached_data("test_key") == test_data + + +def test_cache_expiration(): + """Test cache window expiration.""" + cache = TimeWindowCache(window_duration=0.1) # Very short window + + test_data = {"test": "data"} + cache.set_cached_data("test_key", test_data) + + # Should be cached initially + assert cache.get_cached_data("test_key") == test_data + + # Wait for expiration + time.sleep(0.15) + + # Should be expired now + assert cache.get_cached_data("test_key") is None + + +def test_cache_multiple_keys(): + """Test cache with multiple keys.""" + cache = TimeWindowCache(window_duration=1.0) + + data1 = {"key1": "data1"} + data2 = {"key2": "data2"} + + cache.set_cached_data("key1", data1) + cache.set_cached_data("key2", data2) + + assert cache.get_cached_data("key1") == data1 + assert cache.get_cached_data("key2") == data2 + assert cache.get_cached_data("nonexistent") is None + + +def test_cache_validation(): + """Test cache parameter validation.""" + # 0 should be allowed (disables cache) + cache = TimeWindowCache(window_duration=0) + assert cache._window_duration == 0 + + # Negative values should be rejected + with pytest.raises(ValueError, match="Cache window duration must be non-negative"): + TimeWindowCache(window_duration=-1) + + +@pytest.mark.asyncio +async def test_client_cache_integration(): + """Test that the client properly integrates the cache.""" + # Test that cache window parameter is passed correctly + client = SpanPanelClient("192.168.1.100", cache_window=0.5) + + # Verify cache is initialized + assert hasattr(client, "_api_cache") + assert client._api_cache._window_duration == 0.5 + + +@pytest.mark.asyncio +async def test_cache_prevents_redundant_calls(): + """Test that cache prevents redundant API calls.""" + with patch("span_panel_api.client.system_status_api_v1_status_get") as mock_status_api: + # Mock the API response + mock_response = AsyncMock() + mock_response.return_value = {"status": "mock_data"} + mock_status_api.asyncio = mock_response + + client = SpanPanelClient("192.168.1.100", cache_window=1.0) + + async with client: + # First call should hit the API + result1 = await client.get_status() + + # Second call should use cache (no additional API call) + result2 = await client.get_status() + + # Should be the same object + assert result1 is result2 + + # API should have been called only once + assert mock_status_api.asyncio.call_count == 1 + + +if __name__ == "__main__": + # Run basic functionality tests + test_cache_basic_functionality() + test_cache_expiration() + test_cache_multiple_keys() + test_cache_validation() diff --git a/tests/test_context_manager.py b/tests/test_context_manager.py index 5807cc8..8d8e68c 100644 --- a/tests/test_context_manager.py +++ b/tests/test_context_manager.py @@ -16,7 +16,7 @@ class TestContextManagerFix: @pytest.mark.asyncio async def test_unauthenticated_requests_work_properly(self): """Test that unauthenticated requests (like get_status) work both inside and outside context managers.""" - client = SpanPanelClient("192.168.1.100", timeout=5.0) + client = SpanPanelClient("192.168.1.100", timeout=5.0, cache_window=0) # Disable cache for testing with patch("span_panel_api.client.system_status_api_v1_status_get") as mock_status: # Setup mock response for status endpoint @@ -101,17 +101,19 @@ async def test_context_manager_multiple_api_calls(self): @pytest.mark.asyncio async def test_context_manager_with_authentication_flow(self): """Test that authentication within a context manager doesn't break the context.""" - client = SpanPanelClient("192.168.1.100", timeout=5.0) + client = SpanPanelClient("192.168.1.100", timeout=5.0, cache_window=0) with ( patch("span_panel_api.client.generate_jwt_api_v1_auth_register_post") as mock_auth, patch("span_panel_api.client.get_circuits_api_v1_circuits_get") as mock_circuits, + patch("span_panel_api.client.get_panel_state_api_v1_panel_get") as mock_panel_state, patch("span_panel_api.client.set_circuit_state_api_v_1_circuits_circuit_id_post") as mock_set_circuit, ): # Setup mock responses auth_response = MagicMock(access_token="test-token-12345", token_type="Bearer") mock_auth.asyncio = AsyncMock(return_value=auth_response) mock_circuits.asyncio = AsyncMock(return_value=MagicMock(circuits=MagicMock(additional_properties={}))) + mock_panel_state.asyncio = AsyncMock(return_value=MagicMock(branches=[])) mock_set_circuit.asyncio = AsyncMock(return_value=MagicMock(priority="MUST_HAVE")) async with client: @@ -146,7 +148,7 @@ async def test_context_manager_with_authentication_flow(self): @pytest.mark.asyncio async def test_context_manager_error_handling_preserves_state(self): """Test that errors within the context don't break the context manager state.""" - client = SpanPanelClient("192.168.1.100", timeout=5.0) + client = SpanPanelClient("192.168.1.100", timeout=5.0, cache_window=0) with patch("span_panel_api.client.system_status_api_v1_status_get") as mock_status: # First call succeeds, next calls fail (with retry attempts) diff --git a/tests/test_core_client.py b/tests/test_core_client.py index 6028bb7..73aebce 100644 --- a/tests/test_core_client.py +++ b/tests/test_core_client.py @@ -245,19 +245,29 @@ async def test_get_panel_state_success(self): @pytest.mark.asyncio async def test_get_circuits_success(self): """Test successful circuits retrieval.""" - client = SpanPanelClient("192.168.1.100") + client = SpanPanelClient("192.168.1.100", cache_window=0) client.set_access_token("test-token") - with patch("span_panel_api.client.get_circuits_api_v1_circuits_get") as mock_circuits: + with ( + patch("span_panel_api.client.get_circuits_api_v1_circuits_get") as mock_circuits, + patch("span_panel_api.client.get_panel_state_api_v1_panel_get") as mock_panel_state, + ): # Mock circuits response circuits_response = MagicMock() - circuits_response.circuits = {"1": MagicMock(name="Main", instant_power_w=1500)} + circuits_response.circuits = MagicMock() + circuits_response.circuits.additional_properties = {"1": MagicMock(name="Main", instant_power_w=1500)} mock_circuits.asyncio = AsyncMock(return_value=circuits_response) + # Mock panel state response (needed for enhanced circuits) + panel_state_response = MagicMock() + panel_state_response.branches = [] # No unmapped tabs + mock_panel_state.asyncio = AsyncMock(return_value=panel_state_response) + result = await client.get_circuits() assert result == circuits_response mock_circuits.asyncio.assert_called_once() + mock_panel_state.asyncio.assert_called_once() @pytest.mark.asyncio async def test_get_storage_soe_success(self): diff --git a/tests/test_enhanced_circuits.py b/tests/test_enhanced_circuits.py new file mode 100644 index 0000000..8ea8a3b --- /dev/null +++ b/tests/test_enhanced_circuits.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +""" +Test the enhanced get_circuits method with unmapped tab virtual circuits. +""" + +import asyncio +import logging + +import pytest + +from span_panel_api import SpanPanelClient + +# Panel credentials +PANEL_HOST = "192.168.65.70" +PANEL_TOKEN = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJob21lLWFzc2lzdGFudC1qYXZhNjU2NTY1NjY1IiwiaWF0IjoxNzA2OTA4OTEwfQ.nMbv3zkNTm4l8BIvhOQy1xTU6lP2FpKGNEQFnZ2QCT4" + +logger = logging.getLogger(__name__) + + +@pytest.mark.asyncio +async def test_enhanced_circuits(): + """Test the enhanced get_circuits method.""" + logger.info("Testing enhanced get_circuits method...") + logger.info(f"Host: {PANEL_HOST}") + + client = SpanPanelClient(host=PANEL_HOST) + client.set_access_token(PANEL_TOKEN) + + try: + async with client: + # Get circuits with unmapped tab virtual circuits + circuits_response = await client.get_circuits() + + # Verify we got a response + assert circuits_response is not None + logger.info(f"Circuits response type: {type(circuits_response)}") + + if hasattr(circuits_response, "circuits") and hasattr(circuits_response.circuits, "additional_properties"): + circuit_dict = circuits_response.circuits.additional_properties + logger.info(f"Total circuits (including virtual): {len(circuit_dict)}") + + # Separate real and virtual circuits + real_circuits = [] + virtual_circuits = [] + + for circuit_id, circuit in circuit_dict.items(): + if circuit_id.startswith("unmapped_tab_"): + virtual_circuits.append((circuit_id, circuit)) + else: + real_circuits.append((circuit_id, circuit)) + + logger.info(f"Real circuits: {len(real_circuits)}") + logger.info(f"Virtual circuits: {len(virtual_circuits)}") + + # Verify we have both real and virtual circuits + assert len(real_circuits) > 0, "Should have at least one real circuit" + assert len(virtual_circuits) > 0, "Should have at least one virtual circuit" + + # Verify virtual circuits structure + if virtual_circuits: + logger.info("Virtual Circuits (Unmapped Tabs):") + for circuit_id, circuit in virtual_circuits: + tab_num = circuit_id.replace("unmapped_tab_", "") + name = getattr(circuit, "name", "N/A") + power = getattr(circuit, "instant_power_w", "N/A") + produced = getattr(circuit, "produced_energy_wh", "N/A") + consumed = getattr(circuit, "consumed_energy_wh", "N/A") + tabs = getattr(circuit, "tabs", "N/A") + + logger.info(f" Tab {tab_num}: {name}") + logger.info(f" Power: {power}W") + logger.info(f" Produced: {produced}Wh") + logger.info(f" Consumed: {consumed}Wh") + logger.info(f" Tabs: {tabs}") + + # Verify virtual circuit structure + assert circuit_id.startswith("unmapped_tab_"), f"Invalid virtual circuit ID: {circuit_id}" + assert hasattr(circuit, "name"), "Virtual circuit should have name" + assert hasattr(circuit, "instant_power_w"), "Virtual circuit should have power" + + # Verify real circuits structure + logger.info("Sample Real Circuits:") + for _circuit_id, circuit in real_circuits[:3]: + name = getattr(circuit, "name", "N/A") + power = getattr(circuit, "instant_power_w", "N/A") + tabs = getattr(circuit, "tabs", "N/A") + logger.info(f" {name}: {power}W (tabs: {tabs})") + + logger.info("Enhanced get_circuits test completed successfully!") + else: + pytest.fail("Unexpected circuits response structure") + + except Exception as e: + logger.error(f"Test failed: {e}") + import traceback + + traceback.print_exc() + pytest.fail(f"Test failed with exception: {e}") + + +if __name__ == "__main__": + asyncio.run(test_enhanced_circuits())