tariffkit 0.2.0__tar.gz → 0.2.2__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 (109) hide show
  1. {tariffkit-0.2.0 → tariffkit-0.2.2}/CHANGELOG.md +56 -1
  2. {tariffkit-0.2.0 → tariffkit-0.2.2}/PKG-INFO +28 -2
  3. {tariffkit-0.2.0 → tariffkit-0.2.2}/README.md +26 -0
  4. {tariffkit-0.2.0 → tariffkit-0.2.2}/pyproject.toml +6 -2
  5. {tariffkit-0.2.0 → tariffkit-0.2.2}/src/tariffkit/cli.py +7 -0
  6. {tariffkit-0.2.0 → tariffkit-0.2.2}/src/tariffkit/mqtt/publisher.py +29 -1
  7. {tariffkit-0.2.0 → tariffkit-0.2.2}/tests/test_account_cli.py +12 -1
  8. {tariffkit-0.2.0 → tariffkit-0.2.2}/tests/test_mqtt_publisher.py +92 -0
  9. {tariffkit-0.2.0 → tariffkit-0.2.2}/tests/test_private_config.py +4 -1
  10. {tariffkit-0.2.0 → tariffkit-0.2.2}/.gitignore +0 -0
  11. {tariffkit-0.2.0 → tariffkit-0.2.2}/LICENSE +0 -0
  12. {tariffkit-0.2.0 → tariffkit-0.2.2}/src/tariffkit/__init__.py +0 -0
  13. {tariffkit-0.2.0 → tariffkit-0.2.2}/src/tariffkit/account/__init__.py +0 -0
  14. {tariffkit-0.2.0 → tariffkit-0.2.2}/src/tariffkit/account/cli.py +0 -0
  15. {tariffkit-0.2.0 → tariffkit-0.2.2}/src/tariffkit/account/errors.py +0 -0
  16. {tariffkit-0.2.0 → tariffkit-0.2.2}/src/tariffkit/account/model.py +0 -0
  17. {tariffkit-0.2.0 → tariffkit-0.2.2}/src/tariffkit/account/rates.py +0 -0
  18. {tariffkit-0.2.0 → tariffkit-0.2.2}/src/tariffkit/account/repository.py +0 -0
  19. {tariffkit-0.2.0 → tariffkit-0.2.2}/src/tariffkit/billing/__init__.py +0 -0
  20. {tariffkit-0.2.0 → tariffkit-0.2.2}/src/tariffkit/billing/engine.py +0 -0
  21. {tariffkit-0.2.0 → tariffkit-0.2.2}/src/tariffkit/billing/ledger.py +0 -0
  22. {tariffkit-0.2.0 → tariffkit-0.2.2}/src/tariffkit/billing/models.py +0 -0
  23. {tariffkit-0.2.0 → tariffkit-0.2.2}/src/tariffkit/billing/netting.py +0 -0
  24. {tariffkit-0.2.0 → tariffkit-0.2.2}/src/tariffkit/billing/trueup.py +0 -0
  25. {tariffkit-0.2.0 → tariffkit-0.2.2}/src/tariffkit/cca.py +0 -0
  26. {tariffkit-0.2.0 → tariffkit-0.2.2}/src/tariffkit/config.py +0 -0
  27. {tariffkit-0.2.0 → tariffkit-0.2.2}/src/tariffkit/data/__init__.py +0 -0
  28. {tariffkit-0.2.0 → tariffkit-0.2.2}/src/tariffkit/data/cca/mce/2023-01-01.toml +0 -0
  29. {tariffkit-0.2.0 → tariffkit-0.2.2}/src/tariffkit/data/cca/mce/2026-04-01.toml +0 -0
  30. {tariffkit-0.2.0 → tariffkit-0.2.2}/src/tariffkit/data/export/pge/acc_plus/2023-04-15.toml +0 -0
  31. {tariffkit-0.2.0 → tariffkit-0.2.2}/src/tariffkit/data/export/pge/nbt00.json.gz +0 -0
  32. {tariffkit-0.2.0 → tariffkit-0.2.2}/src/tariffkit/data/export/pge/nbt23.json.gz +0 -0
  33. {tariffkit-0.2.0 → tariffkit-0.2.2}/src/tariffkit/data/export/pge/nbt24.json.gz +0 -0
  34. {tariffkit-0.2.0 → tariffkit-0.2.2}/src/tariffkit/data/export/pge/nbt25.json.gz +0 -0
  35. {tariffkit-0.2.0 → tariffkit-0.2.2}/src/tariffkit/data/export/pge/nbt26.json.gz +0 -0
  36. {tariffkit-0.2.0 → tariffkit-0.2.2}/src/tariffkit/data/holidays.toml +0 -0
  37. {tariffkit-0.2.0 → tariffkit-0.2.2}/src/tariffkit/data/manifest.json +0 -0
  38. {tariffkit-0.2.0 → tariffkit-0.2.2}/src/tariffkit/data/nsc/pge.toml +0 -0
  39. {tariffkit-0.2.0 → tariffkit-0.2.2}/src/tariffkit/data/tariff/pge/eelec/2025-01-01.toml +0 -0
  40. {tariffkit-0.2.0 → tariffkit-0.2.2}/src/tariffkit/data/tariff/pge/eelec/2025-03-01.toml +0 -0
  41. {tariffkit-0.2.0 → tariffkit-0.2.2}/src/tariffkit/data/tariff/pge/eelec/2025-09-01.toml +0 -0
  42. {tariffkit-0.2.0 → tariffkit-0.2.2}/src/tariffkit/data/tariff/pge/eelec/2026-01-01.toml +0 -0
  43. {tariffkit-0.2.0 → tariffkit-0.2.2}/src/tariffkit/data/tariff/pge/eelec/2026-03-01.toml +0 -0
  44. {tariffkit-0.2.0 → tariffkit-0.2.2}/src/tariffkit/data/tariff/pge/etouc/2025-01-01.toml +0 -0
  45. {tariffkit-0.2.0 → tariffkit-0.2.2}/src/tariffkit/data/tariff/pge/etouc/2025-03-01.toml +0 -0
  46. {tariffkit-0.2.0 → tariffkit-0.2.2}/src/tariffkit/data/tariff/pge/etouc/2025-09-01.toml +0 -0
  47. {tariffkit-0.2.0 → tariffkit-0.2.2}/src/tariffkit/data/tariff/pge/etouc/2026-01-01.toml +0 -0
  48. {tariffkit-0.2.0 → tariffkit-0.2.2}/src/tariffkit/data/tariff/pge/etouc/2026-03-01.toml +0 -0
  49. {tariffkit-0.2.0 → tariffkit-0.2.2}/src/tariffkit/data/tariff/pge/ev2a/2025-01-01.toml +0 -0
  50. {tariffkit-0.2.0 → tariffkit-0.2.2}/src/tariffkit/data/tariff/pge/ev2a/2025-03-01.toml +0 -0
  51. {tariffkit-0.2.0 → tariffkit-0.2.2}/src/tariffkit/data/tariff/pge/ev2a/2025-09-01.toml +0 -0
  52. {tariffkit-0.2.0 → tariffkit-0.2.2}/src/tariffkit/data/tariff/pge/ev2a/2026-01-01.toml +0 -0
  53. {tariffkit-0.2.0 → tariffkit-0.2.2}/src/tariffkit/data/tariff/pge/ev2a/2026-03-01.toml +0 -0
  54. {tariffkit-0.2.0 → tariffkit-0.2.2}/src/tariffkit/data/tax/ca_energy_resources/2025-01-01.toml +0 -0
  55. {tariffkit-0.2.0 → tariffkit-0.2.2}/src/tariffkit/data/tax/ca_energy_resources/2026-01-01.toml +0 -0
  56. {tariffkit-0.2.0 → tariffkit-0.2.2}/src/tariffkit/data/versioned.py +0 -0
  57. {tariffkit-0.2.0 → tariffkit-0.2.2}/src/tariffkit/engine.py +0 -0
  58. {tariffkit-0.2.0 → tariffkit-0.2.2}/src/tariffkit/errors.py +0 -0
  59. {tariffkit-0.2.0 → tariffkit-0.2.2}/src/tariffkit/export/__init__.py +0 -0
  60. {tariffkit-0.2.0 → tariffkit-0.2.2}/src/tariffkit/export/nbt.py +0 -0
  61. {tariffkit-0.2.0 → tariffkit-0.2.2}/src/tariffkit/interop/__init__.py +0 -0
  62. {tariffkit-0.2.0 → tariffkit-0.2.2}/src/tariffkit/interop/emhass.py +0 -0
  63. {tariffkit-0.2.0 → tariffkit-0.2.2}/src/tariffkit/interop/predbat.py +0 -0
  64. {tariffkit-0.2.0 → tariffkit-0.2.2}/src/tariffkit/interop/slots.py +0 -0
  65. {tariffkit-0.2.0 → tariffkit-0.2.2}/src/tariffkit/models.py +0 -0
  66. {tariffkit-0.2.0 → tariffkit-0.2.2}/src/tariffkit/mqtt/__init__.py +0 -0
  67. {tariffkit-0.2.0 → tariffkit-0.2.2}/src/tariffkit/mqtt/discovery.py +0 -0
  68. {tariffkit-0.2.0 → tariffkit-0.2.2}/src/tariffkit/providers/__init__.py +0 -0
  69. {tariffkit-0.2.0 → tariffkit-0.2.2}/src/tariffkit/providers/pge/__init__.py +0 -0
  70. {tariffkit-0.2.0 → tariffkit-0.2.2}/src/tariffkit/providers/pge/reconcile.py +0 -0
  71. {tariffkit-0.2.0 → tariffkit-0.2.2}/src/tariffkit/providers/pge/statements/__init__.py +0 -0
  72. {tariffkit-0.2.0 → tariffkit-0.2.2}/src/tariffkit/providers/pge/statements/errors.py +0 -0
  73. {tariffkit-0.2.0 → tariffkit-0.2.2}/src/tariffkit/providers/pge/statements/model.py +0 -0
  74. {tariffkit-0.2.0 → tariffkit-0.2.2}/src/tariffkit/providers/pge/statements/ocr.py +0 -0
  75. {tariffkit-0.2.0 → tariffkit-0.2.2}/src/tariffkit/providers/pge/statements/parse.py +0 -0
  76. {tariffkit-0.2.0 → tariffkit-0.2.2}/src/tariffkit/py.typed +0 -0
  77. {tariffkit-0.2.0 → tariffkit-0.2.2}/src/tariffkit/secrets.py +0 -0
  78. {tariffkit-0.2.0 → tariffkit-0.2.2}/src/tariffkit/sources/__init__.py +0 -0
  79. {tariffkit-0.2.0 → tariffkit-0.2.2}/src/tariffkit/sources/greenbutton.py +0 -0
  80. {tariffkit-0.2.0 → tariffkit-0.2.2}/src/tariffkit/sources/homeassistant.py +0 -0
  81. {tariffkit-0.2.0 → tariffkit-0.2.2}/src/tariffkit/sources/influx.py +0 -0
  82. {tariffkit-0.2.0 → tariffkit-0.2.2}/src/tariffkit/sources/pge.py +0 -0
  83. {tariffkit-0.2.0 → tariffkit-0.2.2}/src/tariffkit/tariff/__init__.py +0 -0
  84. {tariffkit-0.2.0 → tariffkit-0.2.2}/src/tariffkit/tariff/retail.py +0 -0
  85. {tariffkit-0.2.0 → tariffkit-0.2.2}/src/tariffkit/timeutil.py +0 -0
  86. {tariffkit-0.2.0 → tariffkit-0.2.2}/src/tariffkit/web/__init__.py +0 -0
  87. {tariffkit-0.2.0 → tariffkit-0.2.2}/src/tariffkit/web/app.py +0 -0
  88. {tariffkit-0.2.0 → tariffkit-0.2.2}/tests/conftest.py +0 -0
  89. {tariffkit-0.2.0 → tariffkit-0.2.2}/tests/fixtures/nbt_golden_rows.jsonl +0 -0
  90. {tariffkit-0.2.0 → tariffkit-0.2.2}/tests/fixtures/statements/synthetic_cca_ratechange.txt +0 -0
  91. {tariffkit-0.2.0 → tariffkit-0.2.2}/tests/test_account.py +0 -0
  92. {tariffkit-0.2.0 → tariffkit-0.2.2}/tests/test_billing.py +0 -0
  93. {tariffkit-0.2.0 → tariffkit-0.2.2}/tests/test_eelec.py +0 -0
  94. {tariffkit-0.2.0 → tariffkit-0.2.2}/tests/test_engine.py +0 -0
  95. {tariffkit-0.2.0 → tariffkit-0.2.2}/tests/test_ev2a.py +0 -0
  96. {tariffkit-0.2.0 → tariffkit-0.2.2}/tests/test_export_golden.py +0 -0
  97. {tariffkit-0.2.0 → tariffkit-0.2.2}/tests/test_integrations.py +0 -0
  98. {tariffkit-0.2.0 → tariffkit-0.2.2}/tests/test_interop.py +0 -0
  99. {tariffkit-0.2.0 → tariffkit-0.2.2}/tests/test_ledger.py +0 -0
  100. {tariffkit-0.2.0 → tariffkit-0.2.2}/tests/test_mce.py +0 -0
  101. {tariffkit-0.2.0 → tariffkit-0.2.2}/tests/test_pge_reconcile.py +0 -0
  102. {tariffkit-0.2.0 → tariffkit-0.2.2}/tests/test_pge_statements.py +0 -0
  103. {tariffkit-0.2.0 → tariffkit-0.2.2}/tests/test_schedules.py +0 -0
  104. {tariffkit-0.2.0 → tariffkit-0.2.2}/tests/test_sources_greenbutton.py +0 -0
  105. {tariffkit-0.2.0 → tariffkit-0.2.2}/tests/test_sources_ha.py +0 -0
  106. {tariffkit-0.2.0 → tariffkit-0.2.2}/tests/test_sources_influx.py +0 -0
  107. {tariffkit-0.2.0 → tariffkit-0.2.2}/tests/test_sources_pge.py +0 -0
  108. {tariffkit-0.2.0 → tariffkit-0.2.2}/tests/test_trueup.py +0 -0
  109. {tariffkit-0.2.0 → tariffkit-0.2.2}/tests/test_versioned.py +0 -0
