span-panel-api 2.6.4__tar.gz → 3.0.0b1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. span_panel_api-3.0.0b1/.github/workflows/ci.yml +130 -0
  2. span_panel_api-3.0.0b1/.github/workflows/release.yml +107 -0
  3. {span_panel_api-2.6.4 → span_panel_api-3.0.0b1}/.pre-commit-config.yaml +2 -2
  4. {span_panel_api-2.6.4 → span_panel_api-3.0.0b1}/CHANGELOG.md +50 -0
  5. {span_panel_api-2.6.4 → span_panel_api-3.0.0b1}/PKG-INFO +1 -1
  6. span_panel_api-3.0.0b1/packages/schema-0/CHANGELOG.md +34 -0
  7. span_panel_api-3.0.0b1/packages/schema-0/README.md +32 -0
  8. span_panel_api-3.0.0b1/packages/schema-0/pyproject.toml +35 -0
  9. span_panel_api-3.0.0b1/packages/schema-0/src/span_panel_api_schema_0/__init__.py +11 -0
  10. {span_panel_api-2.6.4/src/span_panel_api/mqtt → span_panel_api-3.0.0b1/packages/schema-0/src/span_panel_api_schema_0}/accumulator.py +2 -1
  11. span_panel_api-3.0.0b1/packages/schema-0/src/span_panel_api_schema_0/adapter.py +67 -0
  12. span_panel_api-3.0.0b1/packages/schema-0/src/span_panel_api_schema_0/const.py +83 -0
  13. span_panel_api-2.6.4/src/span_panel_api/mqtt/homie.py → span_panel_api-3.0.0b1/packages/schema-0/src/span_panel_api_schema_0/consumer.py +9 -3
  14. {span_panel_api-2.6.4/src/span_panel_api/mqtt → span_panel_api-3.0.0b1/packages/schema-0/src/span_panel_api_schema_0}/field_metadata.py +4 -57
  15. {span_panel_api-2.6.4 → span_panel_api-3.0.0b1}/pyproject.toml +33 -2
  16. span_panel_api-3.0.0b1/scripts/verify_adapterless_install.py +77 -0
  17. {span_panel_api-2.6.4 → span_panel_api-3.0.0b1}/src/span_panel_api/__init__.py +5 -3
  18. span_panel_api-3.0.0b1/src/span_panel_api/adapters.py +126 -0
  19. span_panel_api-3.0.0b1/src/span_panel_api/exceptions.py +79 -0
  20. span_panel_api-3.0.0b1/src/span_panel_api/factory.py +144 -0
  21. {span_panel_api-2.6.4 → span_panel_api-3.0.0b1}/src/span_panel_api/mqtt/__init__.py +5 -6
  22. {span_panel_api-2.6.4 → span_panel_api-3.0.0b1}/src/span_panel_api/mqtt/client.py +120 -51
  23. span_panel_api-3.0.0b1/src/span_panel_api/mqtt/const.py +30 -0
  24. span_panel_api-3.0.0b1/src/span_panel_api/protocol.py +132 -0
  25. span_panel_api-3.0.0b1/src/span_panel_api/py.typed +0 -0
  26. span_panel_api-3.0.0b1/src/span_panel_api/schema_drift.py +65 -0
  27. {span_panel_api-2.6.4 → span_panel_api-3.0.0b1}/tests/conftest.py +1 -1
  28. {span_panel_api-2.6.4 → span_panel_api-3.0.0b1}/tests/test_accumulator.py +2 -2
  29. span_panel_api-3.0.0b1/tests/test_adapters_discovery.py +224 -0
  30. {span_panel_api-2.6.4 → span_panel_api-3.0.0b1}/tests/test_auth_and_homie_helpers.py +2 -2
  31. span_panel_api-3.0.0b1/tests/test_factory_dispatch.py +160 -0
  32. {span_panel_api-2.6.4 → span_panel_api-3.0.0b1}/tests/test_field_metadata.py +2 -1
  33. {span_panel_api-2.6.4 → span_panel_api-3.0.0b1}/tests/test_mqtt_client_connection.py +84 -17
  34. {span_panel_api-2.6.4 → span_panel_api-3.0.0b1}/tests/test_mqtt_connect_flow.py +11 -14
  35. {span_panel_api-2.6.4 → span_panel_api-3.0.0b1}/tests/test_mqtt_homie.py +22 -26
  36. span_panel_api-3.0.0b1/tests/test_packaging.py +61 -0
  37. span_panel_api-3.0.0b1/tests/test_protocol_conformance.py +111 -0
  38. span_panel_api-3.0.0b1/tests/test_public_api_unchanged.py +87 -0
  39. span_panel_api-3.0.0b1/tests/test_schema_provenance.py +177 -0
  40. span_panel_api-3.0.0b1/tests/test_schema_zero_adapter.py +53 -0
  41. {span_panel_api-2.6.4 → span_panel_api-3.0.0b1}/uv.lock +25 -24
  42. span_panel_api-2.6.4/.github/workflows/ci.yml +0 -101
  43. span_panel_api-2.6.4/.github/workflows/release.yml +0 -42
  44. span_panel_api-2.6.4/src/span_panel_api/exceptions.py +0 -42
  45. span_panel_api-2.6.4/src/span_panel_api/factory.py +0 -73
  46. span_panel_api-2.6.4/src/span_panel_api/mqtt/const.py +0 -71
  47. span_panel_api-2.6.4/src/span_panel_api/protocol.py +0 -79
  48. span_panel_api-2.6.4/tests/test_protocol_conformance.py +0 -48
  49. {span_panel_api-2.6.4 → span_panel_api-3.0.0b1}/.codefactor +0 -0
  50. {span_panel_api-2.6.4 → span_panel_api-3.0.0b1}/.codefactor.yml +0 -0
  51. {span_panel_api-2.6.4 → span_panel_api-3.0.0b1}/.deps-installed +0 -0
  52. {span_panel_api-2.6.4 → span_panel_api-3.0.0b1}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  53. {span_panel_api-2.6.4 → span_panel_api-3.0.0b1}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  54. {span_panel_api-2.6.4 → span_panel_api-3.0.0b1}/.github/dependabot.yml +0 -0
  55. {span_panel_api-2.6.4 → span_panel_api-3.0.0b1}/.github/workflows/dependabot-auto-approve.yml +0 -0
  56. {span_panel_api-2.6.4 → span_panel_api-3.0.0b1}/.github/workflows/dependabot-auto-merge.yml +0 -0
  57. {span_panel_api-2.6.4 → span_panel_api-3.0.0b1}/.gitignore +0 -0
  58. {span_panel_api-2.6.4 → span_panel_api-3.0.0b1}/.markdownlint-cli2.jsonc +0 -0
  59. {span_panel_api-2.6.4 → span_panel_api-3.0.0b1}/.markdownlint.json +0 -0
  60. {span_panel_api-2.6.4 → span_panel_api-3.0.0b1}/.prettierrc.json +0 -0
  61. {span_panel_api-2.6.4 → span_panel_api-3.0.0b1}/.vscode/extensions.json +0 -0
  62. {span_panel_api-2.6.4 → span_panel_api-3.0.0b1}/.vscode/tasks.json +0 -0
  63. {span_panel_api-2.6.4 → span_panel_api-3.0.0b1}/DEVELOPMENT.md +0 -0
  64. {span_panel_api-2.6.4 → span_panel_api-3.0.0b1}/LICENSE +0 -0
  65. {span_panel_api-2.6.4 → span_panel_api-3.0.0b1}/README.md +0 -0
  66. {span_panel_api-2.6.4 → span_panel_api-3.0.0b1}/SECURITY.md +0 -0
  67. {span_panel_api-2.6.4 → span_panel_api-3.0.0b1}/conftest.py +0 -0
  68. {span_panel_api-2.6.4 → span_panel_api-3.0.0b1}/developer_attribute_readme.md +0 -0
  69. {span_panel_api-2.6.4 → span_panel_api-3.0.0b1}/openapi.json +0 -0
  70. {span_panel_api-2.6.4/src/span_panel_api → span_panel_api-3.0.0b1/packages/schema-0/src/span_panel_api_schema_0}/py.typed +0 -0
  71. {span_panel_api-2.6.4 → span_panel_api-3.0.0b1}/pytest.ini +0 -0
  72. {span_panel_api-2.6.4 → span_panel_api-3.0.0b1}/pytest_output.log +0 -0
  73. {span_panel_api-2.6.4 → span_panel_api-3.0.0b1}/scripts/__init__.py +0 -0
  74. {span_panel_api-2.6.4 → span_panel_api-3.0.0b1}/scripts/coverage.py +0 -0
  75. {span_panel_api-2.6.4 → span_panel_api-3.0.0b1}/scripts/format.sh +0 -0
  76. {span_panel_api-2.6.4 → span_panel_api-3.0.0b1}/scripts/format_markdown.py +0 -0
  77. {span_panel_api-2.6.4 → span_panel_api-3.0.0b1}/scripts/test_live_auth.py +0 -0
  78. {span_panel_api-2.6.4 → span_panel_api-3.0.0b1}/setup-hooks.sh +0 -0
  79. {span_panel_api-2.6.4 → span_panel_api-3.0.0b1}/src/span_panel_api/_http.py +0 -0
  80. {span_panel_api-2.6.4 → span_panel_api-3.0.0b1}/src/span_panel_api/auth.py +0 -0
  81. {span_panel_api-2.6.4 → span_panel_api-3.0.0b1}/src/span_panel_api/const.py +0 -0
  82. {span_panel_api-2.6.4 → span_panel_api-3.0.0b1}/src/span_panel_api/detection.py +0 -0
  83. {span_panel_api-2.6.4 → span_panel_api-3.0.0b1}/src/span_panel_api/models.py +0 -0
  84. {span_panel_api-2.6.4 → span_panel_api-3.0.0b1}/src/span_panel_api/mqtt/async_client.py +0 -0
  85. {span_panel_api-2.6.4 → span_panel_api-3.0.0b1}/src/span_panel_api/mqtt/connection.py +0 -0
  86. {span_panel_api-2.6.4 → span_panel_api-3.0.0b1}/src/span_panel_api/mqtt/models.py +0 -0
  87. {span_panel_api-2.6.4 → span_panel_api-3.0.0b1}/src/span_panel_api/phase_validation.py +0 -0
  88. {span_panel_api-2.6.4 → span_panel_api-3.0.0b1}/tests/fixtures/configs/simulation_config_32_circuit.yaml +0 -0
  89. {span_panel_api-2.6.4 → span_panel_api-3.0.0b1}/tests/fixtures/configs/simulation_config_40_circuit_with_battery.yaml +0 -0
  90. {span_panel_api-2.6.4 → span_panel_api-3.0.0b1}/tests/fixtures/configs/simulation_config_8_tab_workshop.yaml +0 -0
  91. {span_panel_api-2.6.4 → span_panel_api-3.0.0b1}/tests/fixtures/v2/README.md +0 -0
  92. {span_panel_api-2.6.4 → span_panel_api-3.0.0b1}/tests/fixtures/v2/homie_schema.json +0 -0
  93. {span_panel_api-2.6.4 → span_panel_api-3.0.0b1}/tests/fixtures/v2/status.json +0 -0
  94. {span_panel_api-2.6.4 → span_panel_api-3.0.0b1}/tests/simulation_fixtures/circuits.response.txt +0 -0
  95. {span_panel_api-2.6.4 → span_panel_api-3.0.0b1}/tests/simulation_fixtures/panel.response.txt +0 -0
  96. {span_panel_api-2.6.4 → span_panel_api-3.0.0b1}/tests/simulation_fixtures/soe.response.txt +0 -0
  97. {span_panel_api-2.6.4 → span_panel_api-3.0.0b1}/tests/simulation_fixtures/status.response.txt +0 -0
  98. {span_panel_api-2.6.4 → span_panel_api-3.0.0b1}/tests/test_async_mqtt_client.py +0 -0
  99. {span_panel_api-2.6.4 → span_panel_api-3.0.0b1}/tests/test_detection_auth.py +0 -0
  100. {span_panel_api-2.6.4 → span_panel_api-3.0.0b1}/tests/test_exceptions.py +0 -0
  101. {span_panel_api-2.6.4 → span_panel_api-3.0.0b1}/tests/test_mqtt_bridge.py +0 -0
  102. {span_panel_api-2.6.4 → span_panel_api-3.0.0b1}/tests/test_mqtt_debounce.py +0 -0
  103. {span_panel_api-2.6.4 → span_panel_api-3.0.0b1}/tests/test_phase_validation_configs.py +0 -0
  104. {span_panel_api-2.6.4 → span_panel_api-3.0.0b1}/tests/test_phase_validation_errors.py +0 -0
  105. {span_panel_api-2.6.4 → span_panel_api-3.0.0b1}/tests/test_protocol_models.py +0 -0
