franklinwh-modbus-async 0.0.1__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.
- franklinwh_modbus_async-0.0.1/.gitignore +23 -0
- franklinwh_modbus_async-0.0.1/CHANGELOG.md +27 -0
- franklinwh_modbus_async-0.0.1/LICENSE +21 -0
- franklinwh_modbus_async-0.0.1/PKG-INFO +113 -0
- franklinwh_modbus_async-0.0.1/README.md +78 -0
- franklinwh_modbus_async-0.0.1/pyproject.toml +79 -0
- franklinwh_modbus_async-0.0.1/src/franklinwh_modbus_async/__init__.py +8 -0
- franklinwh_modbus_async-0.0.1/src/franklinwh_modbus_async/py.typed +0 -0
- franklinwh_modbus_async-0.0.1/tests/test_package.py +13 -0
- franklinwh_modbus_async-0.0.1/tests/test_pii_check.py +67 -0
- franklinwh_modbus_async-0.0.1/tools/pii_check.py +147 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.egg-info/
|
|
5
|
+
build/
|
|
6
|
+
dist/
|
|
7
|
+
.venv/
|
|
8
|
+
venv/
|
|
9
|
+
|
|
10
|
+
# Tooling caches
|
|
11
|
+
.pytest_cache/
|
|
12
|
+
.ruff_cache/
|
|
13
|
+
.mypy_cache/
|
|
14
|
+
.coverage
|
|
15
|
+
htmlcov/
|
|
16
|
+
site/
|
|
17
|
+
|
|
18
|
+
# Local-only: never commit
|
|
19
|
+
.env
|
|
20
|
+
.pii-denylist
|
|
21
|
+
captures/raw/
|
|
22
|
+
tests/results/
|
|
23
|
+
.DS_Store
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are recorded here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project uses [Semantic Versioning](https://semver.org/).
|
|
4
|
+
|
|
5
|
+
## [Unreleased]
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **Phase 0 scaffold:**
|
|
10
|
+
- Packaging: Python >= 3.12, pinned `modbus-connection[tmodbus]~=4.12.4`.
|
|
11
|
+
- Code checks: ruff, mypy `--strict`, pytest-asyncio.
|
|
12
|
+
- CI on Python 3.12 and 3.13.
|
|
13
|
+
- **PII guard** (`tools/pii_check.py`). It runs in pre-commit and CI and blocks:
|
|
14
|
+
- home-directory paths
|
|
15
|
+
- aGate serial numbers
|
|
16
|
+
- MAC addresses
|
|
17
|
+
- non-documentation IP addresses
|
|
18
|
+
- email addresses
|
|
19
|
+
- terms from a local `.pii-denylist` file
|
|
20
|
+
- **Documentation:**
|
|
21
|
+
- `docs/why.md`: the library's purpose, basis and rationale, with a comparison table.
|
|
22
|
+
- `docs/research/modbus-connection.md`: findings on the transport.
|
|
23
|
+
- `PLAN.md`
|
|
24
|
+
- **Release workflow** (`.github/workflows/release.yml`):
|
|
25
|
+
- Publishes to PyPI with trusted publishing (OIDC) when a `v*` tag is pushed.
|
|
26
|
+
- The PII guard runs before the build.
|
|
27
|
+
- **sdist contents:** the source distribution includes only `src/`, `tests/`, the PII tool, README, LICENSE and CHANGELOG.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 david2069
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: franklinwh-modbus-async
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Async FranklinWH aGate Modbus TCP library (SunSpec + extension registers) built on modbus-connection
|
|
5
|
+
Project-URL: Homepage, https://github.com/david2069/franklinwh-modbus-async
|
|
6
|
+
Project-URL: Bug Tracker, https://github.com/david2069/franklinwh-modbus-async/issues
|
|
7
|
+
Project-URL: Changelog, https://github.com/david2069/franklinwh-modbus-async/blob/main/CHANGELOG.md
|
|
8
|
+
Author-email: david2069 <david2069@users.noreply.github.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: agate,asyncio,battery,energy,franklinwh,modbus,sunspec
|
|
12
|
+
Classifier: Development Status :: 2 - Pre-Alpha
|
|
13
|
+
Classifier: Framework :: AsyncIO
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Home Automation
|
|
19
|
+
Classifier: Topic :: System :: Hardware
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
Requires-Python: >=3.12
|
|
22
|
+
Requires-Dist: modbus-connection[tmodbus]~=4.12.4
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: mypy>=1.11; extra == 'dev'
|
|
25
|
+
Requires-Dist: pre-commit>=3.7; extra == 'dev'
|
|
26
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
|
|
27
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
28
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
29
|
+
Provides-Extra: docs
|
|
30
|
+
Requires-Dist: mkdocs-material>=9.5; extra == 'docs'
|
|
31
|
+
Requires-Dist: mkdocs<2,>=1.6; extra == 'docs'
|
|
32
|
+
Provides-Extra: pymodbus
|
|
33
|
+
Requires-Dist: modbus-connection[pymodbus]~=4.12.4; extra == 'pymodbus'
|
|
34
|
+
Description-Content-Type: text/markdown
|
|
35
|
+
|
|
36
|
+
# franklinwh-modbus-async
|
|
37
|
+
|
|
38
|
+
An **async** Python library for the FranklinWH **aGate**. It talks Modbus TCP and covers the SunSpec models and the vendor extension registers. It is built on [`modbus-connection`](https://github.com/home-assistant-libs/modbus-connection).
|
|
39
|
+
|
|
40
|
+
This is the asyncio successor to [`franklinwh-modbus`](https://github.com/david2069/franklinwh-modbus), which is synchronous. It aims to match that library's features and to fix its known connection and threading defects. To find out why it exists and how it compares, read **[docs/why.md](https://github.com/david2069/franklinwh-modbus-async/blob/main/docs/why.md)**.
|
|
41
|
+
|
|
42
|
+
> **Status: pre-alpha (Phase 0 of 7).** The tooling and docs are in place, but there is no device API yet. See [PLAN.md](https://github.com/david2069/franklinwh-modbus-async/blob/main/PLAN.md) for the roadmap.
|
|
43
|
+
|
|
44
|
+
> ## ⚠️ Unofficial software
|
|
45
|
+
>
|
|
46
|
+
> **About this software**
|
|
47
|
+
>
|
|
48
|
+
> - Not affiliated with, endorsed or supported by FranklinWH. FranklinWH and aGate are trademarks of their owners.
|
|
49
|
+
> - Provided **as is**, without warranty, for educational and informational use.
|
|
50
|
+
>
|
|
51
|
+
> **Write commands carry real risk**
|
|
52
|
+
>
|
|
53
|
+
> - This library can send **write commands to grid-connected battery hardware**.
|
|
54
|
+
> - Used incorrectly, it can discharge your battery when you need it, import power you did not intend to, or leave the system in an unexpected state.
|
|
55
|
+
> - You use it at your own risk.
|
|
56
|
+
>
|
|
57
|
+
> **Getting help**
|
|
58
|
+
>
|
|
59
|
+
> - Do not contact FranklinWH support about this library. Report problems [here](https://github.com/david2069/franklinwh-modbus-async/issues).
|
|
60
|
+
|
|
61
|
+
## Requirements
|
|
62
|
+
|
|
63
|
+
- An aGate with **Modbus TCP enabled** (ask your installer), reachable on TCP port 502. A fixed IP and a wired LAN are recommended.
|
|
64
|
+
- **Python 3.12 or later.**
|
|
65
|
+
|
|
66
|
+
## Install
|
|
67
|
+
|
|
68
|
+
The library is not on PyPI yet. To work on it from source:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
git clone git@github.com:david2069/franklinwh-modbus-async.git
|
|
72
|
+
cd franklinwh-modbus-async
|
|
73
|
+
python3.12 -m venv .venv && source .venv/bin/activate
|
|
74
|
+
pip install -e ".[dev]"
|
|
75
|
+
pre-commit install
|
|
76
|
+
pytest
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Roadmap
|
|
80
|
+
|
|
81
|
+
| Phase | Scope | State |
|
|
82
|
+
|---|---|---|
|
|
83
|
+
| 0 | Scaffold, CI, PII guard, rationale docs | ✅ |
|
|
84
|
+
| 1 | Connection, SunSpec discovery, generated models, `FakeAGate` | — |
|
|
85
|
+
| 2 | Typed reads and snapshot | — |
|
|
86
|
+
| 3 | Control: M704 dispatch, watchdog, verify, native modes, safety | — |
|
|
87
|
+
| 4 | Virtual modes, TOU schedules, sequencer, discovery | — |
|
|
88
|
+
| 5 | CLI | — |
|
|
89
|
+
| 6 | Emulator and hardware verification | — |
|
|
90
|
+
| 7 | 1.0 release, migration guide | — |
|
|
91
|
+
|
|
92
|
+
## Documentation
|
|
93
|
+
|
|
94
|
+
| Page | |
|
|
95
|
+
|---|---|
|
|
96
|
+
| [Why this library](https://github.com/david2069/franklinwh-modbus-async/blob/main/docs/why.md) | Purpose, basis, why async, why modbus-connection, and how it compares with the alternatives |
|
|
97
|
+
| [modbus-connection findings](https://github.com/david2069/franklinwh-modbus-async/blob/main/docs/research/modbus-connection.md) | Notes from reviewing the transport library |
|
|
98
|
+
| [Plan](https://github.com/david2069/franklinwh-modbus-async/blob/main/PLAN.md) | Design decisions and phases |
|
|
99
|
+
| [Changelog](https://github.com/david2069/franklinwh-modbus-async/blob/main/CHANGELOG.md) | |
|
|
100
|
+
|
|
101
|
+
## Contributing
|
|
102
|
+
|
|
103
|
+
PRs are welcome. CI runs ruff, mypy `--strict`, pytest and a **PII guard** (`tools/pii_check.py`).
|
|
104
|
+
|
|
105
|
+
- **Placeholders only.** In code, docs, examples and captured output, use:
|
|
106
|
+
- `192.0.2.10` for hosts
|
|
107
|
+
- `FWH-SN-REDACTED` for serials
|
|
108
|
+
- `00:00:5E:00:53:00` for MAC addresses
|
|
109
|
+
- **Private terms.** Put anything you want blocked in a local `.pii-denylist`. git ignores this file.
|
|
110
|
+
|
|
111
|
+
## License
|
|
112
|
+
|
|
113
|
+
MIT. See [LICENSE](https://github.com/david2069/franklinwh-modbus-async/blob/main/LICENSE).
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# franklinwh-modbus-async
|
|
2
|
+
|
|
3
|
+
An **async** Python library for the FranklinWH **aGate**. It talks Modbus TCP and covers the SunSpec models and the vendor extension registers. It is built on [`modbus-connection`](https://github.com/home-assistant-libs/modbus-connection).
|
|
4
|
+
|
|
5
|
+
This is the asyncio successor to [`franklinwh-modbus`](https://github.com/david2069/franklinwh-modbus), which is synchronous. It aims to match that library's features and to fix its known connection and threading defects. To find out why it exists and how it compares, read **[docs/why.md](https://github.com/david2069/franklinwh-modbus-async/blob/main/docs/why.md)**.
|
|
6
|
+
|
|
7
|
+
> **Status: pre-alpha (Phase 0 of 7).** The tooling and docs are in place, but there is no device API yet. See [PLAN.md](https://github.com/david2069/franklinwh-modbus-async/blob/main/PLAN.md) for the roadmap.
|
|
8
|
+
|
|
9
|
+
> ## ⚠️ Unofficial software
|
|
10
|
+
>
|
|
11
|
+
> **About this software**
|
|
12
|
+
>
|
|
13
|
+
> - Not affiliated with, endorsed or supported by FranklinWH. FranklinWH and aGate are trademarks of their owners.
|
|
14
|
+
> - Provided **as is**, without warranty, for educational and informational use.
|
|
15
|
+
>
|
|
16
|
+
> **Write commands carry real risk**
|
|
17
|
+
>
|
|
18
|
+
> - This library can send **write commands to grid-connected battery hardware**.
|
|
19
|
+
> - Used incorrectly, it can discharge your battery when you need it, import power you did not intend to, or leave the system in an unexpected state.
|
|
20
|
+
> - You use it at your own risk.
|
|
21
|
+
>
|
|
22
|
+
> **Getting help**
|
|
23
|
+
>
|
|
24
|
+
> - Do not contact FranklinWH support about this library. Report problems [here](https://github.com/david2069/franklinwh-modbus-async/issues).
|
|
25
|
+
|
|
26
|
+
## Requirements
|
|
27
|
+
|
|
28
|
+
- An aGate with **Modbus TCP enabled** (ask your installer), reachable on TCP port 502. A fixed IP and a wired LAN are recommended.
|
|
29
|
+
- **Python 3.12 or later.**
|
|
30
|
+
|
|
31
|
+
## Install
|
|
32
|
+
|
|
33
|
+
The library is not on PyPI yet. To work on it from source:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
git clone git@github.com:david2069/franklinwh-modbus-async.git
|
|
37
|
+
cd franklinwh-modbus-async
|
|
38
|
+
python3.12 -m venv .venv && source .venv/bin/activate
|
|
39
|
+
pip install -e ".[dev]"
|
|
40
|
+
pre-commit install
|
|
41
|
+
pytest
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Roadmap
|
|
45
|
+
|
|
46
|
+
| Phase | Scope | State |
|
|
47
|
+
|---|---|---|
|
|
48
|
+
| 0 | Scaffold, CI, PII guard, rationale docs | ✅ |
|
|
49
|
+
| 1 | Connection, SunSpec discovery, generated models, `FakeAGate` | — |
|
|
50
|
+
| 2 | Typed reads and snapshot | — |
|
|
51
|
+
| 3 | Control: M704 dispatch, watchdog, verify, native modes, safety | — |
|
|
52
|
+
| 4 | Virtual modes, TOU schedules, sequencer, discovery | — |
|
|
53
|
+
| 5 | CLI | — |
|
|
54
|
+
| 6 | Emulator and hardware verification | — |
|
|
55
|
+
| 7 | 1.0 release, migration guide | — |
|
|
56
|
+
|
|
57
|
+
## Documentation
|
|
58
|
+
|
|
59
|
+
| Page | |
|
|
60
|
+
|---|---|
|
|
61
|
+
| [Why this library](https://github.com/david2069/franklinwh-modbus-async/blob/main/docs/why.md) | Purpose, basis, why async, why modbus-connection, and how it compares with the alternatives |
|
|
62
|
+
| [modbus-connection findings](https://github.com/david2069/franklinwh-modbus-async/blob/main/docs/research/modbus-connection.md) | Notes from reviewing the transport library |
|
|
63
|
+
| [Plan](https://github.com/david2069/franklinwh-modbus-async/blob/main/PLAN.md) | Design decisions and phases |
|
|
64
|
+
| [Changelog](https://github.com/david2069/franklinwh-modbus-async/blob/main/CHANGELOG.md) | |
|
|
65
|
+
|
|
66
|
+
## Contributing
|
|
67
|
+
|
|
68
|
+
PRs are welcome. CI runs ruff, mypy `--strict`, pytest and a **PII guard** (`tools/pii_check.py`).
|
|
69
|
+
|
|
70
|
+
- **Placeholders only.** In code, docs, examples and captured output, use:
|
|
71
|
+
- `192.0.2.10` for hosts
|
|
72
|
+
- `FWH-SN-REDACTED` for serials
|
|
73
|
+
- `00:00:5E:00:53:00` for MAC addresses
|
|
74
|
+
- **Private terms.** Put anything you want blocked in a local `.pii-denylist`. git ignores this file.
|
|
75
|
+
|
|
76
|
+
## License
|
|
77
|
+
|
|
78
|
+
MIT. See [LICENSE](https://github.com/david2069/franklinwh-modbus-async/blob/main/LICENSE).
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.25"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "franklinwh-modbus-async"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Async FranklinWH aGate Modbus TCP library (SunSpec + extension registers) built on modbus-connection"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
authors = [{ name = "david2069", email = "david2069@users.noreply.github.com" }]
|
|
13
|
+
requires-python = ">=3.12"
|
|
14
|
+
dependencies = [
|
|
15
|
+
# Pinned tightly: modbus-connection releases roughly weekly and changes
|
|
16
|
+
# behaviour within minors. Bump deliberately, after the test suite passes.
|
|
17
|
+
"modbus-connection[tmodbus]~=4.12.4",
|
|
18
|
+
]
|
|
19
|
+
keywords = ["franklinwh", "agate", "battery", "modbus", "sunspec", "asyncio", "energy"]
|
|
20
|
+
classifiers = [
|
|
21
|
+
"Development Status :: 2 - Pre-Alpha",
|
|
22
|
+
"Framework :: AsyncIO",
|
|
23
|
+
"Intended Audience :: Developers",
|
|
24
|
+
"Programming Language :: Python :: 3",
|
|
25
|
+
"Programming Language :: Python :: 3.12",
|
|
26
|
+
"Programming Language :: Python :: 3.13",
|
|
27
|
+
"Topic :: Home Automation",
|
|
28
|
+
"Topic :: System :: Hardware",
|
|
29
|
+
"Typing :: Typed",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[project.optional-dependencies]
|
|
33
|
+
pymodbus = ["modbus-connection[pymodbus]~=4.12.4"]
|
|
34
|
+
dev = [
|
|
35
|
+
"pytest>=8.0",
|
|
36
|
+
"pytest-asyncio>=0.24",
|
|
37
|
+
"ruff>=0.6",
|
|
38
|
+
"mypy>=1.11",
|
|
39
|
+
"pre-commit>=3.7",
|
|
40
|
+
]
|
|
41
|
+
docs = ["mkdocs>=1.6,<2", "mkdocs-material>=9.5"]
|
|
42
|
+
|
|
43
|
+
[project.urls]
|
|
44
|
+
Homepage = "https://github.com/david2069/franklinwh-modbus-async"
|
|
45
|
+
"Bug Tracker" = "https://github.com/david2069/franklinwh-modbus-async/issues"
|
|
46
|
+
Changelog = "https://github.com/david2069/franklinwh-modbus-async/blob/main/CHANGELOG.md"
|
|
47
|
+
|
|
48
|
+
[tool.hatch.version]
|
|
49
|
+
path = "src/franklinwh_modbus_async/__init__.py"
|
|
50
|
+
|
|
51
|
+
[tool.hatch.build.targets.wheel]
|
|
52
|
+
packages = ["src/franklinwh_modbus_async"]
|
|
53
|
+
|
|
54
|
+
[tool.pytest.ini_options]
|
|
55
|
+
asyncio_mode = "auto"
|
|
56
|
+
asyncio_default_fixture_loop_scope = "function"
|
|
57
|
+
testpaths = ["tests"]
|
|
58
|
+
addopts = "--strict-markers"
|
|
59
|
+
markers = [
|
|
60
|
+
"hardware: needs a real aGate (deselect with -m 'not hardware')",
|
|
61
|
+
"destructive: writes to a real aGate; also needs --destructive-enabled",
|
|
62
|
+
"emulator: needs the gateway emulator",
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
[tool.ruff]
|
|
66
|
+
target-version = "py312"
|
|
67
|
+
line-length = 100
|
|
68
|
+
|
|
69
|
+
[tool.ruff.lint]
|
|
70
|
+
select = ["E", "F", "W", "I", "UP", "B", "SIM", "ASYNC", "RUF"]
|
|
71
|
+
|
|
72
|
+
[tool.mypy]
|
|
73
|
+
python_version = "3.12"
|
|
74
|
+
strict = true
|
|
75
|
+
files = ["src", "tools"]
|
|
76
|
+
|
|
77
|
+
[tool.hatch.build.targets.sdist]
|
|
78
|
+
# PyPI artifacts are public even while the repo is private: ship only what users need.
|
|
79
|
+
include = ["src/", "tests/", "tools/pii_check.py", "README.md", "LICENSE", "CHANGELOG.md", "pyproject.toml"]
|
|
File without changes
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import re
|
|
2
|
+
|
|
3
|
+
import franklinwh_modbus_async
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_version_is_pep440() -> None:
|
|
7
|
+
pattern = r"\d+\.\d+\.\d+(\.dev\d+|a\d+|b\d+|rc\d+)?"
|
|
8
|
+
assert re.fullmatch(pattern, franklinwh_modbus_async.__version__)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_transport_importable() -> None:
|
|
12
|
+
# The pinned transport and its default (tmodbus) backend must be installed.
|
|
13
|
+
from modbus_connection.tmodbus import ModbusConnection # noqa: F401
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Tests for tools/pii_check.py.
|
|
2
|
+
|
|
3
|
+
Bad samples are assembled at runtime so this file does not trip the guard itself.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import importlib.util
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
_spec = importlib.util.spec_from_file_location(
|
|
13
|
+
"pii_check", Path(__file__).parents[1] / "tools" / "pii_check.py"
|
|
14
|
+
)
|
|
15
|
+
assert _spec and _spec.loader
|
|
16
|
+
pii_check = importlib.util.module_from_spec(_spec)
|
|
17
|
+
sys.modules["pii_check"] = pii_check
|
|
18
|
+
_spec.loader.exec_module(pii_check)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def kinds(text: str, denylist: tuple[str, ...] = ()) -> list[str]:
|
|
22
|
+
return [f.kind for f in pii_check.scan_text("x", text, denylist)]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@pytest.mark.parametrize(
|
|
26
|
+
("text", "kind"),
|
|
27
|
+
[
|
|
28
|
+
("/Us" + "ers/alice/dev/repo", "home path"),
|
|
29
|
+
("/ho" + "me/bob/src", "home path"),
|
|
30
|
+
("C:" + "\\Users\\carol\\x", "home path"),
|
|
31
|
+
("SN " + "12345678" + "A02" + "B12345678", "serial number"),
|
|
32
|
+
("mac " + "AA:BB:CC" + ":12:34:56", "MAC address"),
|
|
33
|
+
("host " + "192.168" + ".0.50", "IP address"),
|
|
34
|
+
("mail " + "someone" + "@gmail.com", "email"),
|
|
35
|
+
],
|
|
36
|
+
)
|
|
37
|
+
def test_flags_pii(text: str, kind: str) -> None:
|
|
38
|
+
assert kinds(text) == [kind]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@pytest.mark.parametrize(
|
|
42
|
+
"text",
|
|
43
|
+
[
|
|
44
|
+
"host 192.0.2.10 port 502",
|
|
45
|
+
"mac 00:00:5E:00:53:00",
|
|
46
|
+
"loopback 127.0.0.1 and 0.0.0.0",
|
|
47
|
+
"david2069@users.noreply.github.com",
|
|
48
|
+
"serial FWH-SN-REDACTED",
|
|
49
|
+
"modbus-connection~=4.12.4 and version 0.0.1",
|
|
50
|
+
"the `/Users/` and `/home/` prefixes are blocked",
|
|
51
|
+
"x " + "192.168" + ".0.50 # pii: allow",
|
|
52
|
+
],
|
|
53
|
+
)
|
|
54
|
+
def test_allows_placeholders(text: str) -> None:
|
|
55
|
+
assert kinds(text) == []
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_denylist_is_case_insensitive_and_redacted() -> None:
|
|
59
|
+
findings = list(pii_check.scan_text("x", "Written by JANE Example", ["jane example"]))
|
|
60
|
+
assert [f.kind for f in findings] == ["denylisted term"]
|
|
61
|
+
assert findings[0].text == "<redacted>"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_denylist_from_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
65
|
+
(tmp_path / ".pii-denylist").write_text("# comment\nalpha\n\n", encoding="utf-8")
|
|
66
|
+
monkeypatch.setenv("PII_DENYLIST", "beta, gamma")
|
|
67
|
+
assert pii_check.load_denylist(tmp_path) == ["alpha", "beta", "gamma"]
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Fail if tracked files contain personal or site-identifying data.
|
|
3
|
+
|
|
4
|
+
Checks every file git would commit (tracked plus untracked-but-not-ignored) for:
|
|
5
|
+
|
|
6
|
+
- home-directory paths (macOS, Linux, Windows)
|
|
7
|
+
- FranklinWH aGate serial numbers (20 chars: 8 digits, letter, 2 digits, letter, 8 digits)
|
|
8
|
+
- MAC addresses other than the RFC 7042 documentation range
|
|
9
|
+
- IPv4 addresses other than loopback, unspecified and RFC 5737 documentation ranges
|
|
10
|
+
- email addresses other than noreply and example domains
|
|
11
|
+
- any term in the local, gitignored ``.pii-denylist`` (one per line, case-insensitive),
|
|
12
|
+
plus terms in the ``PII_DENYLIST`` environment variable (comma-separated, for CI secrets)
|
|
13
|
+
|
|
14
|
+
The denylist lives outside git so the things it blocks (for example a real name) are never
|
|
15
|
+
committed. A line can opt out with the marker ``pii: allow``; use it sparingly and only for
|
|
16
|
+
placeholders the patterns misjudge.
|
|
17
|
+
|
|
18
|
+
Exit status: 0 clean, 1 findings.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import ipaddress
|
|
24
|
+
import os
|
|
25
|
+
import re
|
|
26
|
+
import subprocess
|
|
27
|
+
import sys
|
|
28
|
+
from collections.abc import Iterable, Iterator
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
|
|
32
|
+
ALLOW_MARKER = "pii: allow"
|
|
33
|
+
DENYLIST_FILE = ".pii-denylist"
|
|
34
|
+
|
|
35
|
+
HOME_PATH = re.compile(r"(?:/Users/|/home/|[A-Za-z]:\\Users\\)[A-Za-z0-9._-]+")
|
|
36
|
+
SERIAL = re.compile(r"\b\d{8}[A-Z]\d{2}[A-Z]\d{8}\b")
|
|
37
|
+
MAC = re.compile(r"\b(?:[0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}\b")
|
|
38
|
+
IPV4 = re.compile(r"(?<![\d.])(?:\d{1,3}\.){3}\d{1,3}(?![\d.])")
|
|
39
|
+
EMAIL = re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}")
|
|
40
|
+
|
|
41
|
+
DOC_NETWORKS = tuple(
|
|
42
|
+
ipaddress.ip_network(n)
|
|
43
|
+
for n in ("192.0.2.0/24", "198.51.100.0/24", "203.0.113.0/24", "127.0.0.0/8", "0.0.0.0/32")
|
|
44
|
+
)
|
|
45
|
+
DOC_MAC_PREFIX = "00:00:5e:00:53"
|
|
46
|
+
ALLOWED_EMAIL_SUFFIXES = (
|
|
47
|
+
"@users.noreply.github.com",
|
|
48
|
+
"@noreply.anthropic.com",
|
|
49
|
+
"@example.com",
|
|
50
|
+
"@example.org",
|
|
51
|
+
"@example.net",
|
|
52
|
+
"@github.com",
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class Finding:
|
|
58
|
+
path: str
|
|
59
|
+
line: int
|
|
60
|
+
kind: str
|
|
61
|
+
text: str
|
|
62
|
+
|
|
63
|
+
def __str__(self) -> str:
|
|
64
|
+
return f"{self.path}:{self.line}: {self.kind}: {self.text}"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _ip_allowed(text: str) -> bool:
|
|
68
|
+
try:
|
|
69
|
+
ip = ipaddress.ip_address(text)
|
|
70
|
+
except ValueError:
|
|
71
|
+
return True # not an address (e.g. 999.1.1.1): not our concern
|
|
72
|
+
return any(ip in net for net in DOC_NETWORKS)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def scan_text(path: str, text: str, denylist: Iterable[str] = ()) -> Iterator[Finding]:
|
|
76
|
+
terms = [t.lower() for t in denylist if t]
|
|
77
|
+
for lineno, line in enumerate(text.splitlines(), start=1):
|
|
78
|
+
if ALLOW_MARKER in line:
|
|
79
|
+
continue
|
|
80
|
+
for m in HOME_PATH.finditer(line):
|
|
81
|
+
yield Finding(path, lineno, "home path", m.group())
|
|
82
|
+
for m in SERIAL.finditer(line):
|
|
83
|
+
yield Finding(path, lineno, "serial number", m.group())
|
|
84
|
+
for m in MAC.finditer(line):
|
|
85
|
+
if not m.group().lower().replace("-", ":").startswith(DOC_MAC_PREFIX):
|
|
86
|
+
yield Finding(path, lineno, "MAC address", m.group())
|
|
87
|
+
for m in IPV4.finditer(line):
|
|
88
|
+
if not _ip_allowed(m.group()):
|
|
89
|
+
yield Finding(path, lineno, "IP address", m.group())
|
|
90
|
+
for m in EMAIL.finditer(line):
|
|
91
|
+
if not m.group().lower().endswith(ALLOWED_EMAIL_SUFFIXES):
|
|
92
|
+
yield Finding(path, lineno, "email", m.group())
|
|
93
|
+
lowered = line.lower()
|
|
94
|
+
for term in terms:
|
|
95
|
+
if term in lowered:
|
|
96
|
+
yield Finding(path, lineno, "denylisted term", "<redacted>")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def load_denylist(root: Path) -> list[str]:
|
|
100
|
+
terms: list[str] = []
|
|
101
|
+
f = root / DENYLIST_FILE
|
|
102
|
+
if f.is_file():
|
|
103
|
+
for raw in f.read_text(encoding="utf-8").splitlines():
|
|
104
|
+
term = raw.strip()
|
|
105
|
+
if term and not term.startswith("#"):
|
|
106
|
+
terms.append(term)
|
|
107
|
+
terms += [t.strip() for t in os.environ.get("PII_DENYLIST", "").split(",") if t.strip()]
|
|
108
|
+
return terms
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def candidate_files(root: Path) -> list[Path]:
|
|
112
|
+
out = subprocess.run(
|
|
113
|
+
["git", "ls-files", "-z", "--cached", "--others", "--exclude-standard"],
|
|
114
|
+
cwd=root,
|
|
115
|
+
check=True,
|
|
116
|
+
capture_output=True,
|
|
117
|
+
).stdout
|
|
118
|
+
return [root / p for p in out.decode().split("\0") if p]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def main(argv: list[str] | None = None) -> int:
|
|
122
|
+
root = Path(argv[0]) if argv else Path.cwd()
|
|
123
|
+
denylist = load_denylist(root)
|
|
124
|
+
findings: list[Finding] = []
|
|
125
|
+
for file in candidate_files(root):
|
|
126
|
+
if not file.is_file():
|
|
127
|
+
continue # deleted in the working tree
|
|
128
|
+
try:
|
|
129
|
+
text = file.read_text(encoding="utf-8")
|
|
130
|
+
except UnicodeDecodeError:
|
|
131
|
+
continue # binary; images need a manual check
|
|
132
|
+
findings.extend(scan_text(str(file.relative_to(root)), text, denylist))
|
|
133
|
+
for f in findings:
|
|
134
|
+
print(f)
|
|
135
|
+
if findings:
|
|
136
|
+
print(
|
|
137
|
+
f"\npii_check: {len(findings)} finding(s). Replace with placeholders "
|
|
138
|
+
"(192.0.2.10, FWH-SN-REDACTED, 00:00:5E:00:53:00).",
|
|
139
|
+
file=sys.stderr,
|
|
140
|
+
)
|
|
141
|
+
return 1
|
|
142
|
+
print(f"pii_check: clean ({'with' if denylist else 'no'} denylist)")
|
|
143
|
+
return 0
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
if __name__ == "__main__":
|
|
147
|
+
sys.exit(main(sys.argv[1:]))
|