@@ -5,6 +5,59 @@ All notable changes to this project are documented here. This project follows
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.2.2] - 2026-08-16
9
+
10
+ ### Added
11
+ - Project documentation now uses a TariffKit banner built from the integration's
12
+ existing icon and a provider-neutral electricity rate curve.
13
+ - Public contribution and support guidance now provides privacy-safe issue
14
+ forms, private security routing, review ownership, a pull request checklist,
15
+ community conduct expectations, and exact development checks. The guidance
16
+ also makes the generated-data and repository-only audit boundaries explicit
17
+ so public collaboration does not expose utility-account material or alter
18
+ distribution guarantees.
19
+ - Pull requests now run GitHub's dependency review action with read-only
20
+ permissions so vulnerable or disallowed dependency changes fail before merge.
21
+
22
+ ### Changed
23
+ - **Python 3.14.2 and Home Assistant 2026.3.0 are now the supported floors.**
24
+ The lockfile no longer carries the obsolete Home Assistant 2026.2 fallback,
25
+ and CI audits the complete locked dependency graph with a pinned `pip-audit`
26
+ release while retaining raw reports as failure artifacts. The Linux secrets
27
+ extra and Home Assistant tests both inherit the same exact cryptography pin;
28
+ an expiring policy requires each audit to report exactly its three known,
29
+ unreachable advisories and rejects any additional finding.
30
+ - The README now explains that default HACS approval may take months and gives
31
+ complete custom-repository installation steps for use during the review.
32
+ - Workflow actions are pinned to reviewed immutable commits, checkout credentials
33
+ are not persisted, superseded runs are bounded by concurrency controls, and
34
+ repository write access is isolated to the jobs that publish results.
35
+ - MQTT now rejects credentials over plaintext unless the operator explicitly
36
+ allows insecure authentication for an isolated trusted network. Passwords
37
+ also require a username, while anonymous plaintext publishing remains valid.
38
+
39
+ ### Fixed
40
+ - Rate-sheet regeneration now scans trailing table cells with a linear parser,
41
+ avoiding pathological regular-expression backtracking on malformed publisher
42
+ text while preserving accepted dollar, decimal, negative, and change-marker
43
+ forms.
44
+
45
+ ## [0.2.1] - 2026-08-16
46
+
47
+ ### Added
48
+ - **HACS releases now include a deterministic `tariffkit.zip` integration
49
+ artifact.** HACS installs only the tracked component files, rooted directly
50
+ in the integration directory, while the release pipeline validates the ZIP
51
+ against its source, checksums it with the Python distributions, and attaches
52
+ all artifacts before immutable publication. Dedicated HACS and hassfest
53
+ checks also gate integration changes and prepare TariffKit for default-store
54
+ submission.
55
+
56
+ ### Fixed
57
+ - Home Assistant action descriptions now follow hassfest's current service
58
+ schema: icons live in `icons.json`, target metadata is explicit, and the
59
+ config-entry-only YAML schema is declared.
60
+
8
61
  ## [0.2.0] - 2026-08-16
