eswater 0.1.0__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.
- eswater-0.1.0/.gitignore +27 -0
- eswater-0.1.0/CHANGELOG.md +26 -0
- eswater-0.1.0/LICENSE +21 -0
- eswater-0.1.0/PKG-INFO +134 -0
- eswater-0.1.0/README.md +103 -0
- eswater-0.1.0/docs/api.md +238 -0
- eswater-0.1.0/pyproject.toml +93 -0
- eswater-0.1.0/src/eswater/__init__.py +28 -0
- eswater-0.1.0/src/eswater/__main__.py +108 -0
- eswater-0.1.0/src/eswater/client.py +358 -0
- eswater-0.1.0/src/eswater/const.py +42 -0
- eswater-0.1.0/src/eswater/exceptions.py +28 -0
- eswater-0.1.0/src/eswater/models.py +132 -0
- eswater-0.1.0/src/eswater/py.typed +0 -0
- eswater-0.1.0/tests/conftest.py +49 -0
- eswater-0.1.0/tests/fixtures/account_details.json +29 -0
- eswater-0.1.0/tests/fixtures/account_summary.json +14 -0
- eswater-0.1.0/tests/fixtures/hourly_usage.json +5 -0
- eswater-0.1.0/tests/test_cli.py +131 -0
- eswater-0.1.0/tests/test_client.py +486 -0
- eswater-0.1.0/tests/test_models.py +81 -0
eswater-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.egg-info/
|
|
5
|
+
.eggs/
|
|
6
|
+
build/
|
|
7
|
+
dist/
|
|
8
|
+
.venv/
|
|
9
|
+
venv/
|
|
10
|
+
|
|
11
|
+
# Tooling
|
|
12
|
+
.mypy_cache/
|
|
13
|
+
.ruff_cache/
|
|
14
|
+
.pytest_cache/
|
|
15
|
+
.coverage
|
|
16
|
+
htmlcov/
|
|
17
|
+
|
|
18
|
+
# Editors / OS
|
|
19
|
+
.vscode/
|
|
20
|
+
.idea/
|
|
21
|
+
.DS_Store
|
|
22
|
+
|
|
23
|
+
# Secrets — never commit captured tokens or real credentials
|
|
24
|
+
*.env
|
|
25
|
+
.env
|
|
26
|
+
secrets.*
|
|
27
|
+
captures/
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here. Format loosely follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/); versioning is [SemVer](https://semver.org/).
|
|
5
|
+
|
|
6
|
+
## [0.1.0] - 2026-07-22
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
- Initial project scaffold: `ESWaterClient`, data models, typed exceptions, CLI,
|
|
10
|
+
test harness, and CI.
|
|
11
|
+
- Reverse-engineered the ESW/NWG portal API and documented it in `docs/api.md`.
|
|
12
|
+
- Implemented cookie-based auth (`/api/Auth/Login` → `userProfile` cookie →
|
|
13
|
+
`GetSmartAuthToken` JWT), account/meter discovery, and usage retrieval
|
|
14
|
+
(`get_usage`/`get_meter_usage`) with litres + cost parsing and automatic JWT
|
|
15
|
+
refresh.
|
|
16
|
+
- Tests parsing captured (redacted) fixtures; 100% line coverage, coverage
|
|
17
|
+
gate in CI.
|
|
18
|
+
- **Verified end-to-end against the live API.** Corrected auth to read tokens
|
|
19
|
+
from the login response *body* (not a cookie), and to run the required
|
|
20
|
+
`SaveUserProfile` → `GetAccountSummary` sequence before `GetSmartAuthToken`
|
|
21
|
+
(usage endpoints 401 otherwise). Smart-auth refresh token now rotates
|
|
22
|
+
(single-use) on each refresh. CLI auto-loads `.env`.
|
|
23
|
+
|
|
24
|
+
### TODO
|
|
25
|
+
- Confirm `ReadingType` 0 vs 1 semantics and whether usage accepts date ranges.
|
|
26
|
+
- Tariff endpoint (`get_tariff`) not yet captured/implemented.
|
eswater-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 mikezs
|
|
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.
|
eswater-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: eswater
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Async Python client for Essex & Suffolk Water (Northumbrian Water Group) smart water meter data.
|
|
5
|
+
Project-URL: Homepage, https://github.com/mikezs/python-eswater
|
|
6
|
+
Project-URL: Repository, https://github.com/mikezs/python-eswater
|
|
7
|
+
Project-URL: Issues, https://github.com/mikezs/python-eswater/issues
|
|
8
|
+
Author: mikezs
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: essex,home-assistant,nwg,smart-meter,suffolk,water
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Framework :: AsyncIO
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Home Automation
|
|
21
|
+
Classifier: Typing :: Typed
|
|
22
|
+
Requires-Python: >=3.11
|
|
23
|
+
Requires-Dist: aiohttp>=3.9
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: mypy>=1.10; extra == 'dev'
|
|
26
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
27
|
+
Requires-Dist: pytest-cov>=5.0; extra == 'dev'
|
|
28
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
29
|
+
Requires-Dist: ruff>=0.5; extra == 'dev'
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
# eswater
|
|
33
|
+
|
|
34
|
+
Async Python client for **Essex & Suffolk Water** (Northumbrian Water Group)
|
|
35
|
+
smart water meter data.
|
|
36
|
+
|
|
37
|
+
Built to be consumed by a Home Assistant custom integration (see the companion
|
|
38
|
+
`ha-essex-suffolk-water` repo), but usable standalone.
|
|
39
|
+
|
|
40
|
+
> ⚠️ **Alpha.** The ESW/NWG API is private and undocumented; this library was
|
|
41
|
+
> built by reverse-engineering the web portal — see [`docs/api.md`](docs/api.md).
|
|
42
|
+
> Auth and usage retrieval are implemented and verified end-to-end against the
|
|
43
|
+
> live API, but the API is unofficial and may change or break without notice.
|
|
44
|
+
|
|
45
|
+
## Install
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
pip install eswater # once published
|
|
49
|
+
# or, for development:
|
|
50
|
+
pip install -e ".[dev]"
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Usage
|
|
54
|
+
|
|
55
|
+
Log in with the **email + password** of your ESW online account.
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
import asyncio
|
|
59
|
+
from datetime import datetime
|
|
60
|
+
|
|
61
|
+
import aiohttp
|
|
62
|
+
from eswater import ESWaterClient, Granularity
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
async def main() -> None:
|
|
66
|
+
async with aiohttp.ClientSession() as session:
|
|
67
|
+
client = ESWaterClient(session, "you@example.com", "your-password")
|
|
68
|
+
await client.authenticate()
|
|
69
|
+
|
|
70
|
+
meter = (await client.get_meters())[0]
|
|
71
|
+
# Hourly for a specific day (returns ~24 readings):
|
|
72
|
+
usage = await client.get_usage(
|
|
73
|
+
meter.account_id, meter.serial, datetime(2026, 7, 18), Granularity.HOURLY
|
|
74
|
+
)
|
|
75
|
+
for reading in usage:
|
|
76
|
+
print(reading.timestamp, reading.consumption_litres, "L", reading.cost, "£")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
asyncio.run(main())
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Consumption is in **litres** and cost in **GBP**, both provided by the API.
|
|
83
|
+
The smart-usage JWT (~10 min) is refreshed automatically; the login session
|
|
84
|
+
(~1 hour) is re-established on expiry.
|
|
85
|
+
|
|
86
|
+
> **Timestamps are naive local (UK) time**, exactly as the API returns them —
|
|
87
|
+
> `UsageReading.timestamp` carries no `tzinfo`. Convert to your own zone before
|
|
88
|
+
> use (e.g. attach `ZoneInfo("Europe/London")`); don't assume UTC, or readings
|
|
89
|
+
> will be off by up to an hour across BST/GMT.
|
|
90
|
+
|
|
91
|
+
### CLI
|
|
92
|
+
|
|
93
|
+
Handy for debugging against the live API without Home Assistant:
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
export ESW_USERNAME=you@example.com ESW_PASSWORD='…'
|
|
97
|
+
eswater meters
|
|
98
|
+
eswater usage --account-id 3799250100 --meter-serial 24LU083349 \
|
|
99
|
+
--start 2026-07-18 --granularity hourly
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Public API
|
|
103
|
+
|
|
104
|
+
| Method | Description |
|
|
105
|
+
| --- | --- |
|
|
106
|
+
| `authenticate()` | Log in (cookie session) and fetch the first smart JWT |
|
|
107
|
+
| `refresh()` | Re-fetch the short-lived smart-usage JWT (usually automatic) |
|
|
108
|
+
| `get_accounts()` | List billing accounts, each populated with meters |
|
|
109
|
+
| `get_meters()` | List all smart meters (each has `account_id` + `serial`) |
|
|
110
|
+
| `get_usage(account_id, serial, start_date, granularity)` | Readings (litres + £) |
|
|
111
|
+
| `get_meter_usage(meter, start_date, granularity)` | Same, taking a `Meter` |
|
|
112
|
+
| `get_latest_reading(account_id, serial)` | Most recent daily reading |
|
|
113
|
+
|
|
114
|
+
Errors are typed: `InvalidAuth`, `NotAuthenticated`, `ServiceUnavailable`,
|
|
115
|
+
`ApiError` (all subclass `ESWaterError`).
|
|
116
|
+
|
|
117
|
+
## Development
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
pip install -e ".[dev]"
|
|
121
|
+
ruff check .
|
|
122
|
+
mypy src
|
|
123
|
+
pytest
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## Disclaimer
|
|
127
|
+
|
|
128
|
+
Not affiliated with or endorsed by Essex & Suffolk Water or Northumbrian Water
|
|
129
|
+
Group. Uses a private API that may change or restrict access at any time. For
|
|
130
|
+
personal use with your own account.
|
|
131
|
+
|
|
132
|
+
## License
|
|
133
|
+
|
|
134
|
+
MIT
|
eswater-0.1.0/README.md
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# eswater
|
|
2
|
+
|
|
3
|
+
Async Python client for **Essex & Suffolk Water** (Northumbrian Water Group)
|
|
4
|
+
smart water meter data.
|
|
5
|
+
|
|
6
|
+
Built to be consumed by a Home Assistant custom integration (see the companion
|
|
7
|
+
`ha-essex-suffolk-water` repo), but usable standalone.
|
|
8
|
+
|
|
9
|
+
> ⚠️ **Alpha.** The ESW/NWG API is private and undocumented; this library was
|
|
10
|
+
> built by reverse-engineering the web portal — see [`docs/api.md`](docs/api.md).
|
|
11
|
+
> Auth and usage retrieval are implemented and verified end-to-end against the
|
|
12
|
+
> live API, but the API is unofficial and may change or break without notice.
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pip install eswater # once published
|
|
18
|
+
# or, for development:
|
|
19
|
+
pip install -e ".[dev]"
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Usage
|
|
23
|
+
|
|
24
|
+
Log in with the **email + password** of your ESW online account.
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
import asyncio
|
|
28
|
+
from datetime import datetime
|
|
29
|
+
|
|
30
|
+
import aiohttp
|
|
31
|
+
from eswater import ESWaterClient, Granularity
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
async def main() -> None:
|
|
35
|
+
async with aiohttp.ClientSession() as session:
|
|
36
|
+
client = ESWaterClient(session, "you@example.com", "your-password")
|
|
37
|
+
await client.authenticate()
|
|
38
|
+
|
|
39
|
+
meter = (await client.get_meters())[0]
|
|
40
|
+
# Hourly for a specific day (returns ~24 readings):
|
|
41
|
+
usage = await client.get_usage(
|
|
42
|
+
meter.account_id, meter.serial, datetime(2026, 7, 18), Granularity.HOURLY
|
|
43
|
+
)
|
|
44
|
+
for reading in usage:
|
|
45
|
+
print(reading.timestamp, reading.consumption_litres, "L", reading.cost, "£")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
asyncio.run(main())
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Consumption is in **litres** and cost in **GBP**, both provided by the API.
|
|
52
|
+
The smart-usage JWT (~10 min) is refreshed automatically; the login session
|
|
53
|
+
(~1 hour) is re-established on expiry.
|
|
54
|
+
|
|
55
|
+
> **Timestamps are naive local (UK) time**, exactly as the API returns them —
|
|
56
|
+
> `UsageReading.timestamp` carries no `tzinfo`. Convert to your own zone before
|
|
57
|
+
> use (e.g. attach `ZoneInfo("Europe/London")`); don't assume UTC, or readings
|
|
58
|
+
> will be off by up to an hour across BST/GMT.
|
|
59
|
+
|
|
60
|
+
### CLI
|
|
61
|
+
|
|
62
|
+
Handy for debugging against the live API without Home Assistant:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
export ESW_USERNAME=you@example.com ESW_PASSWORD='…'
|
|
66
|
+
eswater meters
|
|
67
|
+
eswater usage --account-id 3799250100 --meter-serial 24LU083349 \
|
|
68
|
+
--start 2026-07-18 --granularity hourly
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Public API
|
|
72
|
+
|
|
73
|
+
| Method | Description |
|
|
74
|
+
| --- | --- |
|
|
75
|
+
| `authenticate()` | Log in (cookie session) and fetch the first smart JWT |
|
|
76
|
+
| `refresh()` | Re-fetch the short-lived smart-usage JWT (usually automatic) |
|
|
77
|
+
| `get_accounts()` | List billing accounts, each populated with meters |
|
|
78
|
+
| `get_meters()` | List all smart meters (each has `account_id` + `serial`) |
|
|
79
|
+
| `get_usage(account_id, serial, start_date, granularity)` | Readings (litres + £) |
|
|
80
|
+
| `get_meter_usage(meter, start_date, granularity)` | Same, taking a `Meter` |
|
|
81
|
+
| `get_latest_reading(account_id, serial)` | Most recent daily reading |
|
|
82
|
+
|
|
83
|
+
Errors are typed: `InvalidAuth`, `NotAuthenticated`, `ServiceUnavailable`,
|
|
84
|
+
`ApiError` (all subclass `ESWaterError`).
|
|
85
|
+
|
|
86
|
+
## Development
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
pip install -e ".[dev]"
|
|
90
|
+
ruff check .
|
|
91
|
+
mypy src
|
|
92
|
+
pytest
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Disclaimer
|
|
96
|
+
|
|
97
|
+
Not affiliated with or endorsed by Essex & Suffolk Water or Northumbrian Water
|
|
98
|
+
Group. Uses a private API that may change or restrict access at any time. For
|
|
99
|
+
personal use with your own account.
|
|
100
|
+
|
|
101
|
+
## License
|
|
102
|
+
|
|
103
|
+
MIT
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
# Essex & Suffolk Water (NWG) API — reverse-engineered spec
|
|
2
|
+
|
|
3
|
+
> Captured 22 Jul 2026 from the web portal at `https://www.eswater.co.uk`
|
|
4
|
+
> (logged-in "My account" → My Usage → Smart Meter). All personal values below
|
|
5
|
+
> are **redacted** with `<PLACEHOLDERS>`.
|
|
6
|
+
|
|
7
|
+
Essex & Suffolk Water is part of **Northumbrian Water Group (NWG)**. The portal
|
|
8
|
+
is a same-origin ASP.NET Core app fronted by **Cloudflare**; the JSON API lives
|
|
9
|
+
under `https://www.eswater.co.uk/api/`. Identity is handled by **LoginRadius**
|
|
10
|
+
(CIAM); smart-usage endpoints are gated by a LoginRadius-issued JWT.
|
|
11
|
+
|
|
12
|
+
## Auth model (two layers)
|
|
13
|
+
|
|
14
|
+
> **Verified end-to-end against the live API (22 Jul 2026).** The notes below
|
|
15
|
+
> are what actually works, not just what the browser appeared to do.
|
|
16
|
+
|
|
17
|
+
1. **Portal session** — `POST /api/Auth/Login` with `{email, password}` returns
|
|
18
|
+
the profile **in the response body** and sets cookies:
|
|
19
|
+
- `.AspNetCore.Session` — **the only cookie that matters for auth**; the
|
|
20
|
+
usage endpoints authorise off this server-side session (confirmed by
|
|
21
|
+
leave-one-out cookie bisection).
|
|
22
|
+
- `.AspNetCore.Antiforgery.*`, `ARRAffinity*`, `EPiStateMarker` (not needed).
|
|
23
|
+
- The browser *also* sets `userProfile` / `smartTokenInfo` /
|
|
24
|
+
`smartUserTokenInfo` cookies **client-side via JavaScript** — these are
|
|
25
|
+
**not** required by the API and the library does not set them.
|
|
26
|
+
|
|
27
|
+
The login **response body** carries the LoginRadius tokens the server
|
|
28
|
+
obtained on our behalf (this is the source of truth, not the cookie):
|
|
29
|
+
```json
|
|
30
|
+
{
|
|
31
|
+
"RestException": null, "OtherException": null,
|
|
32
|
+
"Response": {
|
|
33
|
+
"access_token": "<uuid>",
|
|
34
|
+
"refresh_token": "<uuid>", // seed for GetSmartAuthToken
|
|
35
|
+
"expires_in": "2026-07-22T16:02:45Z", // ~1 hour
|
|
36
|
+
"Profile": { "CustomFields": { "PersonId": "PersonId" } }
|
|
37
|
+
/* + Email, FirstName, Surname, Roles … (PII — do not log/persist) */
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
```
|
|
41
|
+
A cookie jar is mandatory (carries `.AspNetCore.Session`).
|
|
42
|
+
|
|
43
|
+
2. **Smart JWT** — usage endpoints require a short-lived JWT from
|
|
44
|
+
`POST /api/Customer/GetSmartAuthToken`, sent in the usage request **body** as
|
|
45
|
+
`Authorization`. **But the JWT alone is not sufficient** — see the required
|
|
46
|
+
sequence below.
|
|
47
|
+
|
|
48
|
+
### Required login sequence (order matters!)
|
|
49
|
+
The usage endpoints return **401** unless the session is set up in this exact
|
|
50
|
+
order *before* fetching the smart token:
|
|
51
|
+
|
|
52
|
+
1. `POST /api/Auth/Login {email, password}` → read `Response.refresh_token`.
|
|
53
|
+
2. `POST /api/Auth/SaveUserProfile` with the whole `Response` object — registers
|
|
54
|
+
the profile in the session. Without it, `GetAccountSummary` returns
|
|
55
|
+
`{"statusField": {"codeField": 400, "messageField": "PersonId is required"}}`.
|
|
56
|
+
3. `GET /api/Customer/GetAccountSummary?personId=PersonId` — **binds the account
|
|
57
|
+
to the session**. This is the non-obvious step: skipping it makes usage 401
|
|
58
|
+
even with a valid JWT (`AddOrUpdateCustomerSession` does *not* substitute).
|
|
59
|
+
4. `POST /api/Customer/GetSmartAuthToken {refresh_Token}` → `Id_token` (JWT).
|
|
60
|
+
5. Usage calls now succeed.
|
|
61
|
+
|
|
62
|
+
No LoginRadius apikey and **no call to `api.loginradius.com`** is needed — the
|
|
63
|
+
server does that internally. (The JWT is LoginRadius-issued:
|
|
64
|
+
`iss = cloud-api.loginradius.com/sso/oidc/northumbrianwater`,
|
|
65
|
+
`aud = f62c1509-d0c0-4ebf-8f0d-ed2e71d5c3a3`, `PersonId`, **~10 min** lifetime.)
|
|
66
|
+
|
|
67
|
+
### Token refresh & rotation (verified)
|
|
68
|
+
- The `GetSmartAuthToken` **refresh token is single-use** and **rotates**: each
|
|
69
|
+
call returns a new `Refresh_token`; reusing a spent one returns `null`. Store
|
|
70
|
+
the returned `Refresh_token` for the next refresh.
|
|
71
|
+
- Within a live session, a repeat `GetSmartAuthToken` (using the rotated token)
|
|
72
|
+
works **without** re-running steps 1–3, and its JWT authorises usage.
|
|
73
|
+
- Lifetimes: login `refresh_token` ~1 hour (re-login after); JWT ~10 min
|
|
74
|
+
(re-fetch before expiry).
|
|
75
|
+
|
|
76
|
+
## Common request conventions
|
|
77
|
+
- `Content-Type: application/json`, `Accept: */*`
|
|
78
|
+
- `X-Requested-With: XMLHttpRequest` (send this — likely enforced)
|
|
79
|
+
- Cookies from step 1 on every call
|
|
80
|
+
- Bodies are JSON; some responses are `text/plain` (e.g. token)
|
|
81
|
+
|
|
82
|
+
---
|
|
83
|
+
|
|
84
|
+
## Endpoints
|
|
85
|
+
|
|
86
|
+
### `POST /api/Auth/Login`
|
|
87
|
+
Request:
|
|
88
|
+
```json
|
|
89
|
+
{ "email": "<EMAIL>", "password": "<PASSWORD>" }
|
|
90
|
+
```
|
|
91
|
+
Response: empty body; sets session cookies.
|
|
92
|
+
|
|
93
|
+
### `GET /api/Customer/GetAccountSummary?personId=<PERSON_ID>&_=<ts>`
|
|
94
|
+
Account/premise discovery entry point.
|
|
95
|
+
```json
|
|
96
|
+
{
|
|
97
|
+
"Message": "",
|
|
98
|
+
"Accounts": [
|
|
99
|
+
{
|
|
100
|
+
"AccountID": "<ACCOUNT_ID>",
|
|
101
|
+
"PremiseID": "<PREMISE_ID>",
|
|
102
|
+
"PropertyAddress": "<ADDRESS>",
|
|
103
|
+
"AccountType": 1,
|
|
104
|
+
"AccountBalance": -27.19,
|
|
105
|
+
"MultiplePremises": false
|
|
106
|
+
}
|
|
107
|
+
],
|
|
108
|
+
"Status": { "Code": "0", "Message": "<p>Success</p>" }
|
|
109
|
+
}
|
|
110
|
+
```
|
|
111
|
+
> Note: the portal often sends `personId=PersonId` literally (a placeholder);
|
|
112
|
+
> the server resolves the person from the session cookie.
|
|
113
|
+
|
|
114
|
+
### `POST /api/Customer/GetAccountDetails` ← meter discovery
|
|
115
|
+
Request:
|
|
116
|
+
```json
|
|
117
|
+
{ "AccountId": "<ACCOUNT_ID>", "PremiseId": "<PREMISE_ID>", "PersonId": "PersonId" }
|
|
118
|
+
```
|
|
119
|
+
Response (trimmed to the useful bits): `AccountDetail.Meters[]` holds the meter,
|
|
120
|
+
where **`BadgeNumber` is the `MeterSerial`** used by the usage calls:
|
|
121
|
+
```json
|
|
122
|
+
{
|
|
123
|
+
"AccountDetail": {
|
|
124
|
+
"Account": { "SmartMeter": true, "StartDate": "2017-01-30T00:00:00",
|
|
125
|
+
"NumberOfOccupiers": 4, "LastBillAmount": 254.81 },
|
|
126
|
+
"Meters": [
|
|
127
|
+
{
|
|
128
|
+
"BadgeNumber": "<METER_SERIAL>",
|
|
129
|
+
"smartPoint": "<SMART_POINT>",
|
|
130
|
+
"smartStatus": "COMM",
|
|
131
|
+
"meterInstalledDate": "2024-03-20T00:00:00",
|
|
132
|
+
"LastRead": "231.000000",
|
|
133
|
+
"LastReadDate": "2026-07-14T00:00:00",
|
|
134
|
+
"NumberDials": 5
|
|
135
|
+
}
|
|
136
|
+
],
|
|
137
|
+
"Premise": { "PropertyAddress": "<ADDRESS>", "Measured": true },
|
|
138
|
+
"AccountId": "<ACCOUNT_ID>", "PremiseId": "<PREMISE_ID>", "PersonId": "PersonId"
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
```
|
|
142
|
+
> Also contains `Person` (name, DOB, email, phone) and `Payment` blocks — ignore
|
|
143
|
+
> for usage; do not persist PII.
|
|
144
|
+
|
|
145
|
+
### `POST /api/Customer/GetSmartAuthToken` ← smart JWT
|
|
146
|
+
Request:
|
|
147
|
+
```json
|
|
148
|
+
{ "refresh_Token": "<REFRESH_TOKEN>" }
|
|
149
|
+
```
|
|
150
|
+
Response (`text/plain` JSON):
|
|
151
|
+
```json
|
|
152
|
+
{
|
|
153
|
+
"Access_token": "<uuid>",
|
|
154
|
+
"Token_type": "Bearer",
|
|
155
|
+
"Refresh_token": "<uuid>",
|
|
156
|
+
"Expires_in": 3598,
|
|
157
|
+
"Id_token": "<JWT>",
|
|
158
|
+
"Status": null
|
|
159
|
+
}
|
|
160
|
+
```
|
|
161
|
+
`Id_token` (the JWT) is what usage calls put in their `Authorization` field.
|
|
162
|
+
Cache it and re-fetch when within ~30s of the 10-min expiry.
|
|
163
|
+
|
|
164
|
+
### Usage — `POST /api/Customer/Get{Hourly,Daily,Weekly,Monthly,Yearly}WaterUsage`
|
|
165
|
+
All five share the same request/response contract; only aggregation differs.
|
|
166
|
+
|
|
167
|
+
Request:
|
|
168
|
+
```json
|
|
169
|
+
{
|
|
170
|
+
"AccountId": "<ACCOUNT_ID>",
|
|
171
|
+
"Authorization": "<JWT from GetSmartAuthToken>",
|
|
172
|
+
"MeterSerial": "<METER_SERIAL>",
|
|
173
|
+
"StartDate": "2026-07-18T00:00:00"
|
|
174
|
+
}
|
|
175
|
+
```
|
|
176
|
+
Response — array of readings:
|
|
177
|
+
```json
|
|
178
|
+
[
|
|
179
|
+
{ "Date": "2026-07-18T01:00:00", "LitreValue": 4, "MonetaryValue": 0.02,
|
|
180
|
+
"Key": "12:00\nam|1:00\nam", "ReadingType": 1, "Status": null }
|
|
181
|
+
]
|
|
182
|
+
```
|
|
183
|
+
Field semantics:
|
|
184
|
+
- **`LitreValue`** — consumption in **litres** for the interval (integer).
|
|
185
|
+
- **`MonetaryValue`** — cost in **£** for the interval.
|
|
186
|
+
- **`Date`** — interval timestamp (local, no tz). For hourly, `Date` is the
|
|
187
|
+
**end** of the hour (00:00→01:00 row has `Date=01:00`).
|
|
188
|
+
- **`Key`** — display label (`\n`-separated); ignore for data purposes.
|
|
189
|
+
- **`ReadingType`** — `1` vs `0`. Observed: older intervals = `1`, most recent
|
|
190
|
+
1–3 days = `0`. Best guess: `1 = actual/confirmed`, `0 = provisional/estimated`.
|
|
191
|
+
_TODO: confirm._
|
|
192
|
+
- **`Status`** — null in all samples.
|
|
193
|
+
|
|
194
|
+
Observed windowing behaviour:
|
|
195
|
+
- **Hourly**: `StartDate` = the target day → returns ~24 rows for that day.
|
|
196
|
+
- **Daily**: returned the most recent **7 days** (rolling), despite `StartDate`
|
|
197
|
+
being the account start date. _TODO: confirm whether a narrower `StartDate`
|
|
198
|
+
or an `EndDate` field narrows/extends it._
|
|
199
|
+
- **Weekly / Monthly / Yearly**: same shape, coarser buckets (not re-captured;
|
|
200
|
+
assume identical contract). _TODO: confirm max range + whether `EndDate` is
|
|
201
|
+
supported._
|
|
202
|
+
|
|
203
|
+
### Other endpoints seen (not needed for core usage)
|
|
204
|
+
- `POST /api/Customer/GetMeterReadHistory` — manual/actual meter reads
|
|
205
|
+
- `POST /api/Customer/GetWaterUsageEfficiency`
|
|
206
|
+
- `POST /api/Customer/GetSmartMeterAlertStatus` — leak/alert status
|
|
207
|
+
- `GET /api/Customer/GetUsageComparison?noOfOccupiers=&lastYearAvgUsage=`
|
|
208
|
+
- `POST /api/Customer/AddOrUpdateCustomerSession` — body `["PersonId:…","AccountId:…"]` → `true`
|
|
209
|
+
- `POST /api/Auth/SaveUserProfile` — empty response
|
|
210
|
+
- `POST /api/BillsPayments/GetBillsPayments`, `GET /api/BillsPayments/GetPaymentPlan?accountId=`
|
|
211
|
+
|
|
212
|
+
### Minimal library flow (verified)
|
|
213
|
+
1. `POST /api/Auth/Login` → read `Response.refresh_token` from the body
|
|
214
|
+
2. `POST /api/Auth/SaveUserProfile` (whole `Response` object)
|
|
215
|
+
3. `GET /api/Customer/GetAccountSummary?personId=PersonId` (binds session)
|
|
216
|
+
4. `POST /api/Customer/GetSmartAuthToken {refresh_Token}` → JWT (+ rotated token)
|
|
217
|
+
5. `POST /api/Customer/GetAccountDetails` → meter `BadgeNumber` (serial)
|
|
218
|
+
6. `POST /api/Customer/Get…WaterUsage` (JWT in body + serial) → readings
|
|
219
|
+
|
|
220
|
+
`AddOrUpdateCustomerSession` and `GetAccountDetails` order-relative-to-token do
|
|
221
|
+
not matter for auth; only steps 2 and 3 must precede step 4.
|
|
222
|
+
|
|
223
|
+
## Gotchas
|
|
224
|
+
- Cloudflare + reCAPTCHA sit on the **login page**; the LoginRadius API login
|
|
225
|
+
may avoid the browser reCAPTCHA, but watch for challenges / rate limits.
|
|
226
|
+
- Send `X-Requested-With: XMLHttpRequest`.
|
|
227
|
+
- JWT lifetime ~10 min — refresh proactively; the portal session cookie lasts
|
|
228
|
+
longer (re-login on 401).
|
|
229
|
+
- `LitreValue` is already litres — no unit conversion needed.
|
|
230
|
+
|
|
231
|
+
## Still TODO (minor; needs another short capture / test)
|
|
232
|
+
1. `ReadingType` 0 vs 1 meaning (guess: 1 = actual/confirmed, 0 = provisional).
|
|
233
|
+
2. Whether usage endpoints accept an `EndDate` / arbitrary historical ranges
|
|
234
|
+
(matters for HA long-term-statistics backfill).
|
|
235
|
+
3. Weekly/Monthly/Yearly `StartDate` windowing (assumed same contract as daily).
|
|
236
|
+
|
|
237
|
+
_Resolved: auth needs no LoginRadius apikey — the seed refresh_token is in the
|
|
238
|
+
`userProfile` cookie from `/api/Auth/Login`._
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "eswater"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Async Python client for Essex & Suffolk Water (Northumbrian Water Group) smart water meter data."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "mikezs" }]
|
|
14
|
+
keywords = ["essex", "suffolk", "water", "smart-meter", "home-assistant", "nwg"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 3 - Alpha",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Framework :: AsyncIO",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Programming Language :: Python :: 3.13",
|
|
23
|
+
"Operating System :: OS Independent",
|
|
24
|
+
"Topic :: Home Automation",
|
|
25
|
+
"Typing :: Typed",
|
|
26
|
+
]
|
|
27
|
+
dependencies = [
|
|
28
|
+
"aiohttp>=3.9",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[project.optional-dependencies]
|
|
32
|
+
dev = [
|
|
33
|
+
"pytest>=8.0",
|
|
34
|
+
"pytest-asyncio>=0.23",
|
|
35
|
+
"pytest-cov>=5.0",
|
|
36
|
+
"ruff>=0.5",
|
|
37
|
+
"mypy>=1.10",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
[project.urls]
|
|
41
|
+
Homepage = "https://github.com/mikezs/python-eswater"
|
|
42
|
+
Repository = "https://github.com/mikezs/python-eswater"
|
|
43
|
+
Issues = "https://github.com/mikezs/python-eswater/issues"
|
|
44
|
+
|
|
45
|
+
[project.scripts]
|
|
46
|
+
eswater = "eswater.__main__:main"
|
|
47
|
+
|
|
48
|
+
[tool.hatch.build.targets.wheel]
|
|
49
|
+
packages = ["src/eswater"]
|
|
50
|
+
|
|
51
|
+
# This repo may not be under version control, so hatchling can't fall back to
|
|
52
|
+
# VCS-ignore to prune the sdist. Enumerate exactly what ships so secrets
|
|
53
|
+
# (.env), caches, and virtualenvs can never leak into the tarball.
|
|
54
|
+
[tool.hatch.build.targets.sdist]
|
|
55
|
+
include = [
|
|
56
|
+
"/src",
|
|
57
|
+
"/tests",
|
|
58
|
+
"/docs",
|
|
59
|
+
"/README.md",
|
|
60
|
+
"/CHANGELOG.md",
|
|
61
|
+
"/LICENSE",
|
|
62
|
+
"/pyproject.toml",
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
[tool.ruff]
|
|
66
|
+
line-length = 100
|
|
67
|
+
target-version = "py311"
|
|
68
|
+
|
|
69
|
+
[tool.ruff.lint]
|
|
70
|
+
select = ["E", "F", "I", "UP", "B", "SIM", "ASYNC"]
|
|
71
|
+
|
|
72
|
+
[tool.mypy]
|
|
73
|
+
python_version = "3.11"
|
|
74
|
+
strict = true
|
|
75
|
+
warn_unused_ignores = true
|
|
76
|
+
disallow_untyped_defs = true
|
|
77
|
+
|
|
78
|
+
[tool.pytest.ini_options]
|
|
79
|
+
asyncio_mode = "auto"
|
|
80
|
+
testpaths = ["tests"]
|
|
81
|
+
|
|
82
|
+
[tool.coverage.run]
|
|
83
|
+
source = ["eswater"]
|
|
84
|
+
branch = true
|
|
85
|
+
|
|
86
|
+
[tool.coverage.report]
|
|
87
|
+
show_missing = true
|
|
88
|
+
exclude_lines = [
|
|
89
|
+
"pragma: no cover",
|
|
90
|
+
"raise NotImplementedError",
|
|
91
|
+
"if __name__ == .__main__.:",
|
|
92
|
+
"\\.\\.\\.",
|
|
93
|
+
]
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""eswater: async client for Essex & Suffolk Water smart meter data."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .client import ESWaterClient
|
|
6
|
+
from .exceptions import (
|
|
7
|
+
ApiError,
|
|
8
|
+
ESWaterError,
|
|
9
|
+
InvalidAuth,
|
|
10
|
+
NotAuthenticated,
|
|
11
|
+
ServiceUnavailable,
|
|
12
|
+
)
|
|
13
|
+
from .models import Account, Granularity, Meter, UsageReading
|
|
14
|
+
|
|
15
|
+
__version__ = "0.1.0"
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"ESWaterClient",
|
|
19
|
+
"Account",
|
|
20
|
+
"Meter",
|
|
21
|
+
"UsageReading",
|
|
22
|
+
"Granularity",
|
|
23
|
+
"ESWaterError",
|
|
24
|
+
"InvalidAuth",
|
|
25
|
+
"NotAuthenticated",
|
|
26
|
+
"ServiceUnavailable",
|
|
27
|
+
"ApiError",
|
|
28
|
+
]
|