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.
@@ -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
@@ -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
+ ]