9
62
 
10
63
  ### Added
@@ -168,5 +221,7 @@ Initial release.
168
221
  - Holiday calendars are extracted per vintage from the source data rather than
169
222
  recomputed, because the vintage files disagree in far-future years.
170
223
 
171
- [Unreleased]: https://github.com/eman/tariffkit/compare/v0.2.0...HEAD
224
+ [Unreleased]: https://github.com/eman/tariffkit/compare/v0.2.2...HEAD
225
+ [0.2.2]: https://github.com/eman/tariffkit/releases/tag/v0.2.2
226
+ [0.2.1]: https://github.com/eman/tariffkit/releases/tag/v0.2.1
172
227
  [0.2.0]: https://github.com/eman/tariffkit/releases/tag/v0.2.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: tariffkit
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: Offline electricity tariff pricing, billing, and integration tools
5
5
  Project-URL: Homepage, https://github.com/eman/tariffkit
6
6
  Project-URL: Documentation, https://github.com/eman/tariffkit#documentation
@@ -17,7 +17,7 @@ Classifier: Programming Language :: Python :: 3
17
17
  Classifier: Programming Language :: Python :: 3.14
18
18
  Classifier: Topic :: Home Automation
19
19
  Classifier: Typing :: Typed
20
- Requires-Python: >=3.14
20
+ Requires-Python: >=3.14.2
21
21
  Provides-Extra: all