@@ -0,0 +1,130 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [ main, develop ]
6
+ pull_request:
7
+ branches: [ main, develop ]
8
+
9
+ jobs:
10
+ lint-and-test:
11
+ runs-on: ubuntu-latest
12
+ strategy:
13
+ matrix:
14
+ python-version: ["3.14"]
15
+
16
+ steps:
17
+ - name: Checkout code
18
+ uses: actions/checkout@v7
19
+
20
+ - name: Set up Python ${{ matrix.python-version }}
21
+ uses: actions/setup-python@v7
22
+ with:
23
+ python-version: ${{ matrix.python-version }}
24
+
25
+ - name: Install uv
26
+ uses: astral-sh/setup-uv@v7
27
+ with:
28
+ enable-cache: true
29
+
30
+ - name: Install dependencies
31
+ run: uv sync --all-packages
32
+
33
+ - name: Run pre-commit hooks
34
+ run: |
35
+ uv run pre-commit run --all-files
36
+
37
+ - name: Run tests with pytest
38
+ run: |
39
+ uv run pytest tests/ -v \
40
+ --cov=src/span_panel_api \
41
+ --cov=packages/schema-0/src/span_panel_api_schema_0 \
42
+ --cov-report=xml --cov-report=term-missing
43
+
44
+
45
+
46
+ security-scan:
47
+ runs-on: ubuntu-latest
48
+ steps:
49
+ - name: Checkout code
50
+ uses: actions/checkout@v7
51
+
52
+ - name: Set up Python
53
+ uses: actions/setup-python@v7
54
+ with:
55
+ python-version: "3.14"
56
+
57
+ - name: Install uv
58
+ uses: astral-sh/setup-uv@v7
59
+ with:
60
+ enable-cache: true
61
+
62
+ - name: Install dependencies
63
+ run: uv sync --all-packages
64
+
65
+ - name: Run Bandit security scan
66
+ run: |
67
+ uv run bandit -r src/ packages/ -f json -o bandit-report.json || true
68
+
69
+ - name: Upload Bandit scan results
70
+ uses: actions/upload-artifact@v7
71
+ with:
72
+ name: bandit-report
73
+ path: bandit-report.json
74
+
75
+ build-check:
76
+ runs-on: ubuntu-latest
77
+ steps:
78
+ - name: Checkout code
79
+ uses: actions/checkout@v7
80
+
81
+ - name: Set up Python
82
+ uses: actions/setup-python@v7
83
+ with:
84
+ python-version: "3.14"
85
+
86
+ - name: Install uv
87
+ uses: astral-sh/setup-uv@v7
88
+ with:
89
+ enable-cache: true
90
+
91
+ - name: Install dependencies
92
+ run: uv sync --all-packages
93
+
94
+ - name: Build packages
95
+ run: uv build --all-packages
96
+
97
+ - name: Check packages
98
+ run: uv run twine check dist/*
99
+
100
+ # Every distribution here is fully annotated, so every distribution has to
101
+ # carry the marker that lets a consumer's type checker see those annotations.
102
+ # Without it the package resolves to Any downstream and the typing is inert.
103
+ - name: Verify every wheel ships a py.typed marker
104
+ run: |
105
+ python -c "
106
+ import glob, sys, zipfile
107
+ wheels = glob.glob('dist/*.whl')
108
+ if not wheels:
109
+ sys.exit('::error::no wheels were built')
110
+ for wheel in wheels:
111
+ if not any(n.endswith('/py.typed') for n in zipfile.ZipFile(wheel).namelist()):
112
+ sys.exit(f'::error::{wheel} ships no py.typed marker; downstream type checking would resolve it as Any')
113
+ print(f'{wheel}: py.typed present')
114
+ "
115
+
116
+ # The configuration entry-point discovery exists to support, and the one
117
+ # nothing else in CI exercises: the bootstrap wheel installed with no
118
+ # adapter present. It must import, and it must fail by name rather than
119
+ # with ModuleNotFoundError.
120
+ - name: Verify the bootstrap installs without an adapter
121
+ run: |
122
+ uv venv /tmp/bootstrap-only
123
+ VIRTUAL_ENV=/tmp/bootstrap-only uv pip install dist/span_panel_api-*.whl
124
+ VIRTUAL_ENV=/tmp/bootstrap-only uv run --no-project python scripts/verify_adapterless_install.py
125
+
126
+ - name: Upload build artifacts
127
+ uses: actions/upload-artifact@v7
128
+ with:
129
+ name: dist
130
+ path: dist/
@@ -0,0 +1,107 @@
1
+ name: Release
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+
7
+ # This repo publishes two distributions that version independently: the
8
+ # bootstrap (span-panel-api) and each schema adapter (span-panel-api-schema-N).
9
+ # One release publishes exactly one of them, chosen by the tag prefix:
10
+ #
11
+ # v3.0.0b1 -> span-panel-api (the historical convention)
12
+ # schema-0-v1.0.0b1 -> span-panel-api-schema-0
13
+ #
14
+ # The version lives in the distribution's own pyproject.toml and this workflow
15
+ # only verifies the tag agrees. It deliberately does not rewrite the version at
16
+ # release time: the adapter declares a floor on the bootstrap
17
+ # (span-panel-api>=X), so the committed versions are load-bearing for resolution
18
+ # and cannot be treated as placeholders that a release stamps over.
19
+ #
20
+ # Publishing uses PyPI trusted publishing, which is configured per project. A
21
+ # distribution released here for the first time needs a pending publisher
22
+ # created on PyPI beforehand (project name, this repo, workflow `release.yml`,
23
+ # environment `release`); without it the publish step fails on an otherwise
24
+ # correct build.
25
+ jobs:
26
+ deploy:
27
+ runs-on: ubuntu-latest
28
+ environment: release
29
+ permissions:
30
+ id-token: write # For trusted publishing to PyPI
31
+
32
+ steps:
33
+ - name: Checkout code
34
+ uses: actions/checkout@v7
35
+
36
+ - name: Set up Python
37
+ uses: actions/setup-python@v7
38
+ with:
39
+ python-version: "3.14"
40
+
41
+ - name: Install uv
42
+ uses: astral-sh/setup-uv@v7
43
+ with:
44
+ enable-cache: true
45
+
46
+ - name: Resolve the distribution from the tag
47
+ id: target
48
+ run: |
49
+ TAG=${GITHUB_REF#refs/tags/}
50
+ case "$TAG" in
51
+ schema-*-v*)
52
+ SCHEMA=${TAG#schema-}
53
+ SCHEMA=${SCHEMA%%-v*}
54
+ PACKAGE="span-panel-api-schema-$SCHEMA"
55
+ MANIFEST="packages/schema-$SCHEMA/pyproject.toml"
56
+ VERSION=${TAG#schema-$SCHEMA-v}
57
+ ;;
58
+ v*)
59
+ PACKAGE="span-panel-api"
60
+ MANIFEST="pyproject.toml"
61
+ VERSION=${TAG#v}
62
+ ;;
63
+ *)
64
+ echo "::error::Tag '$TAG' names no distribution. Use 'vX.Y.Z' for the bootstrap or 'schema-N-vX.Y.Z' for an adapter."
65
+ exit 1
66
+ ;;
67
+ esac
68
+ if [ ! -f "$MANIFEST" ]; then
69
+ echo "::error::Tag '$TAG' resolves to '$MANIFEST', which does not exist."
70
+ exit 1
71
+ fi
72
+ echo "Tag '$TAG' releases $PACKAGE $VERSION from $MANIFEST"
73
+ echo "package=$PACKAGE" >> "$GITHUB_OUTPUT"
74
+ echo "manifest=$MANIFEST" >> "$GITHUB_OUTPUT"
75
+ echo "version=$VERSION" >> "$GITHUB_OUTPUT"
76
+
77
+ - name: Verify the tag matches the committed version
78
+ run: |
79
+ DECLARED=$(python -c "import sys, tomllib; print(tomllib.load(open(sys.argv[1], 'rb'))['project']['version'])" "${{ steps.target.outputs.manifest }}")
80
+ if [ "$DECLARED" != "${{ steps.target.outputs.version }}" ]; then
81
+ echo "::error::${{ steps.target.outputs.manifest }} declares version '$DECLARED' but the tag says '${{ steps.target.outputs.version }}'. Commit the version bump before tagging."
82
+ exit 1
83
+ fi
84
+ echo "Version $DECLARED confirmed."
85
+
86
+ # Only the tagged distribution is built, so dist/ holds exactly what this
87
+ # release publishes and the publish step cannot pick up a sibling package.
88
+ - name: Build package
89
+ run: uv build --package "${{ steps.target.outputs.package }}"
90
+
91
+ - name: Verify the wheel ships a py.typed marker
92
+ run: |
93
+ python -c "
94
+ import glob, sys, zipfile
95
+ wheels = glob.glob('dist/*.whl')
96
+ if not wheels:
97
+ sys.exit('::error::no wheel was built')
98
+ for wheel in wheels:
99
+ if not any(n.endswith('/py.typed') for n in zipfile.ZipFile(wheel).namelist()):
100
+ sys.exit(f'::error::{wheel} ships no py.typed marker; downstream type checking would resolve it as Any')
101
+ print(f'{wheel}: py.typed present')
102
+ "
103
+
104
+ - name: Publish to PyPI
105
+ uses: pypa/gh-action-pypi-publish@release/v1
106
+ with:
107
+ print-hash: true
@@ -108,7 +108,7 @@ repos:
108
108
  hooks:
109
109
  - id: vulture
110
110
  name: vulture
111
- entry: bash -c 'uv run vulture src/span_panel_api/ --min-confidence 80'
111
+ entry: bash -c 'uv run vulture src/span_panel_api/ packages/schema-0/src/span_panel_api_schema_0/ --min-confidence 80'
112
112
  language: system
113
113
  types: [python]
114
114
  pass_filenames: false
@@ -131,6 +131,6 @@ repos:
131
131
  name: coverage summary
132
132
  entry: bash
133
133
  language: system
134
- args: ['-c', 'output=$(uv run pytest tests/ --cov=src/span_panel_api --cov-config=pyproject.toml --cov-fail-under=85 -q 2>&1); status=$?; echo "$output"; exit "$status"']
134
+ args: ['-c', 'output=$(uv run pytest tests/ --cov=src/span_panel_api --cov=packages/schema-0/src/span_panel_api_schema_0 --cov-config=pyproject.toml --cov-fail-under=85 -q 2>&1); status=$?; echo "$output"; exit "$status"']
135
135
  pass_filenames: false
136
136
  verbose: true
@@ -4,6 +4,56 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [3.0.0b1] - 08/2026
8
+
9
+ Pre-release. `span-panel-api` becomes a transport and a dispatcher that contains **no parser**. Wire formats ship as separate distributions and register themselves via entry points, so support for a new panel schema arrives by installing a package rather
10
+ than by upgrading the transport. This is prototype work being proven end to end before any decision to land it on `main`.
11
+
12
+ ### Removed
13
+
14
+ - **BREAKING: `span-panel-api` no longer contains a parser.** Installing it alone gives a client that connects and then raises `SpanPanelAdapterMissingError`. Flat-schema panels (firmware `r202603`–`r202627`) need **`span-panel-api-schema-0`** installed
15
+ alongside it:
16
+
17
+ ```console
18
+ pip install span-panel-api span-panel-api-schema-0
19
+ ```
20
+
21
+ - **BREAKING: `HomieLifecycle`, `HomiePropertyAccumulator` and `HomieDeviceConsumer` are no longer exported** from `span_panel_api` or `span_panel_api.mqtt`. All three are flat-schema-specific rather than Homie-convention-level: the accumulator filters
22
+ every topic against a single device's prefix and stores `node → prop`, which drops nearly every message under the parent/child model; `HomieLifecycle`'s members are not Homie 5 `$state` values but a consumer-side progression encoding "one description
23
+ received ⇒ ready", which is the flat readiness model. They now live in `span_panel_api_schema_0`.
24
+ - **Removed dead constants** `DEVICE_TOPIC_FMT`, `STATE_TOPIC_FMT`, `DESCRIPTION_TOPIC_FMT`, `PROPERTY_TOPIC_FMT` (unreferenced before the Phase 0 relocation) and `TYPE_PCS` (a real schema type this library does not consume).
25
+
26
+ ### Added
27
+
28
+ - **`span_panel_api.adapters.resolve_adapter(key, reason)`** — the single place a missing adapter becomes a named error, used by both Tier 1 dispatch and the transport's default path.
29
+ - **`SpanPanelSchemaVersionError`**, raised when a panel reports a `data-model-version` whose schema major cannot be determined. Distinct from `SpanPanelAdapterMissingError` because the remedy differs: a missing adapter is a known schema with no installed
30
+ parser, while this is a schema no adapter can even be named for.
31
+ - **`SpanPanelAdapterMissingError` and `SpanPanelSchemaVersionError` are now exported** from the top-level package — both are errors a user sees when their panel outruns their install, so catching them should not require reaching into a private module.
32
+ - **`SchemaAdapter.__init__` is declared on the protocol.** Construction was always part of the contract (the transport resolves an adapter class from the registry and calls it), but was previously typed only as a `Callable`, leaving the signature
33
+ unchecked against implementations.
34
+ - **Entry-point validation.** `discover_adapters()` now verifies each loaded object is a class implementing the protocol before registering it, and skips it with a logged reason otherwise. One broken third-party adapter cannot take down a panel whose own
35
+ adapter is fine.
36
+ - **`scripts/verify_adapterless_install.py`** and a CI step that runs it against a venv holding only the bootstrap wheel.
37
+
38
+ ### Changed
39
+
40
+ - **`SpanMqttClient(adapter_factory=...)` is now optional.** When omitted, the parser is resolved through entry-point discovery at `_build_adapter()` rather than imported. Resolution is lazy by design: constructing a client must not require an adapter to
41
+ be installed, only building a parser must.
42
+ - **Dispatch refuses an unreadable `data-model-version` instead of assuming flat.** Absence still means the flat schema — that is a real signal, since the property was introduced by the firmware that introduced parent/child. A value whose major _can_ be
43
+ read but whose form is non-canonical (`1`, `1.0-beta`) dispatches on that major and logs the deviation. A value with no extractable major now raises. Previously all three fell through to the flat parser, which does not fail — it produces plausible but
44
+ wrong power and energy figures.
45
+ - **Dispatch diagnostics travel through the `SpanMqttClient` constructor**, removing the window where a connected client reported a selected adapter alongside `schema_dispatch_reason='not dispatched'`.
46
+ - **Releases are now per-distribution and the tag no longer sets the version.** A tag selects which distribution to publish — `vX.Y.Z` for `span-panel-api`, `schema-N-vX.Y.Z` for an adapter — and the release fails unless the tagged version matches the one
47
+ committed in that distribution's `pyproject.toml`. The previous workflow rewrote the root version from the tag and built only the root package, which under a two-distribution layout would have published the bootstrap with no adapter alongside it. Version
48
+ numbers are now load-bearing between the two (the adapter declares a floor on the bootstrap), so they belong in the repository rather than being stamped at release time.
49
+
50
+ ### Fixed
51
+
52
+ - **Adapter distributions ship a `py.typed` marker.** Without it a consumer's type checker refuses to read the adapter's annotations and resolves every symbol it exports as `Any`, silently erasing the strict typing at the wheel boundary. CI now fails any
53
+ wheel built without one.
54
+ - **Protocol conformance checking no longer depends on member kind.** The required-member set is derived from every public member `SchemaAdapter` declares, not only the callable ones — a `property` or `classmethod` object is not callable, so the previous
55
+ derivation would have quietly stopped requiring such a member the day the protocol declared one.
56
+
7
57
  ## [2.6.4] - 05/2026
8
58
 
9
59
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: span-panel-api
3
- Version: 2.6.4
3
+ Version: 3.0.0b1
4
4
  Summary: A client library for SPAN Panel API
5
5
  Project-URL: Homepage, https://github.com/SpanPanel/span-panel-api
6
6
  Project-URL: Issues, https://github.com/SpanPanel/span-panel-api/issues
@@ -0,0 +1,34 @@
1
+ # Changelog
2
+
3
+ All notable changes to `span-panel-api-schema-0` are documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ Note that this package versions on the **library-API axis**, not the wire-format axis. The wire format it parses is fixed — the flat single-device schema, SPAN firmware `r202603` through `r202627` — and is identified by `SUPPORTS_DATA_MODEL_VERSIONS`
8
+ rather than by this version number. A release here means this parser changed, never that the panel did.
9
+
10
+ ## [1.0.0b1] - 08/2026
11
+
12
+ Pre-release. First release as a standalone distribution.
13
+
14
+ ### Added
15
+
16
+ - **The flat-schema parser, extracted from `span-panel-api` 2.6.4.** Relocated verbatim from `span_panel_api._impl.schema_0` to `span_panel_api_schema_0`; only import statements changed. Registers itself as `schema_0` under the
17
+ `span_panel_api.schema_adapters` entry-point group, which is the only way `span-panel-api` reaches it — the bootstrap never imports this package.
18
+ - **`SCHEMA_ANCHOR`** (`sha256:d347556a07d98f40`, firmware `spanos2/r202603/05`) — the schema revision every hardcoded fact in this package was read from, with `SCHEMA_ANCHOR_FIELD` naming the field it comes from (`typesSchemaHash`). The field is
19
+ per-adapter: parent/child firmware renames it to `deviceClassesSchemaHash` along with the block it covers, so a future `schema_1` declares its own rather than inheriting one that does not exist on its firmware.
20
+ - **Provenance tests** asserting that all 64 hardcoded `(node_type, property_id)` pairs still resolve against the captured schema, that `HOMIE_DOMAIN` / `HOMIE_VERSION` still match it, and that the two lugs subtypes real firmware publishes remain absent
21
+ from the schema _and_ present in the metadata alias table. This is the only signal that catches schema drift before release; every other symptom reaches production as a silent absence.
22
+ - **A `py.typed` marker**, so consumers type-check against this package's real annotations rather than resolving everything it exports as `Any`.
23
+
24
+ ### Known deviations from the published schema
25
+
26
+ - **Circuit `active-power` is treated as watts, though the schema declares kilowatts.** Real panels publish watts; this was established against live hardware and the 1000× correction was removed accordingly. A test asserts the schema still says `kW`, so
27
+ the day SPAN corrects it we find out rather than discovering it as a factor-of-1000 error.
28
+ - **`energy.ebus.device.lugs.upstream` / `.downstream` are parsed but undeclared.** Firmware publishes these node types in `$description`; the schema declares only the base `energy.ebus.device.lugs`. Property metadata for them resolves through an alias to
29
+ the base type.
30
+
31
+ ### Retirement
32
+
33
+ SPAN retires the flat schema in the same firmware release that introduces the parent/child model (`r202633`; fleet rollout projected, not committed, for the first two weeks of September 2026). This package stops being published once the fleet has moved.
34
+ Published versions remain on PyPI for anyone still running older firmware.
@@ -0,0 +1,32 @@
1
+ # span-panel-api-schema-0
2
+
3
+ The **flat-schema** parser for [`span-panel-api`](https://github.com/SpanPanel/span-panel-api): the single-device Homie model published by SPAN firmware `r202603` through `r202627`, which carries no `data-model-version`.
4
+
5
+ ## Why this is a separate distribution
6
+
7
+ `span-panel-api` is a transport and a dispatcher. It knows how to connect to a panel's MQTT broker, route messages, and choose a parser — but it contains no parsing code and no Homie type strings. Each wire format ships as its own distribution and
8
+ registers itself under the `span_panel_api.schema_adapters` entry-point group.
9
+
10
+ That split exists because the two halves break on different axes. The wire format changes when SPAN ships firmware; the library API changes when we do. Separate distributions let each carry its own version, so a consumer can pin them independently and add
11
+ support for a new panel schema by installing a package rather than by upgrading the transport.
12
+
13
+ ## Installation
14
+
15
+ ```console
16
+ pip install span-panel-api span-panel-api-schema-0
17
+ ```
18
+
19
+ Installing this package is what makes flat-schema panels work. `span-panel-api` on its own will connect and then raise `SpanPanelAdapterMissingError` naming the adapter it could not find.
20
+
21
+ A consumer that wants to support panels on either schema installs both adapters:
22
+
23
+ ```console
24
+ pip install span-panel-api span-panel-api-schema-0 span-panel-api-schema-1
25
+ ```
26
+
27
+ Dispatch happens at runtime, per panel, from the `data-model-version` the panel reports.
28
+
29
+ ## Retirement
30
+
31
+ SPAN retires the flat schema in the same release that introduces the parent/child model (`r202633`, fleet rollout projected for early September 2026). When the fleet has moved, consumers drop this package from their requirements. Published versions stay on
32
+ PyPI for anyone still running older firmware.
@@ -0,0 +1,35 @@
1
+ [project]
2
+ name = "span-panel-api-schema-0"
3
+ version = "1.0.0b1"
4
+ description = "Flat-schema (data-model-version absent) parser for span-panel-api"
5
+ authors = [
6
+ {name = "SpanPanel"}
7
+ ]
8
+ readme = "README.md"
9
+ license = "MIT"
10
+ requires-python = ">=3.10,<4.0"
11
+ dependencies = [
12
+ "span-panel-api>=3.0.0b1,<4.0",
13
+ ]
14
+
15
+ [project.urls]
16
+ Homepage = "https://github.com/SpanPanel/span-panel-api"
17
+ Issues = "https://github.com/SpanPanel/span-panel-api/issues"
18
+
19
+ # The whole point of this distribution. The bootstrap finds this adapter by
20
+ # discovering the group, never by importing this package.
21
+ [project.entry-points."span_panel_api.schema_adapters"]
22
+ schema_0 = "span_panel_api_schema_0:SchemaZeroAdapter"
23
+
24
+ # Resolve the bootstrap from the workspace when developing here. Published
25
+ # wheels are unaffected: this table is uv-only metadata and the dependency
26
+ # above is what a consumer installing from PyPI sees.
27
+ [tool.uv.sources]
28
+ span-panel-api = { workspace = true }
29
+
30
+ [build-system]
31
+ requires = ["hatchling"]
32
+ build-backend = "hatchling.build"
33
+
34
+ [tool.hatch.build.targets.wheel]
35
+ packages = ["src/span_panel_api_schema_0"]
@@ -0,0 +1,11 @@
1
+ """Flat-schema adapter package (data-model-version absent)."""
2
+
3
+ from span_panel_api_schema_0.adapter import SchemaZeroAdapter
4
+
5
+ # Re-exported from the adapter rather than restated. The protocol requires the
6
+ # range as a class attribute, so the class is the source of truth; a second
7
+ # literal here would be free to drift, and nothing would notice until a panel
8
+ # reported a version this adapter claims — falsely — to support.
9
+ SUPPORTS_DATA_MODEL_VERSIONS: tuple[str, str] = SchemaZeroAdapter.SUPPORTS_DATA_MODEL_VERSIONS
10
+
11
+ __all__ = ["SUPPORTS_DATA_MODEL_VERSIONS", "SchemaZeroAdapter"]
@@ -13,7 +13,8 @@ import json
13
13
  import logging
14
14
  import time
15
15
 
16
- from .const import HOMIE_STATE_DISCONNECTED, HOMIE_STATE_LOST, HOMIE_STATE_READY, TOPIC_PREFIX
16
+ from span_panel_api.mqtt.const import HOMIE_STATE_DISCONNECTED, HOMIE_STATE_LOST, HOMIE_STATE_READY
17
+ from span_panel_api_schema_0.const import TOPIC_PREFIX
17
18
 
18
19
  _LOGGER = logging.getLogger(__name__)
19
20
 
@@ -0,0 +1,67 @@
1
+ """Flat-schema (data-model-version absent) adapter.
2
+
3
+ Composes the existing accumulator + consumer and owns the flat wire format:
4
+ a single Homie device whose node ids are circuit UUIDs and capability names.
5
+ Nothing outside this package constructs a flat-schema topic.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Callable
11
+ from typing import TYPE_CHECKING
12
+
13
+ from span_panel_api_schema_0.accumulator import HomiePropertyAccumulator
14
+ from span_panel_api_schema_0.const import PROPERTY_SET_TOPIC_FMT, TYPE_CORE, WILDCARD_TOPIC_FMT
15
+ from span_panel_api_schema_0.consumer import HomieDeviceConsumer
16
+ from span_panel_api_schema_0.field_metadata import build_field_metadata
17
+
18
+ if TYPE_CHECKING:
19
+ from span_panel_api.models import FieldMetadata, HomieSchemaTypes, SpanPanelSnapshot
20
+
21
+
22
+ class SchemaZeroAdapter:
23
+ """Parser for the flat single-device schema (firmware r202603-r202627)."""
24
+
25
+ schema_major = "schema_0"
26
+ SUPPORTS_DATA_MODEL_VERSIONS: tuple[str, str] = (">=0", "<1.0")
27
+
28
+ def __init__(self, serial_number: str, panel_size: int) -> None:
29
+ self._serial_number = serial_number
30
+ self._accumulator = HomiePropertyAccumulator(serial_number)
31
+ self._consumer = HomieDeviceConsumer(self._accumulator, panel_size)
32
+
33
+ def topics_to_subscribe(self) -> list[str]:
34
+ return [WILDCARD_TOPIC_FMT.format(serial=self._serial_number)]
35
+
36
+ def handle_message(self, topic: str, payload: str) -> None:
37
+ self._consumer.handle_message(topic, payload)
38
+
39
+ def is_ready(self) -> bool:
40
+ return self._consumer.is_ready()
41
+
42
+ def build_snapshot(self) -> SpanPanelSnapshot:
43
+ return self._consumer.build_snapshot()
44
+
45
+ def build_field_metadata(self, schema_types: HomieSchemaTypes) -> dict[str, FieldMetadata]:
46
+ return build_field_metadata(schema_types)
47
+
48
+ def circuit_nodes_missing_names(self) -> list[str]:
49
+ return self._consumer.circuit_nodes_missing_names()
50
+
51
+ def find_node_by_type(self, type_str: str) -> str | None:
52
+ return self._consumer.find_node_by_type(type_str)
53
+
54
+ def set_circuit_relay_topic(self, circuit_id: str) -> str:
55
+ return PROPERTY_SET_TOPIC_FMT.format(serial=self._serial_number, node=circuit_id, prop="relay")
56
+
57
+ def set_circuit_priority_topic(self, circuit_id: str) -> str:
58
+ return PROPERTY_SET_TOPIC_FMT.format(serial=self._serial_number, node=circuit_id, prop="shed-priority")
59
+
60
+ def set_dominant_power_source_topic(self) -> str | None:
61
+ core_node = self._consumer.find_node_by_type(TYPE_CORE)
62
+ if core_node is None:
63
+ return None
64
+ return PROPERTY_SET_TOPIC_FMT.format(serial=self._serial_number, node=core_node, prop="dominant-power-source")
65
+
66
+ def register_property_callback(self, callback: Callable[[str, str, str, str | None], None]) -> Callable[[], None]:
67
+ return self._consumer.register_property_callback(callback)
@@ -0,0 +1,83 @@
1
+ """Constants for the flat-schema (Homie v5) parsing implementation."""
2
+
3
+ # ---------------------------------------------------------------------------
4
+ # Provenance anchor — the schema revision every fact in this module was read
5
+ # from. `tests/test_schema_provenance.py` fails when a captured schema reports a
6
+ # different one, which is the only pre-release signal that this adapter has
7
+ # drifted from the wire it claims to parse.
8
+ #
9
+ # The field name is per-adapter, not per-bootstrap: flat firmware publishes
10
+ # `typesSchemaHash` over a `types` block, while parent/child renames it to
11
+ # `deviceClassesSchemaHash` over `deviceClasses` — the hash is renamed with the
12
+ # block it covers, so schema_1 declares its own.
13
+ #
14
+ # Content-derived, not build-derived: SPAN defines it as the SHA-256 of the
15
+ # canonicalized schema object and states the schema "may remain unchanged across
16
+ # multiple firmware releases". So it moves when the schema moves, not on every
17
+ # release — which is what makes it usable as an anchor rather than noise.
18
+ # ---------------------------------------------------------------------------
19
+ SCHEMA_ANCHOR_FIELD = "typesSchemaHash"
20
+ SCHEMA_ANCHOR = "sha256:d347556a07d98f40"
21
+ SCHEMA_ANCHOR_FIRMWARE = "spanos2/r202603/05"
22
+
23
+ # Homie v5 topic structure
24
+ HOMIE_VERSION = 5
25
+ HOMIE_DOMAIN = "ebus"
26
+ TOPIC_PREFIX = f"{HOMIE_DOMAIN}/{HOMIE_VERSION}"
27
+
28
+ # Topic patterns (serial_number substituted at runtime).
29
+ # The adapter subscribes with the wildcard and publishes with the set pattern;
30
+ # per-topic read formats are not needed because every message arrives through
31
+ # the one wildcard subscription.
32
+ PROPERTY_SET_TOPIC_FMT = f"{TOPIC_PREFIX}/{{serial}}/{{node}}/{{prop}}/set"
33
+ WILDCARD_TOPIC_FMT = f"{TOPIC_PREFIX}/{{serial}}/#"
34
+
35
+ # ---------------------------------------------------------------------------
36
+ # Homie type strings.
37
+ #
38
+ # Two namespaces that are easy to conflate and are NOT the same set:
39
+ #
40
+ # * the `types` block of GET /api/v2/homie/schema, which declares the
41
+ # properties, units and datatypes available to a type; and
42
+ # * the `type` string a node actually carries in its $description on the wire.
43
+ #
44
+ # Every constant below is a node type observed on the wire. The ones in the
45
+ # first group are also declared in the schema, so metadata lookup finds them
46
+ # directly. See tests/test_schema_provenance.py, which asserts that.
47
+ # ---------------------------------------------------------------------------
48
+ TYPE_CORE = "energy.ebus.device.distribution-enclosure.core"
49
+ TYPE_LUGS = "energy.ebus.device.lugs"
50
+ TYPE_CIRCUIT = "energy.ebus.device.circuit"
51
+ TYPE_BESS = "energy.ebus.device.bess"
52
+ TYPE_PV = "energy.ebus.device.pv"
53
+ TYPE_EVSE = "energy.ebus.device.evse"
54
+ TYPE_POWER_FLOWS = "energy.ebus.device.power-flows"
55
+
56
+ # Wire-only subtypes: real node types published by real firmware (confirmed
57
+ # against a live panel in 1eef0dc), but NOT declared in the schema's `types`
58
+ # block, which carries only the base `energy.ebus.device.lugs`. Firmware uses
59
+ # one convention or the other — typed nodes, or generic nodes plus a
60
+ # `direction` property — and _find_lugs_node handles both.
61
+ #
62
+ # Because the schema does not declare them, every one of these needs an entry
63
+ # in field_metadata._LUGS_FALLBACK mapping it to a declared type, or property
64
+ # metadata silently comes back empty for those nodes. The provenance test
65
+ # asserts that pairing rather than trusting it.
66
+ TYPE_LUGS_UPSTREAM = "energy.ebus.device.lugs.upstream"
67
+ TYPE_LUGS_DOWNSTREAM = "energy.ebus.device.lugs.downstream"
68
+
69
+ # Lugs direction values
70
+ LUGS_UPSTREAM = "UPSTREAM"
71
+ LUGS_DOWNSTREAM = "DOWNSTREAM"
72
+
73
+
74
+ def normalize_circuit_id(node_id: str) -> str:
75
+ """Strip dashes from Homie UUID for entity stability."""
76
+ return node_id.replace("-", "")
77
+
78
+
79
+ def denormalize_circuit_id(circuit_id: str) -> str:
80
+ """Restore dashes to a 32-char dashless UUID (8-4-4-4-12 format)."""
81
+ if len(circuit_id) == 32 and "-" not in circuit_id:
82
+ return f"{circuit_id[:8]}-{circuit_id[8:12]}-{circuit_id[12:16]}-{circuit_id[16:20]}-{circuit_id[20:]}"
83
+ return circuit_id
@@ -12,9 +12,15 @@ import logging
12
12
  import time
13
13
  from typing import ClassVar
14
14
 
15
- from ..models import SpanBatterySnapshot, SpanCircuitSnapshot, SpanEvseSnapshot, SpanPanelSnapshot, SpanPVSnapshot
16
- from .accumulator import HomiePropertyAccumulator
17
- from .const import (
15
+ from span_panel_api.models import (
16
+ SpanBatterySnapshot,
17
+ SpanCircuitSnapshot,
18
+ SpanEvseSnapshot,
19
+ SpanPanelSnapshot,
20
+ SpanPVSnapshot,
21
+ )
22
+ from span_panel_api_schema_0.accumulator import HomiePropertyAccumulator
23
+ from span_panel_api_schema_0.const import (
18
24
  LUGS_DOWNSTREAM,
19
25
  LUGS_UPSTREAM,
20
26
  TYPE_BESS,