22
22
  Requires-Dist: fastapi>=0.110; extra == 'all'
23
23
  Requires-Dist: httpx>=0.27; extra == 'all'
@@ -43,8 +43,17 @@ Requires-Dist: fastapi>=0.110; extra == 'web'
43
43
  Requires-Dist: uvicorn[standard]>=0.29; extra == 'web'
44
44
  Description-Content-Type: text/markdown
45
45
 
46
+ <p align="center">
47
+ <img src="images/banner.svg" alt="TariffKit — electricity pricing, billing, and energy integrations" width="100%">
48
+ </p>
49
+
46
50
  # TariffKit
47
51
 
52
+ [![CI](https://github.com/eman/tariffkit/actions/workflows/ci.yml/badge.svg)](https://github.com/eman/tariffkit/actions/workflows/ci.yml)
53
+ [![PyPI](https://img.shields.io/pypi/v/tariffkit)](https://pypi.org/project/tariffkit/)
54
+ [![Python 3.14.2](https://img.shields.io/badge/python-3.14.2-blue.svg)](https://www.python.org/downloads/)
55
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
56
+
48
57
  An offline electricity tariff engine for pricing, billing, and energy-system
49
58
  integrations. The first data provider supports PG&E residential rate plans under
50
59
  **NEM 3.0 / the Net Billing Tariff (NBT)**; the package identity is deliberately
@@ -134,6 +143,23 @@ pip install 'tariffkit[statements]' # + reading local PG&E statement PDFs
134
143
  pip install 'tariffkit[all]'
135
144
  ```
136
145
 
146
+ The Home Assistant integration has been
147
+ [submitted to the default HACS store](https://github.com/hacs/default/pull/10019),
148
+ but approval queues can take months. Until it is approved, install TariffKit as
149
+ a custom repository:
150
+
151
+ [![Open your Home Assistant instance and add the TariffKit repository to HACS](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=eman&repository=tariffkit&category=integration)
152
+
153
+ Or add it manually:
154
+
155
+ 1. Open **HACS** in Home Assistant.
156
+ 2. Select the **three-dot menu → Custom repositories**.
157
+ 3. Enter `https://github.com/eman/tariffkit`, choose **Integration**, and select
158
+ **Add**.
159
+ 4. Search HACS for **TariffKit**, open it, and select **Download**.
160
+ 5. Restart Home Assistant, then add TariffKit from
161
+ **Settings → Devices & services → Add integration**.
162
+
137
163
  ## CLI
138
164
 
139
165
  ```bash
@@ -1,5 +1,14 @@
1
+ <p align="center">
2
+ <img src="images/banner.svg" alt="TariffKit — electricity pricing, billing, and energy integrations" width="100%">
3
+ </p>
4
+
1
5
  # TariffKit
2
6
 
7
+ [![CI](https://github.com/eman/tariffkit/actions/workflows/ci.yml/badge.svg)](https://github.com/eman/tariffkit/actions/workflows/ci.yml)
8
+ [![PyPI](https://img.shields.io/pypi/v/tariffkit)](https://pypi.org/project/tariffkit/)
9
+ [![Python 3.14.2](https://img.shields.io/badge/python-3.14.2-blue.svg)](https://www.python.org/downloads/)
10
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
11
+
3
12
  An offline electricity tariff engine for pricing, billing, and energy-system
4
13
  integrations. The first data provider supports PG&E residential rate plans under
5
14
  **NEM 3.0 / the Net Billing Tariff (NBT)**; the package identity is deliberately
@@ -89,6 +98,23 @@ pip install 'tariffkit[statements]' # + reading local PG&E statement PDFs
89
98
  pip install 'tariffkit[all]'
90
99
  ```
91
100
 
101
+ The Home Assistant integration has been
102
+ [submitted to the default HACS store](https://github.com/hacs/default/pull/10019),
103
+ but approval queues can take months. Until it is approved, install TariffKit as
104
+ a custom repository:
105
+
106
+ [![Open your Home Assistant instance and add the TariffKit repository to HACS](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=eman&repository=tariffkit&category=integration)
107
+
108
+ Or add it manually:
109
+
110
+ 1. Open **HACS** in Home Assistant.
111
+ 2. Select the **three-dot menu → Custom repositories**.
112
+ 3. Enter `https://github.com/eman/tariffkit`, choose **Integration**, and select
113
+ **Add**.
114
+ 4. Search HACS for **TariffKit**, open it, and select **Download**.
115
+ 5. Restart Home Assistant, then add TariffKit from
116
+ **Settings → Devices & services → Add integration**.
117
+
92
118
  ## CLI
93
119
 
94
120
  ```bash
@@ -4,12 +4,12 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "tariffkit"
7
- version = "0.2.0"
7
+ version = "0.2.2"
8
8
  description = "Offline electricity tariff pricing, billing, and integration tools"
9
9
  readme = "README.md"
10
10
  license = "MIT"
11
11
  license-files = ["LICENSE"]
12
- requires-python = ">=3.14"
12
+ requires-python = ">=3.14.2"
13
13
  keywords = ["pge", "nem3", "net-billing-tariff", "solar", "battery", "home-assistant", "energy", "tariff"]
14
14
  classifiers = [
15
15
  "Development Status :: 3 - Alpha",
@@ -61,11 +61,15 @@ release = [
61
61
  "packaging>=25",
62
62
  "twine>=6.2",
63
63
  ]
64
+ security = [
65
+ "pip-audit==2.10.1",
66
+ ]
64
67
  dev = [
65
68
  { include-group = "test" },
66
69
  { include-group = "quality" },
67
70
  { include-group = "regen" },
68
71
  { include-group = "release" },
72
+ { include-group = "security" },
69
73
  "fastapi>=0.110",
70
74
  "paho-mqtt>=2.0",
71
75
  "pytest-homeassistant-custom-component>=0.13.316",
@@ -215,6 +215,12 @@ def build_parser() -> argparse.ArgumentParser:
215
215
  mqtt.add_argument("--topic-prefix")
216
216
  mqtt.add_argument("--forecast-hours", type=int)
217
217
  mqtt.add_argument("--tls", action="store_true", default=None)
218
+ mqtt.add_argument(
219
+ "--allow-insecure-auth",
220
+ action="store_true",
221
+ default=None,
222
+ help="allow MQTT credentials without TLS on an isolated trusted network",
223
+ )
218
224
  mqtt.add_argument(
219
225
  "--no-discovery",
220
226
  dest="discovery",
@@ -601,6 +607,7 @@ def _mqtt_settings(args: Any, *, config: Config | None, profile_name: str | None
601
607
  discovery=args.discovery,
602
608
  forecast_hours=args.forecast_hours,
603
609
  tls=args.tls,
610
+ allow_insecure_auth=args.allow_insecure_auth,
604
611
  profile=profile_name,
605
612
  )
606
613
  if config is not None and settings.profile is not None:
@@ -49,10 +49,26 @@ class MqttSettings:
49
49
  forecast_hours: int = 48
50
50
  client_id: str = "tariffkit"
51
51
  tls: bool = False
52
+ allow_insecure_auth: bool = False
52
53
  profile: str | None = None
53
54
  account: str | None = None
54
55
 
55
56
  def __post_init__(self) -> None:
57
+ if not isinstance(self.tls, bool):
58
+ raise ConfigError("MQTT tls must be a boolean")
59
+ if not isinstance(self.allow_insecure_auth, bool):
60
+ raise ConfigError("MQTT allow_insecure_auth must be a boolean")
61
+ if self.password is not None and not self.username:
62
+ raise ConfigError("MQTT password requires a username")
63
+ if (
64
+ (self.username is not None or self.password is not None)
65
+ and not self.tls
66
+ and not self.allow_insecure_auth
67
+ ):
68
+ raise ConfigError(
69
+ "MQTT credentials require TLS; enable tls or explicitly set "
70
+ "allow_insecure_auth for an isolated trusted network"
71
+ )
56
72
  if self.profile is not None and self.account is not None and self.profile != self.account:
57
73
  raise ConfigError("profile and account selections disagree")
58
74
  if self.profile is None:
@@ -79,6 +95,7 @@ class MqttSettings:
79
95
  "forecast_hours",
80
96
  "client_id",
81
97
  "tls",
98
+ "allow_insecure_auth",
82
99
  "profile",
83
100
  "account",
84
101
  ):
@@ -101,6 +118,17 @@ class MqttSettings:
101
118
  ):
102
119
  if value := env.get(name):
103
120
  values[key] = value
121
+ if value := env.get("TARIFFKIT_MQTT_ALLOW_INSECURE_AUTH"):
122
+ normalized = value.casefold()
123
+ if normalized in {"1", "true", "yes", "on"}:
124
+ values["allow_insecure_auth"] = True
125
+ elif normalized in {"0", "false", "no", "off"}:
126
+ values["allow_insecure_auth"] = False
127
+ else:
128
+ raise ConfigError(
129
+ "TARIFFKIT_MQTT_ALLOW_INSECURE_AUTH must be a boolean "
130
+ "(true/false, yes/no, on/off, or 1/0)"
131
+ )
104
132
  for name in ("TARIFFKIT_ACCOUNT", "TARIFFKIT_PROFILE"):
105
133
  if value := env.get(name):
106
134
  values["profile"] = value
@@ -177,7 +205,7 @@ class MqttPublisher:
177
205
  Kept separate from construction to allow dependency injection of the client
178
206
  while still exercising this logic.
179
207
  """
180
- if self.settings.username:
208
+ if self.settings.username is not None:
181
209
  client.username_pw_set(self.settings.username, self.settings.password)
182
210
  if self.settings.tls:
183
211
  client.tls_set()
@@ -21,7 +21,7 @@ from tariffkit.account import (
21
21
  )
22
22
  from tariffkit.account.cli import migrate_existing, sync_profile
23
23
  from tariffkit.billing import BillingPeriod, IntervalReading
24
- from tariffkit.cli import _mqtt_settings, _pricing_context, main
24
+ from tariffkit.cli import _mqtt_settings, _pricing_context, build_parser, main
25
25
  from tariffkit.config import Config
26
26
  from tariffkit.errors import ConfigError
27
27
  from tariffkit.models import Supplier
@@ -412,6 +412,7 @@ def test_explicit_config_stops_mqtt_from_reverting_to_a_default_profile(
412
412
  discovery=None,
413
413
  forecast_hours=None,
414
414
  tls=None,
415
+ allow_insecure_auth=None,
415
416
  )
416
417
  _engine, config, profile_name, _repository = _pricing_context(args)
417
418
 
@@ -420,3 +421,13 @@ def test_explicit_config_stops_mqtt_from_reverting_to_a_default_profile(
420
421
  settings = _mqtt_settings(args, config=config, profile_name=profile_name)
421
422
 
422
423
  assert settings.profile is None
424
+
425
+
426
+ def test_mqtt_cli_accepts_insecure_auth_escape_hatch() -> None:
427
+ args = build_parser().parse_args(
428
+ ["mqtt", "--broker", "broker.local", "--username", "user", "--allow-insecure-auth"]
429
+ )
430
+
431
+ settings = _mqtt_settings(args, config=None, profile_name=None)
432
+
433
+ assert settings.allow_insecure_auth is True
@@ -23,9 +23,17 @@ class FakeClient:
23
23
  self.published: list[tuple[str, str, bool]] = []
24
24
  self.will: tuple[str, str, bool] | None = None
25
25
  self.connected: tuple[str, int] | None = None
26
+ self.auth: tuple[str, str | None] | None = None
27
+ self.tls = False
26
28
  self.loop_running = False
27
29
  self.disconnected = False
28
30
 
31
+ def username_pw_set(self, username: str, password: str | None = None) -> None:
32
+ self.auth = (username, password)
33
+
34
+ def tls_set(self) -> None:
35
+ self.tls = True
36
+
29
37
  def will_set(self, topic: str, payload: str, retain: bool = False) -> None:
30
38
  self.will = (topic, payload, retain)
31
39
 
@@ -82,6 +90,48 @@ def test_discovery_can_be_disabled() -> None:
82
90
  assert not any(t.startswith("homeassistant/") for t in client_of(publisher).topics())
83
91
 
84
92
 
93
+ def test_anonymous_plaintext_connection_is_allowed() -> None:
94
+ publisher = make_publisher()
95
+
96
+ assert client_of(publisher).auth is None
97
+ assert client_of(publisher).tls is False
98
+
99
+
100
+ def test_authenticated_plaintext_connection_is_rejected() -> None:
101
+ with pytest.raises(ConfigError, match="credentials require TLS"):
102
+ make_publisher(username="user")
103
+
104
+
105
+ def test_password_without_username_is_rejected_even_with_tls() -> None:
106
+ with pytest.raises(ConfigError, match="password requires a username"):
107
+ make_publisher(password="secret", tls=True)
108
+
109
+
110
+ def test_insecure_auth_escape_hatch_must_be_boolean() -> None:
111
+ with pytest.raises(ConfigError, match="allow_insecure_auth must be a boolean"):
112
+ make_publisher(username="user", allow_insecure_auth="false")
113
+
114
+
115
+ def test_authenticated_tls_connection_configures_auth_and_tls() -> None:
116
+ publisher = make_publisher(username="user", password="secret", tls=True, port=8883)
117
+
118
+ client = client_of(publisher)
119
+ assert client.auth == ("user", "secret")
120
+ assert client.tls is True
121
+
122
+
123
+ def test_explicit_insecure_auth_allows_credentials_without_tls() -> None:
124
+ publisher = make_publisher(
125
+ username="user",
126
+ password="secret",
127
+ allow_insecure_auth=True,
128
+ )
129
+
130
+ client = client_of(publisher)
131
+ assert client.auth == ("user", "secret")
132
+ assert client.tls is False
133
+
134
+
85
135
  def test_publishes_known_values_retained(publisher: MqttPublisher) -> None:
86
136
  publisher.publish_now(datetime(2026, 9, 15, 19, tzinfo=PACIFIC))
87
137
  topics = client_of(publisher).topics()
@@ -204,6 +254,48 @@ def test_mqtt_settings_read_default_profile_from_shared_config(tmp_path: Path) -
204
254
  assert settings.profile == "home"
205
255
 
206
256
 
257
+ def test_mqtt_settings_loads_insecure_auth_escape_hatch_from_toml(tmp_path: Path) -> None:
258
+ config = tmp_path / "config.toml"
259
+ config.write_text(
260
+ '[mqtt]\nbroker = "broker.local"\nallow_insecure_auth = true\n',
261
+ encoding="utf-8",
262
+ )
263
+
264
+ settings = MqttSettings.load(
265
+ config,
266
+ tmp_path / "absent",
267
+ username="user",
268
+ password="secret",
269
+ )
270
+
271
+ assert settings.allow_insecure_auth is True
272
+
273
+
274
+ def test_mqtt_settings_loads_insecure_auth_escape_hatch_from_environment(
275
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
276
+ ) -> None:
277
+ monkeypatch.setenv("TARIFFKIT_MQTT_BROKER", "broker.local")
278
+ monkeypatch.setenv("TARIFFKIT_MQTT_USERNAME", "user")
279
+ monkeypatch.setenv("TARIFFKIT_MQTT_ALLOW_INSECURE_AUTH", "true")
280
+
281
+ settings = MqttSettings.load(tmp_path / "absent", tmp_path / "absent-dotenv")
282
+
283
+ assert settings.allow_insecure_auth is True
284
+
285
+
286
+ def test_mqtt_settings_rejects_invalid_insecure_auth_environment_value(
287
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
288
+ ) -> None:
289
+ monkeypatch.setenv("TARIFFKIT_MQTT_ALLOW_INSECURE_AUTH", "sometimes")
290
+
291
+ with pytest.raises(ConfigError, match="must be a boolean"):
292
+ MqttSettings.load(
293
+ tmp_path / "absent",
294
+ tmp_path / "absent-dotenv",
295
+ broker="broker.local",
296
+ )
297
+
298
+
207
299
  def test_mqtt_environment_profile_overrides_config_alias(
208
300
  tmp_path: Path, monkeypatch: pytest.MonkeyPatch
209
301
  ) -> None:
@@ -101,7 +101,10 @@ def test_mqtt_settings_resolve_config_environment_and_keyring(
101
101
  tmp_path: Path, monkeypatch: pytest.MonkeyPatch
102
102
  ) -> None:
103
103
  config = tmp_path / "config.toml"
104
- config.write_text('[mqtt]\nbroker = "config-broker"\nport = 1884\n', encoding="utf-8")
104
+ config.write_text(
105
+ '[mqtt]\nbroker = "config-broker"\nport = 1884\ntls = true\n',
106
+ encoding="utf-8",
107
+ )
105
108
  monkeypatch.setenv("TARIFFKIT_MQTT_BROKER", "env-broker")
106
109
  monkeypatch.setattr(
107
110
  "tariffkit.mqtt.publisher.get_secret",
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes