franklinwh-ha-cloud-analytics 0.1.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_ha_cloud_analytics-0.1.1/LICENSE +21 -0
- franklinwh_ha_cloud_analytics-0.1.1/PKG-INFO +87 -0
- franklinwh_ha_cloud_analytics-0.1.1/README.md +72 -0
- franklinwh_ha_cloud_analytics-0.1.1/pyproject.toml +28 -0
- franklinwh_ha_cloud_analytics-0.1.1/setup.cfg +4 -0
- franklinwh_ha_cloud_analytics-0.1.1/src/franklinwh_ha_cloud_analytics/__init__.py +22 -0
- franklinwh_ha_cloud_analytics-0.1.1/src/franklinwh_ha_cloud_analytics/demo.py +119 -0
- franklinwh_ha_cloud_analytics-0.1.1/src/franklinwh_ha_cloud_analytics/payload.py +124 -0
- franklinwh_ha_cloud_analytics-0.1.1/src/franklinwh_ha_cloud_analytics/periods.py +60 -0
- franklinwh_ha_cloud_analytics-0.1.1/src/franklinwh_ha_cloud_analytics/service.py +48 -0
- franklinwh_ha_cloud_analytics-0.1.1/src/franklinwh_ha_cloud_analytics/static/cloud-analytics.css +40 -0
- franklinwh_ha_cloud_analytics-0.1.1/src/franklinwh_ha_cloud_analytics/static/cloud-analytics.js +190 -0
- franklinwh_ha_cloud_analytics-0.1.1/src/franklinwh_ha_cloud_analytics.egg-info/PKG-INFO +87 -0
- franklinwh_ha_cloud_analytics-0.1.1/src/franklinwh_ha_cloud_analytics.egg-info/SOURCES.txt +17 -0
- franklinwh_ha_cloud_analytics-0.1.1/src/franklinwh_ha_cloud_analytics.egg-info/dependency_links.txt +1 -0
- franklinwh_ha_cloud_analytics-0.1.1/src/franklinwh_ha_cloud_analytics.egg-info/requires.txt +4 -0
- franklinwh_ha_cloud_analytics-0.1.1/src/franklinwh_ha_cloud_analytics.egg-info/top_level.txt +1 -0
- franklinwh_ha_cloud_analytics-0.1.1/tests/test_page.py +150 -0
- franklinwh_ha_cloud_analytics-0.1.1/tests/test_payload.py +130 -0
|
@@ -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,87 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: franklinwh-ha-cloud-analytics
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: The FranklinWH cloud's energy reporting (periods, chart payload, page panel) shared by the FranklinWH bridges
|
|
5
|
+
Author: david2069
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Source, https://github.com/david2069/franklinwh-ha-cloud-analytics
|
|
8
|
+
Requires-Python: >=3.11
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Requires-Dist: franklinwh-cloud>=0.6.5
|
|
12
|
+
Provides-Extra: test
|
|
13
|
+
Requires-Dist: pytest>=8.0; extra == "test"
|
|
14
|
+
Dynamic: license-file
|
|
15
|
+
|
|
16
|
+
# franklinwh-ha-cloud-analytics
|
|
17
|
+
|
|
18
|
+
The FranklinWH cloud's energy reporting, shared by the FranklinWH bridges. It sits next to
|
|
19
|
+
a bridge's own (local) analytics behind a **Local / Cloud** switch: the same controls,
|
|
20
|
+
two different sources, and the page always says which one it is showing.
|
|
21
|
+
|
|
22
|
+
- **Periods:** Day (5-min), Day (hourly), Week, Month, Year, Lifetime, with a date picker.
|
|
23
|
+
- **Chart:** home load, solar, battery (discharge +, charge −) and grid (import +,
|
|
24
|
+
export −) in the site's time zone.
|
|
25
|
+
- **Totals:** solar production (to home, battery, grid), battery flows (discharged by
|
|
26
|
+
destination, charged by source), grid flows (imported / exported) and home load (by
|
|
27
|
+
source), each value with how sure the source is of it.
|
|
28
|
+
- **History:** imported into the bridge's own database (closed months once, the current
|
|
29
|
+
month refreshed) for monthly, annual and lifetime totals, queries and download.
|
|
30
|
+
|
|
31
|
+
Status: phase 1 (periods, chart payload, panel). The phased plan is in
|
|
32
|
+
[direct-connect-bridge #215](https://github.com/david2069/franklinwh-direct-connect-bridge/issues/215).
|
|
33
|
+
|
|
34
|
+
## How a bridge uses it
|
|
35
|
+
|
|
36
|
+
The bridge keeps the cloud credentials, its routes and its database. This package makes
|
|
37
|
+
the cloud calls through [franklinwh-cloud](https://github.com/david2069/franklinwh-cloud),
|
|
38
|
+
builds one payload per period, and ships a dependency-free panel the bridge mounts under
|
|
39
|
+
its Analytics page.
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
import franklinwh_ha_cloud_analytics as ca
|
|
43
|
+
|
|
44
|
+
cache = ca.Cache() # one per bridge; keyed by gateway below
|
|
45
|
+
|
|
46
|
+
# GET /api/analytics/cloud?period=month&date=2026-11-05
|
|
47
|
+
payload = await ca.fetch(client, "month", date(2026, 11, 5), today=site_today,
|
|
48
|
+
cache=cache, key=gateway_id)
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
`client` is a logged-in franklinwh-cloud client for the gateway, and `site_today` is
|
|
52
|
+
today's date in the site's own time zone. Serve `ca.static_dir()` at `/cloud-analytics/`,
|
|
53
|
+
then:
|
|
54
|
+
|
|
55
|
+
```html
|
|
56
|
+
<link rel="stylesheet" href="cloud-analytics/cloud-analytics.css">
|
|
57
|
+
<script src="cloud-analytics/cloud-analytics.js"></script>
|
|
58
|
+
<script>FWHCloudAnalytics.mount(el, {endpoint: "api/analytics/cloud",
|
|
59
|
+
params: () => ({gateway: currentGatewayId})});</script>
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
`python -m franklinwh_ha_cloud_analytics.demo` serves the panel over made-up data.
|
|
63
|
+
|
|
64
|
+
## Periods
|
|
65
|
+
|
|
66
|
+
| Period | Library call | Chart |
|
|
67
|
+
|---|---|---|
|
|
68
|
+
| Day (5-min) | `get_power_by_day(day)` | lines, kW |
|
|
69
|
+
| Day (hourly) | `get_power_by_day(day)`, added up per hour | bars, kWh |
|
|
70
|
+
| Week | `get_power_details(2, its Monday)` | bars per day, kWh |
|
|
71
|
+
| Month | `get_power_details(3, the 1st)` | bars per day, kWh |
|
|
72
|
+
| Year | `get_power_details(4, 1 January)` | bars per month, kWh |
|
|
73
|
+
| Lifetime | `get_power_details(5, today)` | bars per year, kWh |
|
|
74
|
+
|
|
75
|
+
A Day request to `get_power_details` returns a single bucket, so both Day views read the
|
|
76
|
+
5-minute power. There, home, battery and grid are sums of the cloud's directional flows
|
|
77
|
+
(solar → home, grid → battery, …), so they're marked derived (≈). Every series carries
|
|
78
|
+
the weakest evidence rating of the fields it uses (franklinwh-cloud's field dictionary);
|
|
79
|
+
INFERRED ones are labelled. No data stays null, never 0. Signs: battery discharge +,
|
|
80
|
+
charge −; grid import +, export −.
|
|
81
|
+
|
|
82
|
+
Caching: a period that today falls in is kept for 5 minutes, a finished one for 6 hours.
|
|
83
|
+
The cloud's rate limit is per account, shared with the FranklinWH app.
|
|
84
|
+
|
|
85
|
+
## Licence
|
|
86
|
+
|
|
87
|
+
MIT
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# franklinwh-ha-cloud-analytics
|
|
2
|
+
|
|
3
|
+
The FranklinWH cloud's energy reporting, shared by the FranklinWH bridges. It sits next to
|
|
4
|
+
a bridge's own (local) analytics behind a **Local / Cloud** switch: the same controls,
|
|
5
|
+
two different sources, and the page always says which one it is showing.
|
|
6
|
+
|
|
7
|
+
- **Periods:** Day (5-min), Day (hourly), Week, Month, Year, Lifetime, with a date picker.
|
|
8
|
+
- **Chart:** home load, solar, battery (discharge +, charge −) and grid (import +,
|
|
9
|
+
export −) in the site's time zone.
|
|
10
|
+
- **Totals:** solar production (to home, battery, grid), battery flows (discharged by
|
|
11
|
+
destination, charged by source), grid flows (imported / exported) and home load (by
|
|
12
|
+
source), each value with how sure the source is of it.
|
|
13
|
+
- **History:** imported into the bridge's own database (closed months once, the current
|
|
14
|
+
month refreshed) for monthly, annual and lifetime totals, queries and download.
|
|
15
|
+
|
|
16
|
+
Status: phase 1 (periods, chart payload, panel). The phased plan is in
|
|
17
|
+
[direct-connect-bridge #215](https://github.com/david2069/franklinwh-direct-connect-bridge/issues/215).
|
|
18
|
+
|
|
19
|
+
## How a bridge uses it
|
|
20
|
+
|
|
21
|
+
The bridge keeps the cloud credentials, its routes and its database. This package makes
|
|
22
|
+
the cloud calls through [franklinwh-cloud](https://github.com/david2069/franklinwh-cloud),
|
|
23
|
+
builds one payload per period, and ships a dependency-free panel the bridge mounts under
|
|
24
|
+
its Analytics page.
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
import franklinwh_ha_cloud_analytics as ca
|
|
28
|
+
|
|
29
|
+
cache = ca.Cache() # one per bridge; keyed by gateway below
|
|
30
|
+
|
|
31
|
+
# GET /api/analytics/cloud?period=month&date=2026-11-05
|
|
32
|
+
payload = await ca.fetch(client, "month", date(2026, 11, 5), today=site_today,
|
|
33
|
+
cache=cache, key=gateway_id)
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
`client` is a logged-in franklinwh-cloud client for the gateway, and `site_today` is
|
|
37
|
+
today's date in the site's own time zone. Serve `ca.static_dir()` at `/cloud-analytics/`,
|
|
38
|
+
then:
|
|
39
|
+
|
|
40
|
+
```html
|
|
41
|
+
<link rel="stylesheet" href="cloud-analytics/cloud-analytics.css">
|
|
42
|
+
<script src="cloud-analytics/cloud-analytics.js"></script>
|
|
43
|
+
<script>FWHCloudAnalytics.mount(el, {endpoint: "api/analytics/cloud",
|
|
44
|
+
params: () => ({gateway: currentGatewayId})});</script>
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
`python -m franklinwh_ha_cloud_analytics.demo` serves the panel over made-up data.
|
|
48
|
+
|
|
49
|
+
## Periods
|
|
50
|
+
|
|
51
|
+
| Period | Library call | Chart |
|
|
52
|
+
|---|---|---|
|
|
53
|
+
| Day (5-min) | `get_power_by_day(day)` | lines, kW |
|
|
54
|
+
| Day (hourly) | `get_power_by_day(day)`, added up per hour | bars, kWh |
|
|
55
|
+
| Week | `get_power_details(2, its Monday)` | bars per day, kWh |
|
|
56
|
+
| Month | `get_power_details(3, the 1st)` | bars per day, kWh |
|
|
57
|
+
| Year | `get_power_details(4, 1 January)` | bars per month, kWh |
|
|
58
|
+
| Lifetime | `get_power_details(5, today)` | bars per year, kWh |
|
|
59
|
+
|
|
60
|
+
A Day request to `get_power_details` returns a single bucket, so both Day views read the
|
|
61
|
+
5-minute power. There, home, battery and grid are sums of the cloud's directional flows
|
|
62
|
+
(solar → home, grid → battery, …), so they're marked derived (≈). Every series carries
|
|
63
|
+
the weakest evidence rating of the fields it uses (franklinwh-cloud's field dictionary);
|
|
64
|
+
INFERRED ones are labelled. No data stays null, never 0. Signs: battery discharge +,
|
|
65
|
+
charge −; grid import +, export −.
|
|
66
|
+
|
|
67
|
+
Caching: a period that today falls in is kept for 5 minutes, a finished one for 6 hours.
|
|
68
|
+
The cloud's rate limit is per account, shared with the FranklinWH app.
|
|
69
|
+
|
|
70
|
+
## Licence
|
|
71
|
+
|
|
72
|
+
MIT
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=64"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "franklinwh-ha-cloud-analytics"
|
|
7
|
+
version = "0.1.1"
|
|
8
|
+
description = "The FranklinWH cloud's energy reporting (periods, chart payload, page panel) shared by the FranklinWH bridges"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = {text = "MIT"}
|
|
12
|
+
authors = [{name = "david2069"}]
|
|
13
|
+
dependencies = ["franklinwh-cloud>=0.6.5"]
|
|
14
|
+
|
|
15
|
+
[project.optional-dependencies]
|
|
16
|
+
test = ["pytest>=8.0"]
|
|
17
|
+
|
|
18
|
+
[project.urls]
|
|
19
|
+
Source = "https://github.com/david2069/franklinwh-ha-cloud-analytics"
|
|
20
|
+
|
|
21
|
+
[tool.setuptools.packages.find]
|
|
22
|
+
where = ["src"]
|
|
23
|
+
|
|
24
|
+
[tool.setuptools.package-data]
|
|
25
|
+
franklinwh_ha_cloud_analytics = ["static/*"]
|
|
26
|
+
|
|
27
|
+
[tool.pytest.ini_options]
|
|
28
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""The FranklinWH cloud's energy reporting, shared by the FranklinWH bridges.
|
|
2
|
+
|
|
3
|
+
A bridge passes in its logged-in franklinwh-cloud client; :func:`fetch` makes the one
|
|
4
|
+
library call a period needs (cached), and :func:`build` turns the reply into the payload
|
|
5
|
+
the page panel in ``static/`` draws. The bridge keeps credentials, routes and storage.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from importlib import resources
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from .payload import build
|
|
13
|
+
from .periods import PERIODS, Period, resolve
|
|
14
|
+
from .service import Cache, fetch
|
|
15
|
+
|
|
16
|
+
__version__ = "0.1.1"
|
|
17
|
+
__all__ = ["PERIODS", "Cache", "Period", "build", "fetch", "resolve", "static_dir"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def static_dir() -> Path:
|
|
21
|
+
"""The folder a bridge serves at ``/cloud-analytics/`` (cloud-analytics.js and .css)."""
|
|
22
|
+
return Path(str(resources.files(__package__) / "static"))
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""A local page with the panel over made-up data, for trying it and for the browser tests.
|
|
2
|
+
|
|
3
|
+
python -m franklinwh_ha_cloud_analytics.demo [port]
|
|
4
|
+
|
|
5
|
+
No cloud call is made: the endpoint builds payloads from the shapes the library returns.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import math
|
|
11
|
+
import sys
|
|
12
|
+
from datetime import date, timedelta
|
|
13
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
14
|
+
from urllib.parse import parse_qs, urlparse
|
|
15
|
+
|
|
16
|
+
from . import build, resolve, static_dir
|
|
17
|
+
|
|
18
|
+
PAGE = """<!doctype html><html><head><meta charset="utf-8">
|
|
19
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
20
|
+
<title>Cloud analytics demo</title><link rel="stylesheet" href="cloud-analytics/cloud-analytics.css">
|
|
21
|
+
<style>body{font-family:system-ui,sans-serif;margin:16px;background:#fff;color:#18202b}
|
|
22
|
+
@media (prefers-color-scheme:dark){body{background:#121924;color:#e4e9f0}}</style></head>
|
|
23
|
+
<body><div id="ca"></div><script src="cloud-analytics/cloud-analytics.js"></script>
|
|
24
|
+
<script>FWHCloudAnalytics.mount(document.getElementById("ca"), {endpoint: "api/cloud"});</script>
|
|
25
|
+
</body></html>"""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _day(on: date) -> dict:
|
|
29
|
+
"""288 five-minute samples of a sunny day: solar by day, battery at night."""
|
|
30
|
+
t, flows = [], {k: [] for k in ("powerSolarHomeArray", "powerSolarGirdArray",
|
|
31
|
+
"powerSolarFhpArray", "powerGirdHomeArray",
|
|
32
|
+
"powerGirdFhpArray", "powerFhpHomeArray",
|
|
33
|
+
"powerFhpGirdArray")}
|
|
34
|
+
for i in range(288):
|
|
35
|
+
h = i / 12
|
|
36
|
+
t.append(f"{on.isoformat()} {int(h):02d}:{i % 12 * 5:02d}")
|
|
37
|
+
sun = max(0.0, math.sin((h - 6) / 13 * math.pi)) * 6
|
|
38
|
+
home = 0.6 + 0.8 * math.exp(-((h - 19) ** 2) / 4)
|
|
39
|
+
s_home = min(sun, home)
|
|
40
|
+
s_bat = min(sun - s_home, 3.0)
|
|
41
|
+
b_home = home - s_home if sun < 0.2 else 0.0
|
|
42
|
+
vals = {"powerSolarHomeArray": s_home, "powerSolarGirdArray": sun - s_home - s_bat,
|
|
43
|
+
"powerSolarFhpArray": s_bat, "powerGirdHomeArray": home - s_home - b_home,
|
|
44
|
+
"powerGirdFhpArray": 0.0, "powerFhpHomeArray": b_home, "powerFhpGirdArray": 0.0}
|
|
45
|
+
for k, v in vals.items():
|
|
46
|
+
flows[k].append(round(max(0.0, v), 3))
|
|
47
|
+
return {"deviceTimeArray": t, **flows}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _buckets(labels: list[str], today: date) -> dict:
|
|
51
|
+
out = {k: [] for k in ("kwhLoadArray", "kwhSuArray", "kwhGenArray", "kwhFhpDiArray",
|
|
52
|
+
"kwhFhpChgArray", "kwhUtiInArray", "kwhUtiOutArray")}
|
|
53
|
+
for i, lab in enumerate(labels):
|
|
54
|
+
future = lab > today.isoformat()[:len(lab)]
|
|
55
|
+
base = 18 + 6 * math.sin(i)
|
|
56
|
+
for k, v in zip(out, (base, base * 1.4, 0.0, base * 0.4, base * 0.45, 2.0, base * 0.5)):
|
|
57
|
+
out[k].append(None if future else round(v, 2))
|
|
58
|
+
return {"deviceTimeArray": labels, **out}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def payload(period: str, on: date, today: date) -> dict:
|
|
62
|
+
p = resolve(period, on, today)
|
|
63
|
+
if p.type is None:
|
|
64
|
+
return build(p, _day(p.start) if p.start <= today else {})
|
|
65
|
+
if period == "week":
|
|
66
|
+
labels = [(p.start + timedelta(days=i)).isoformat() for i in range(7)]
|
|
67
|
+
elif period == "month":
|
|
68
|
+
nxt = date(p.start.year + p.start.month // 12, p.start.month % 12 + 1, 1)
|
|
69
|
+
labels = [(p.start + timedelta(days=i)).isoformat() for i in range((nxt - p.start).days)]
|
|
70
|
+
elif period == "year":
|
|
71
|
+
labels = [f"{p.start.year}-{m:02d}" for m in range(1, 13)]
|
|
72
|
+
else:
|
|
73
|
+
labels = [str(y) for y in range(today.year - 3, today.year + 1)]
|
|
74
|
+
return build(p, _buckets(labels, today))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class Handler(BaseHTTPRequestHandler):
|
|
78
|
+
today = date.today()
|
|
79
|
+
|
|
80
|
+
def log_message(self, *a): # quiet
|
|
81
|
+
pass
|
|
82
|
+
|
|
83
|
+
def _send(self, body: bytes, ctype: str, code: int = 200):
|
|
84
|
+
self.send_response(code)
|
|
85
|
+
self.send_header("Content-Type", ctype)
|
|
86
|
+
self.end_headers()
|
|
87
|
+
self.wfile.write(body)
|
|
88
|
+
|
|
89
|
+
def do_GET(self):
|
|
90
|
+
u = urlparse(self.path)
|
|
91
|
+
if u.path == "/":
|
|
92
|
+
return self._send(PAGE.encode(), "text/html; charset=utf-8")
|
|
93
|
+
if u.path == "/api/cloud":
|
|
94
|
+
q = {k: v[0] for k, v in parse_qs(u.query).items()}
|
|
95
|
+
try:
|
|
96
|
+
body = payload(q.get("period", "day5"),
|
|
97
|
+
date.fromisoformat(q.get("date") or self.today.isoformat()),
|
|
98
|
+
self.today)
|
|
99
|
+
return self._send(json.dumps(body).encode(), "application/json")
|
|
100
|
+
except ValueError as e:
|
|
101
|
+
return self._send(json.dumps({"error": str(e)}).encode(), "application/json", 400)
|
|
102
|
+
if u.path.startswith("/cloud-analytics/"):
|
|
103
|
+
f = static_dir() / u.path.rsplit("/", 1)[-1]
|
|
104
|
+
if f.is_file():
|
|
105
|
+
ctype = "text/css" if f.suffix == ".css" else "text/javascript"
|
|
106
|
+
return self._send(f.read_bytes(), ctype)
|
|
107
|
+
self._send(b"not found", "text/plain", 404)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def serve(port: int = 8765, today: date | None = None) -> ThreadingHTTPServer:
|
|
111
|
+
if today:
|
|
112
|
+
Handler.today = today
|
|
113
|
+
return ThreadingHTTPServer(("127.0.0.1", port), Handler)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
if __name__ == "__main__":
|
|
117
|
+
srv = serve(int(sys.argv[1]) if len(sys.argv) > 1 else 8765)
|
|
118
|
+
print(f"http://127.0.0.1:{srv.server_address[1]}/")
|
|
119
|
+
srv.serve_forever()
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""The chart payload: the library's arrays as series, in the bridges' signs.
|
|
2
|
+
|
|
3
|
+
kWh periods (Week, Month, Year, Lifetime) plot the cloud's own totals as bars. Day (5-min)
|
|
4
|
+
plots lines in kW, and Day (hourly) bars in kWh, both made from the 5-minute directional
|
|
5
|
+
flows, so those series are marked derived. Each series carries the weakest evidence rating
|
|
6
|
+
of the fields it uses (franklinwh_cloud.const.energy_fields). Signs: battery discharge +,
|
|
7
|
+
charge −; grid import +, export −. No data stays null, never 0.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from franklinwh_cloud.const.energy_fields import ELECTRIC_FIELDS, POWER_FIELDS
|
|
12
|
+
|
|
13
|
+
from .periods import Period
|
|
14
|
+
|
|
15
|
+
_EVIDENCE = {f.api: f.evidence for f in (*ELECTRIC_FIELDS, *POWER_FIELDS)}
|
|
16
|
+
_RANK = {"CONFIRMED": 0, "CORROBORATED": 1, "INFERRED": 2}
|
|
17
|
+
SAMPLES_PER_HOUR = 12
|
|
18
|
+
|
|
19
|
+
LABELS = {"home": "Home load", "solar": "Solar", "generator": "Generator",
|
|
20
|
+
"battery": "Battery", "battery_discharge": "Battery discharged",
|
|
21
|
+
"battery_charge": "Battery charged", "grid": "Grid",
|
|
22
|
+
"grid_import": "Grid imported", "grid_export": "Grid exported"}
|
|
23
|
+
|
|
24
|
+
#: kWh totals per bucket (getFhpElectricData): key → (API array, sign).
|
|
25
|
+
_TOTALS = (("home", "kwhLoadArray", 1), ("solar", "kwhSuArray", 1),
|
|
26
|
+
("generator", "kwhGenArray", 1), ("battery_discharge", "kwhFhpDiArray", 1),
|
|
27
|
+
("battery_charge", "kwhFhpChgArray", -1), ("grid_import", "kwhUtiInArray", 1),
|
|
28
|
+
("grid_export", "kwhUtiOutArray", -1))
|
|
29
|
+
|
|
30
|
+
# The 5-minute directional flows (getFhpPowerByDay; "Gird" is the API's spelling).
|
|
31
|
+
_S_HOME, _S_GRID, _S_BAT = "powerSolarHomeArray", "powerSolarGirdArray", "powerSolarFhpArray"
|
|
32
|
+
_G_HOME, _G_BAT = "powerGirdHomeArray", "powerGirdFhpArray"
|
|
33
|
+
_B_HOME, _B_GRID = "powerFhpHomeArray", "powerFhpGirdArray"
|
|
34
|
+
_GEN_HOME, _GEN_BAT = "powerGenHomeArray", "powerGenFhpArray"
|
|
35
|
+
|
|
36
|
+
#: key → [(flow, sign)]: what each series adds up from the flows.
|
|
37
|
+
_LINES = {
|
|
38
|
+
"home": [(_S_HOME, 1), (_G_HOME, 1), (_B_HOME, 1), (_GEN_HOME, 1)],
|
|
39
|
+
"solar": [(_S_HOME, 1), (_S_GRID, 1), (_S_BAT, 1)],
|
|
40
|
+
"battery": [(_B_HOME, 1), (_B_GRID, 1), (_S_BAT, -1), (_G_BAT, -1), (_GEN_BAT, -1)],
|
|
41
|
+
"grid": [(_G_HOME, 1), (_G_BAT, 1), (_S_GRID, -1), (_B_GRID, -1)],
|
|
42
|
+
}
|
|
43
|
+
_HOURLY = {
|
|
44
|
+
"home": _LINES["home"], "solar": _LINES["solar"],
|
|
45
|
+
"generator": [(_GEN_HOME, 1), (_GEN_BAT, 1)],
|
|
46
|
+
"battery_discharge": [(_B_HOME, 1), (_B_GRID, 1)],
|
|
47
|
+
"battery_charge": [(_S_BAT, -1), (_G_BAT, -1), (_GEN_BAT, -1)],
|
|
48
|
+
"grid_import": [(_G_HOME, 1), (_G_BAT, 1)],
|
|
49
|
+
"grid_export": [(_S_GRID, -1), (_B_GRID, -1)],
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _weakest(apis) -> str:
|
|
54
|
+
levels = [_EVIDENCE.get(a, "INFERRED") for a in apis]
|
|
55
|
+
return max(levels, key=_RANK.__getitem__) if levels else "INFERRED"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _series(key, values, apis, derived) -> dict:
|
|
59
|
+
return {"key": key, "label": LABELS[key], "values": values,
|
|
60
|
+
"evidence": _weakest(apis), "derived": derived}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _combine(result: dict, parts, i: int):
|
|
64
|
+
"""One sample of a derived series. Flows the reply doesn't carry at all are left out
|
|
65
|
+
(no generator on this site); a null in a flow it does carry makes the sample null."""
|
|
66
|
+
total, seen = 0.0, False
|
|
67
|
+
for api, sign in parts:
|
|
68
|
+
arr = result.get(api)
|
|
69
|
+
if not isinstance(arr, list):
|
|
70
|
+
continue
|
|
71
|
+
v = arr[i] if i < len(arr) else None
|
|
72
|
+
if v is None:
|
|
73
|
+
return None
|
|
74
|
+
total += sign * v
|
|
75
|
+
seen = True
|
|
76
|
+
return round(total, 4) if seen else None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _present(result: dict, parts) -> list[str]:
|
|
80
|
+
return [a for a, _ in parts if isinstance(result.get(a), list)]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def build(period: Period, result: dict | None) -> dict:
|
|
84
|
+
"""The payload the panel draws for ``period`` from the library's reply ``result``."""
|
|
85
|
+
result = result or {}
|
|
86
|
+
labels = list(result.get("deviceTimeArray") or [])
|
|
87
|
+
out = {"source": "cloud", "period": period.name, "date": period.start.isoformat(),
|
|
88
|
+
"nav": {"prev": period.prev and period.prev.isoformat(),
|
|
89
|
+
"next": period.next and period.next.isoformat()},
|
|
90
|
+
"note": None}
|
|
91
|
+
if period.name == "day5":
|
|
92
|
+
series = [_series(k, [_combine(result, parts, i) for i in range(len(labels))],
|
|
93
|
+
_present(result, parts), True) for k, parts in _LINES.items()]
|
|
94
|
+
out.update(chart="line", unit="kW")
|
|
95
|
+
elif period.name == "dayhour":
|
|
96
|
+
hours = [f"{h:02d}:00" for h in range(24)]
|
|
97
|
+
idx: dict[str, list[int]] = {h: [] for h in hours}
|
|
98
|
+
for i, t in enumerate(labels):
|
|
99
|
+
hh = str(t).split(" ")[-1][:2] + ":00"
|
|
100
|
+
if hh in idx:
|
|
101
|
+
idx[hh].append(i)
|
|
102
|
+
series = []
|
|
103
|
+
for k, parts in _HOURLY.items():
|
|
104
|
+
vals = []
|
|
105
|
+
for h in hours:
|
|
106
|
+
samples = [_combine(result, parts, i) for i in idx[h]]
|
|
107
|
+
vals.append(None if not samples or None in samples
|
|
108
|
+
else round(sum(samples) / SAMPLES_PER_HOUR, 4))
|
|
109
|
+
series.append(_series(k, vals, _present(result, parts), True))
|
|
110
|
+
labels = hours if labels else []
|
|
111
|
+
out.update(chart="bars", unit="kWh",
|
|
112
|
+
note="Hourly kWh added up from the 5-minute power, within about 3% of "
|
|
113
|
+
"the day's own totals.")
|
|
114
|
+
else:
|
|
115
|
+
series = []
|
|
116
|
+
for k, api, sign in _TOTALS:
|
|
117
|
+
arr = list(result.get(api) or [None] * len(labels))
|
|
118
|
+
series.append(_series(k, [None if v is None else sign * v for v in arr],
|
|
119
|
+
[api], False))
|
|
120
|
+
out.update(chart="bars", unit="kWh")
|
|
121
|
+
out["labels"] = labels
|
|
122
|
+
out["series"] = series
|
|
123
|
+
out["empty"] = not any(v is not None for s in series for v in s["values"])
|
|
124
|
+
return out
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Periods: which library call each needs, the date it is asked for, and its neighbours.
|
|
2
|
+
|
|
3
|
+
getFhpElectricData honours a week only from its Monday; month and year are snapped to their
|
|
4
|
+
first day so stepping never skips or repeats one (franklinwh-cloud
|
|
5
|
+
docs/ENERGY_CLI_IMPLEMENTATION_PLAN.md, DEF-ENERGY-PERIOD-PARAM). A Day request returns a
|
|
6
|
+
single bucket, so both Day views read the 5-minute power instead.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from datetime import date, timedelta
|
|
12
|
+
|
|
13
|
+
#: period → getFhpElectricData type, or None for the 5-minute call.
|
|
14
|
+
PERIODS: dict[str, int | None] = {
|
|
15
|
+
"day5": None, "dayhour": None, "week": 2, "month": 3, "year": 4, "lifetime": 5}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class Period:
|
|
20
|
+
name: str
|
|
21
|
+
type: int | None # None: get_power_by_day
|
|
22
|
+
start: date # the date the library is asked for
|
|
23
|
+
prev: date | None
|
|
24
|
+
next: date | None # None when it would be in the future
|
|
25
|
+
current: bool # today falls inside it
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _start(name: str, on: date, today: date) -> date:
|
|
29
|
+
if name in ("day5", "dayhour"):
|
|
30
|
+
return on
|
|
31
|
+
if name == "week":
|
|
32
|
+
return on - timedelta(days=on.weekday())
|
|
33
|
+
if name == "month":
|
|
34
|
+
return on.replace(day=1)
|
|
35
|
+
if name == "year":
|
|
36
|
+
return on.replace(month=1, day=1)
|
|
37
|
+
return today
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _step(name: str, start: date, n: int) -> date:
|
|
41
|
+
if name in ("day5", "dayhour"):
|
|
42
|
+
return start + timedelta(days=n)
|
|
43
|
+
if name == "week":
|
|
44
|
+
return start + timedelta(weeks=n)
|
|
45
|
+
if name == "month":
|
|
46
|
+
m = start.year * 12 + start.month - 1 + n
|
|
47
|
+
return date(m // 12, m % 12 + 1, 1)
|
|
48
|
+
return start.replace(year=start.year + n)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def resolve(name: str, on: date, today: date) -> Period:
|
|
52
|
+
"""The period ``name`` that contains ``on`` (``today`` in the site's zone)."""
|
|
53
|
+
if name not in PERIODS:
|
|
54
|
+
raise ValueError(f"unknown period {name!r}; one of {', '.join(PERIODS)}")
|
|
55
|
+
start = _start(name, on, today)
|
|
56
|
+
if name == "lifetime":
|
|
57
|
+
return Period(name, PERIODS[name], start, None, None, True)
|
|
58
|
+
nxt = _step(name, start, 1)
|
|
59
|
+
return Period(name, PERIODS[name], start, _step(name, start, -1),
|
|
60
|
+
nxt if nxt <= today else None, start <= today < nxt)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""The one library call per period, cached so the cloud is asked as little as possible.
|
|
2
|
+
|
|
3
|
+
franklinwh-cloud has no energy cache of its own and its rate limits are per account
|
|
4
|
+
(docs/RATE_LIMITING.md), so a period that is still filling in is kept for a few minutes
|
|
5
|
+
and a finished one for hours.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import time
|
|
10
|
+
from datetime import date
|
|
11
|
+
|
|
12
|
+
from .payload import build
|
|
13
|
+
from .periods import resolve
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Cache:
|
|
17
|
+
CURRENT_S = 300 # a period today falls in
|
|
18
|
+
FINISHED_S = 6 * 3600 # a period that has ended
|
|
19
|
+
|
|
20
|
+
def __init__(self):
|
|
21
|
+
self._d: dict[tuple, tuple[float, float, dict]] = {}
|
|
22
|
+
|
|
23
|
+
def get(self, key: tuple, now: float):
|
|
24
|
+
hit = self._d.get(key)
|
|
25
|
+
return hit[2] if hit and now - hit[0] < hit[1] else None
|
|
26
|
+
|
|
27
|
+
def put(self, key: tuple, value: dict, ttl: float, now: float) -> None:
|
|
28
|
+
self._d[key] = (now, ttl, value)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
async def fetch(client, period: str, on: date, *, today: date, cache: Cache | None = None,
|
|
32
|
+
key: str = "", now: float | None = None) -> dict:
|
|
33
|
+
"""The payload for ``period`` containing ``on``. ``client`` is a logged-in
|
|
34
|
+
franklinwh-cloud client for the gateway; ``key`` separates gateways in ``cache``."""
|
|
35
|
+
p = resolve(period, on, today)
|
|
36
|
+
now = time.time() if now is None else now
|
|
37
|
+
call = ("by_day", p.start.isoformat()) if p.type is None \
|
|
38
|
+
else ("details", p.type, p.start.isoformat())
|
|
39
|
+
ck = (key, *call)
|
|
40
|
+
result = cache.get(ck, now) if cache else None
|
|
41
|
+
if result is None:
|
|
42
|
+
result = (await client.get_power_by_day(p.start.isoformat()) if p.type is None
|
|
43
|
+
else await client.get_power_details(p.type, p.start.isoformat()))
|
|
44
|
+
if cache is not None:
|
|
45
|
+
cache.put(ck, result or {}, Cache.CURRENT_S if p.current else Cache.FINISHED_S, now)
|
|
46
|
+
out = build(p, result)
|
|
47
|
+
out["fetched_at"] = now
|
|
48
|
+
return out
|
franklinwh_ha_cloud_analytics-0.1.1/src/franklinwh_ha_cloud_analytics/static/cloud-analytics.css
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/* FranklinWH cloud analytics panel. It sits inside the host's page and takes the host's text
|
|
2
|
+
* colour; a host can override any --ca- token. */
|
|
3
|
+
.fwhca{ --ca-muted: color-mix(in srgb, currentColor 60%, transparent); --ca-line: color-mix(in srgb, currentColor 18%, transparent);
|
|
4
|
+
--ca-accent: #2f9e5d; --ca-crit: #d1453b;
|
|
5
|
+
--ca-home: #5b8def; --ca-solar: #e8b100; --ca-generator: #9b6bd6;
|
|
6
|
+
--ca-battery: #2fae6b; --ca-battery_discharge: #2fae6b; --ca-battery_charge: #7fd1a3;
|
|
7
|
+
--ca-grid: #8a94a3; --ca-grid_import: #d1453b; --ca-grid_export: #8a94a3; }
|
|
8
|
+
.fwhca-bar{ display: flex; flex-wrap: wrap; gap: 8px; align-items: center; justify-content: space-between; }
|
|
9
|
+
.fwhca-nav{ display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
|
|
10
|
+
.fwhca button, .fwhca input, .fwhca select{ font: inherit; font-size: .82rem; color: inherit; background: transparent; border: 1px solid var(--ca-line); border-radius: 8px; padding: 5px 10px; min-height: 34px; }
|
|
11
|
+
.fwhca button{ cursor: pointer; }
|
|
12
|
+
.fwhca button:disabled{ opacity: .4; cursor: default; }
|
|
13
|
+
.fwhca button[aria-pressed="true"]{ border-color: var(--ca-accent); color: var(--ca-accent); }
|
|
14
|
+
.fwhca select{ background: transparent; cursor: pointer; padding: 4px 6px; }
|
|
15
|
+
.fwhca select option{ color: initial; }
|
|
16
|
+
.fwhca button:focus-visible, .fwhca input:focus-visible, .fwhca select:focus-visible{ outline: 2px solid var(--ca-accent); outline-offset: 2px; }
|
|
17
|
+
.fwhca-status{ margin: 10px 0 4px; font-size: .82rem; color: var(--ca-muted); }
|
|
18
|
+
.fwhca-status.fwhca-err{ color: var(--ca-crit); }
|
|
19
|
+
.fwhca-chart svg{ width: 100%; height: auto; display: block; }
|
|
20
|
+
.fwhca-empty{ padding: 40px 0; text-align: center; color: var(--ca-muted); }
|
|
21
|
+
.fwhca-grid{ stroke: var(--ca-line); stroke-width: 1; }
|
|
22
|
+
.fwhca-grid.zero{ stroke: var(--ca-muted); }
|
|
23
|
+
.fwhca-ax{ fill: var(--ca-muted); font-size: 11px; }
|
|
24
|
+
.fwhca-line{ fill: none; stroke-width: 1.6; }
|
|
25
|
+
.fwhca-hit{ fill: transparent; }
|
|
26
|
+
.fwhca-hit:hover{ fill: var(--ca-line); }
|
|
27
|
+
.fwhca-legend{ display: flex; flex-wrap: wrap; gap: 6px 14px; font-size: .8rem; margin-top: 6px; }
|
|
28
|
+
.fwhca-key i{ display: inline-block; width: 10px; height: 10px; border-radius: 2px; margin-right: 5px; vertical-align: -1px; }
|
|
29
|
+
.fwhca-note{ font-size: .78rem; color: var(--ca-muted); margin: 6px 0 0; }
|
|
30
|
+
.fwhca abbr{ text-decoration: none; color: var(--ca-muted); }
|
|
31
|
+
.c-home{ --c: var(--ca-home); } .c-solar{ --c: var(--ca-solar); } .c-generator{ --c: var(--ca-generator); }
|
|
32
|
+
.c-battery{ --c: var(--ca-battery); } .c-battery_discharge{ --c: var(--ca-battery_discharge); } .c-battery_charge{ --c: var(--ca-battery_charge); }
|
|
33
|
+
.c-grid{ --c: var(--ca-grid); } .c-grid_import{ --c: var(--ca-grid_import); } .c-grid_export{ --c: var(--ca-grid_export); }
|
|
34
|
+
.fwhca-line[class*="c-"]{ stroke: var(--c); }
|
|
35
|
+
.fwhca-bar-r[class*="c-"], .fwhca-key i[class*="c-"]{ fill: var(--c); background: var(--c); }
|
|
36
|
+
|
|
37
|
+
.fwhca [hidden]{ display: none !important; }
|
|
38
|
+
.fwhca-nav{ gap: 4px; }
|
|
39
|
+
.fwhca-nav button{ min-width: 32px; padding: 4px 6px; }
|
|
40
|
+
.fwhca input[type=date]{ padding: 4px 6px; height: 34px; box-sizing: border-box; }
|
franklinwh_ha_cloud_analytics-0.1.1/src/franklinwh_ha_cloud_analytics/static/cloud-analytics.js
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/* FranklinWH cloud analytics: the period picker and chart, drawn inside a host's page.
|
|
2
|
+
*
|
|
3
|
+
* The bridge serves this file and cloud-analytics.css at /cloud-analytics/, includes both,
|
|
4
|
+
* and calls FWHCloudAnalytics.mount(el, {endpoint: "api/analytics/cloud"}). The endpoint
|
|
5
|
+
* takes ?period=&date= (and anything the host adds through params(), such as its gateway)
|
|
6
|
+
* and returns franklinwh_ha_cloud_analytics.build()'s payload, or {error: "..."}. Dates
|
|
7
|
+
* and their steps come from the payload (the site's own calendar), never from this
|
|
8
|
+
* browser's clock in UTC.
|
|
9
|
+
*/
|
|
10
|
+
(function () {
|
|
11
|
+
"use strict";
|
|
12
|
+
|
|
13
|
+
const PERIODS = [["day5", "Day (5-min)"], ["dayhour", "Day (hourly)"], ["week", "Week"],
|
|
14
|
+
["month", "Month"], ["year", "Year"], ["lifetime", "Lifetime"]];
|
|
15
|
+
const KEY = "fwh-cloud-analytics:period";
|
|
16
|
+
const KEY_STACK = "fwh-cloud-analytics:stacked"; // "1": bars stacked, one column per bucket
|
|
17
|
+
const esc = (s) => String(s == null ? "" : s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
|
|
18
|
+
const store = {
|
|
19
|
+
get(k) { try { return localStorage.getItem(k); } catch (e) { return null; } },
|
|
20
|
+
set(k, v) { try { localStorage.setItem(k, v); } catch (e) { /* private mode */ } }
|
|
21
|
+
};
|
|
22
|
+
// Today in this browser's own zone (not toISOString, which is UTC). Only the first load
|
|
23
|
+
// uses it; after that the payload's date and nav are the site's calendar.
|
|
24
|
+
const localToday = () => { const d = new Date(); const p = (n) => String(n).padStart(2, "0");
|
|
25
|
+
return d.getFullYear() + "-" + p(d.getMonth() + 1) + "-" + p(d.getDate()); };
|
|
26
|
+
const fmt = (v, unit) => v == null ? "no data" : (Math.abs(v) >= 100 ? v.toFixed(0) : v.toFixed(2)) + " " + unit;
|
|
27
|
+
|
|
28
|
+
function mount(el, options) {
|
|
29
|
+
const o = Object.assign({ endpoint: "api/analytics/cloud", params: null, sourceLabel: "the FranklinWH cloud" }, options || {});
|
|
30
|
+
const st = { period: store.get(KEY) || "day5", date: localToday(), data: null, busy: false,
|
|
31
|
+
stacked: store.get(KEY_STACK) === "1" };
|
|
32
|
+
if (!PERIODS.some((p) => p[0] === st.period)) st.period = "day5";
|
|
33
|
+
el.classList.add("fwhca");
|
|
34
|
+
el.innerHTML =
|
|
35
|
+
'<div class="fwhca-bar"><select class="fwhca-period" aria-label="Period">' +
|
|
36
|
+
PERIODS.map((p) => '<option value="' + p[0] + '">' + p[1] + "</option>").join("") +
|
|
37
|
+
'</select><div class="fwhca-nav"><button type="button" data-step="prev" aria-label="Previous period">‹</button>' +
|
|
38
|
+
'<input type="date" aria-label="Date"><button type="button" data-step="next" aria-label="Next period">›</button>' +
|
|
39
|
+
'<button type="button" data-stack aria-pressed="false" aria-label="Stacked bars" title="Stacked: where the energy came from above zero, where it went below">' +
|
|
40
|
+
'<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><path d="M3 2h4v5H3zM3 9h4v5H3zM9 5h4v3H9zM9 10h4v4H9z" fill="currentColor"/></svg></button>' +
|
|
41
|
+
'<button type="button" data-refresh aria-label="Refresh" title="Refresh">↻</button></div></div>' +
|
|
42
|
+
'<p class="fwhca-status" aria-live="polite"></p><div class="fwhca-chart"></div>' +
|
|
43
|
+
'<div class="fwhca-legend"></div><p class="fwhca-note"></p>';
|
|
44
|
+
const $ = (s) => el.querySelector(s);
|
|
45
|
+
const dateIn = $("input[type=date]");
|
|
46
|
+
|
|
47
|
+
el.addEventListener("click", (e) => {
|
|
48
|
+
const b = e.target.closest("button");
|
|
49
|
+
if (!b || st.busy) return;
|
|
50
|
+
if (b.dataset.step && st.data && st.data.nav[b.dataset.step]) { st.date = st.data.nav[b.dataset.step]; load(); }
|
|
51
|
+
else if ("refresh" in b.dataset) load(true);
|
|
52
|
+
else if ("stack" in b.dataset) { st.stacked = !st.stacked; store.set(KEY_STACK, st.stacked ? "1" : "0"); render(); }
|
|
53
|
+
});
|
|
54
|
+
const periodIn = $(".fwhca-period");
|
|
55
|
+
periodIn.addEventListener("change", () => { st.period = periodIn.value; store.set(KEY, st.period); load(); });
|
|
56
|
+
dateIn.addEventListener("change", () => { if (dateIn.value) { st.date = dateIn.value; load(); } });
|
|
57
|
+
|
|
58
|
+
async function load(refresh) {
|
|
59
|
+
st.busy = true; render();
|
|
60
|
+
const q = new URLSearchParams(Object.assign({ period: st.period, date: st.date },
|
|
61
|
+
refresh ? { refresh: "1" } : {}, typeof o.params === "function" ? o.params() : o.params || {}));
|
|
62
|
+
try {
|
|
63
|
+
const r = await fetch(o.endpoint + (o.endpoint.includes("?") ? "&" : "?") + q);
|
|
64
|
+
const j = await r.json().catch(() => ({ error: "HTTP " + r.status }));
|
|
65
|
+
st.data = r.ok && !j.error ? j : { error: j.error || j.detail || "HTTP " + r.status };
|
|
66
|
+
if (!st.data.error) st.date = st.data.date;
|
|
67
|
+
} catch (e) { st.data = { error: "Could not reach the bridge: " + e.message }; }
|
|
68
|
+
st.busy = false; render();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function render() {
|
|
72
|
+
const d = st.data;
|
|
73
|
+
periodIn.value = st.period;
|
|
74
|
+
periodIn.disabled = st.busy;
|
|
75
|
+
const life = st.period === "lifetime";
|
|
76
|
+
$(".fwhca-nav").hidden = life;
|
|
77
|
+
dateIn.value = st.date;
|
|
78
|
+
dateIn.max = localToday();
|
|
79
|
+
$("[data-step=prev]").disabled = !d || !!d.error || !d.nav || !d.nav.prev;
|
|
80
|
+
$("[data-step=next]").disabled = !d || !!d.error || !d.nav || !d.nav.next;
|
|
81
|
+
const stackBtn = $("[data-stack]");
|
|
82
|
+
stackBtn.hidden = !d || !!d.error || d.chart !== "bars";
|
|
83
|
+
stackBtn.setAttribute("aria-pressed", String(st.stacked));
|
|
84
|
+
const status = $(".fwhca-status");
|
|
85
|
+
status.classList.toggle("fwhca-err", !!(d && d.error));
|
|
86
|
+
if (st.busy) status.textContent = "Loading from " + o.sourceLabel + "…";
|
|
87
|
+
else if (!d) status.textContent = "";
|
|
88
|
+
else if (d.error) status.textContent = d.error;
|
|
89
|
+
else status.textContent = "Showing cloud data from " + o.sourceLabel + (life ? "" : " · " + d.date) +
|
|
90
|
+
(d.fetched_at ? " · read " + new Date(d.fetched_at * 1000).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) : "");
|
|
91
|
+
const ok = d && !d.error && !st.busy;
|
|
92
|
+
$(".fwhca-chart").innerHTML = ok ? (d.empty ? '<p class="fwhca-empty">No cloud data for this period.</p>' : chart(d)) : "";
|
|
93
|
+
$(".fwhca-legend").innerHTML = ok && !d.empty ? legend(d) : "";
|
|
94
|
+
$(".fwhca-note").textContent = ok ? [d.note, st.stacked && d.chart === "bars" && !d.empty
|
|
95
|
+
? "Stacked: where the energy came from above zero (solar, battery, grid import), where it went below (home, battery charge, grid export)." : null]
|
|
96
|
+
.filter(Boolean).join(" ") : "";
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Series with nothing but null or 0 (a site with no generator) are left out.
|
|
100
|
+
const shown = (d) => d.series.filter((s) => s.values.some((v) => v != null && v !== 0));
|
|
101
|
+
|
|
102
|
+
function legend(d) {
|
|
103
|
+
return shown(d).map((s) => '<span class="fwhca-key"><i class="c-' + esc(s.key) + '"></i>' + esc(s.label) +
|
|
104
|
+
(s.evidence === "INFERRED" ? ' <abbr title="Inferred from the field name; never checked">(inferred)</abbr>' : "") +
|
|
105
|
+
(s.derived ? ' <abbr title="Added up from the cloud\'s 5-minute flows">≈</abbr>' : "") + "</span>").join("");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function chart(d) {
|
|
109
|
+
// drawn at the panel's own width, so axis text stays readable on a phone
|
|
110
|
+
const S = shown(d), n = d.labels.length, W = Math.max(300, Math.round($(".fwhca-chart").clientWidth || 720)),
|
|
111
|
+
H = W < 520 ? 220 : 260, L = 44, R = 8, T = 24, B = 28;
|
|
112
|
+
const stack = st.stacked && d.chart === "bars";
|
|
113
|
+
// Stacked is an energy balance: sources above zero, uses below. Home load is a use, so
|
|
114
|
+
// it is drawn below (its tooltip keeps the real value).
|
|
115
|
+
const drawn = (s, v) => (stack && s.key === "home" && v != null ? -v : v);
|
|
116
|
+
// Stacked: positives pile up above zero and negatives below, so the range is the
|
|
117
|
+
// per-bucket sums rather than the single values.
|
|
118
|
+
const vals = stack ? d.labels.flatMap((_, i) => [
|
|
119
|
+
S.reduce((a, s) => a + Math.max(0, drawn(s, s.values[i]) || 0), 0),
|
|
120
|
+
S.reduce((a, s) => a + Math.min(0, drawn(s, s.values[i]) || 0), 0)])
|
|
121
|
+
: S.flatMap((s) => s.values).filter((v) => v != null);
|
|
122
|
+
let hi = Math.max(0, ...vals), lo = Math.min(0, ...vals);
|
|
123
|
+
if (hi === lo) hi = lo + 1;
|
|
124
|
+
const step = nice((hi - lo) / 4);
|
|
125
|
+
hi = Math.ceil(hi / step) * step; lo = Math.floor(lo / step) * step;
|
|
126
|
+
const y = (v) => T + (hi - v) / (hi - lo) * (H - T - B);
|
|
127
|
+
const cw = (W - L - R) / Math.max(n, 1), x = (i) => L + cw * (i + 0.5);
|
|
128
|
+
let g = "";
|
|
129
|
+
for (let v = lo; v <= hi + step / 2; v += step) {
|
|
130
|
+
g += '<line class="fwhca-grid' + (Math.abs(v) < step / 1e6 ? " zero" : "") + '" x1="' + L + '" x2="' + (W - R) + '" y1="' + y(v) + '" y2="' + y(v) + '"/>' +
|
|
131
|
+
'<text class="fwhca-ax" x="' + (L - 6) + '" y="' + (y(v) + 4) + '" text-anchor="end">' + +v.toFixed(2) + "</text>";
|
|
132
|
+
}
|
|
133
|
+
const every = Math.ceil(n / (W < 520 ? 4 : 8));
|
|
134
|
+
d.labels.forEach((t, i) => { if (i % every === 0) g += '<text class="fwhca-ax" x="' + x(i) + '" y="' + (H - 8) + '" text-anchor="middle">' + esc(short(t, d.period)) + "</text>"; });
|
|
135
|
+
if (d.chart === "line") {
|
|
136
|
+
S.forEach((s) => {
|
|
137
|
+
let path = "", pen = false;
|
|
138
|
+
s.values.forEach((v, i) => { if (v == null) { pen = false; return; } path += (pen ? "L" : "M") + x(i).toFixed(1) + " " + y(v).toFixed(1); pen = true; });
|
|
139
|
+
g += '<path class="fwhca-line c-' + esc(s.key) + '" d="' + path + '"><title>' + esc(s.label) + "</title></path>";
|
|
140
|
+
});
|
|
141
|
+
// one invisible column per sample, so hovering anywhere shows that time's values
|
|
142
|
+
d.labels.forEach((t, i) => { g += '<rect class="fwhca-hit" x="' + (x(i) - cw / 2) + '" y="' + T + '" width="' + cw + '" height="' + (H - T - B) + '"><title>' +
|
|
143
|
+
esc(t + "\n" + S.map((s) => s.label + ": " + fmt(s.values[i], d.unit)).join("\n")) + "</title></rect>"; });
|
|
144
|
+
} else if (stack) {
|
|
145
|
+
const bw = Math.max(1, cw * 0.7);
|
|
146
|
+
d.labels.forEach((t, i) => {
|
|
147
|
+
let up = 0, down = 0;
|
|
148
|
+
S.forEach((s) => {
|
|
149
|
+
const real = s.values[i], v = drawn(s, real);
|
|
150
|
+
if (v == null || v === 0) return;
|
|
151
|
+
const from = v > 0 ? up : down, to = from + v;
|
|
152
|
+
if (v > 0) up = to; else down = to;
|
|
153
|
+
g += '<rect class="fwhca-bar-r c-' + esc(s.key) + '" x="' + (x(i) - bw / 2).toFixed(1) + '" width="' + bw.toFixed(1) +
|
|
154
|
+
'" y="' + Math.min(y(from), y(to)).toFixed(1) + '" height="' + Math.max(0.5, Math.abs(y(to) - y(from))).toFixed(1) +
|
|
155
|
+
'"><title>' + esc(t + " · " + s.label + ": " + fmt(real, d.unit)) + "</title></rect>";
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
} else {
|
|
159
|
+
const bw = Math.max(1, cw * 0.8 / Math.max(S.length, 1));
|
|
160
|
+
S.forEach((s, k) => s.values.forEach((v, i) => {
|
|
161
|
+
if (v == null) return;
|
|
162
|
+
const bx = x(i) - cw * 0.4 + k * bw;
|
|
163
|
+
g += '<rect class="fwhca-bar-r c-' + esc(s.key) + '" x="' + bx.toFixed(1) + '" width="' + bw.toFixed(1) + '" y="' + Math.min(y(v), y(0)).toFixed(1) +
|
|
164
|
+
'" height="' + Math.max(0.5, Math.abs(y(v) - y(0))).toFixed(1) + '"><title>' + esc(d.labels[i] + " · " + s.label + ": " + fmt(v, d.unit)) + "</title></rect>";
|
|
165
|
+
}));
|
|
166
|
+
}
|
|
167
|
+
return '<svg viewBox="0 0 ' + W + " " + H + '" role="img" aria-label="Cloud energy chart in ' + esc(d.unit) + '">' +
|
|
168
|
+
'<text class="fwhca-ax" x="4" y="12">' + esc(d.unit) + "</text>" + g + "</svg>";
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// redraw at the new width when the panel is resized (rotation, sidebar)
|
|
172
|
+
let lastW = 0;
|
|
173
|
+
if (window.ResizeObserver) new ResizeObserver(() => {
|
|
174
|
+
const w = $(".fwhca-chart").clientWidth;
|
|
175
|
+
if (Math.abs(w - lastW) > 8) { lastW = w; if (!st.busy) render(); }
|
|
176
|
+
}).observe(el);
|
|
177
|
+
load();
|
|
178
|
+
return { reload: () => load(true) };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function nice(x) { const p = Math.pow(10, Math.floor(Math.log10(x))); const m = x / p; return (m <= 1 ? 1 : m <= 2 ? 2 : m <= 5 ? 5 : 10) * p; }
|
|
182
|
+
function short(t, period) {
|
|
183
|
+
t = String(t);
|
|
184
|
+
if (period === "day5") return t.split(" ").pop().slice(0, 5);
|
|
185
|
+
if (period === "week" || period === "month") return t.slice(5);
|
|
186
|
+
return t;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
window.FWHCloudAnalytics = { mount };
|
|
190
|
+
})();
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: franklinwh-ha-cloud-analytics
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: The FranklinWH cloud's energy reporting (periods, chart payload, page panel) shared by the FranklinWH bridges
|
|
5
|
+
Author: david2069
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Source, https://github.com/david2069/franklinwh-ha-cloud-analytics
|
|
8
|
+
Requires-Python: >=3.11
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Requires-Dist: franklinwh-cloud>=0.6.5
|
|
12
|
+
Provides-Extra: test
|
|
13
|
+
Requires-Dist: pytest>=8.0; extra == "test"
|
|
14
|
+
Dynamic: license-file
|
|
15
|
+
|
|
16
|
+
# franklinwh-ha-cloud-analytics
|
|
17
|
+
|
|
18
|
+
The FranklinWH cloud's energy reporting, shared by the FranklinWH bridges. It sits next to
|
|
19
|
+
a bridge's own (local) analytics behind a **Local / Cloud** switch: the same controls,
|
|
20
|
+
two different sources, and the page always says which one it is showing.
|
|
21
|
+
|
|
22
|
+
- **Periods:** Day (5-min), Day (hourly), Week, Month, Year, Lifetime, with a date picker.
|
|
23
|
+
- **Chart:** home load, solar, battery (discharge +, charge −) and grid (import +,
|
|
24
|
+
export −) in the site's time zone.
|
|
25
|
+
- **Totals:** solar production (to home, battery, grid), battery flows (discharged by
|
|
26
|
+
destination, charged by source), grid flows (imported / exported) and home load (by
|
|
27
|
+
source), each value with how sure the source is of it.
|
|
28
|
+
- **History:** imported into the bridge's own database (closed months once, the current
|
|
29
|
+
month refreshed) for monthly, annual and lifetime totals, queries and download.
|
|
30
|
+
|
|
31
|
+
Status: phase 1 (periods, chart payload, panel). The phased plan is in
|
|
32
|
+
[direct-connect-bridge #215](https://github.com/david2069/franklinwh-direct-connect-bridge/issues/215).
|
|
33
|
+
|
|
34
|
+
## How a bridge uses it
|
|
35
|
+
|
|
36
|
+
The bridge keeps the cloud credentials, its routes and its database. This package makes
|
|
37
|
+
the cloud calls through [franklinwh-cloud](https://github.com/david2069/franklinwh-cloud),
|
|
38
|
+
builds one payload per period, and ships a dependency-free panel the bridge mounts under
|
|
39
|
+
its Analytics page.
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
import franklinwh_ha_cloud_analytics as ca
|
|
43
|
+
|
|
44
|
+
cache = ca.Cache() # one per bridge; keyed by gateway below
|
|
45
|
+
|
|
46
|
+
# GET /api/analytics/cloud?period=month&date=2026-11-05
|
|
47
|
+
payload = await ca.fetch(client, "month", date(2026, 11, 5), today=site_today,
|
|
48
|
+
cache=cache, key=gateway_id)
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
`client` is a logged-in franklinwh-cloud client for the gateway, and `site_today` is
|
|
52
|
+
today's date in the site's own time zone. Serve `ca.static_dir()` at `/cloud-analytics/`,
|
|
53
|
+
then:
|
|
54
|
+
|
|
55
|
+
```html
|
|
56
|
+
<link rel="stylesheet" href="cloud-analytics/cloud-analytics.css">
|
|
57
|
+
<script src="cloud-analytics/cloud-analytics.js"></script>
|
|
58
|
+
<script>FWHCloudAnalytics.mount(el, {endpoint: "api/analytics/cloud",
|
|
59
|
+
params: () => ({gateway: currentGatewayId})});</script>
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
`python -m franklinwh_ha_cloud_analytics.demo` serves the panel over made-up data.
|
|
63
|
+
|
|
64
|
+
## Periods
|
|
65
|
+
|
|
66
|
+
| Period | Library call | Chart |
|
|
67
|
+
|---|---|---|
|
|
68
|
+
| Day (5-min) | `get_power_by_day(day)` | lines, kW |
|
|
69
|
+
| Day (hourly) | `get_power_by_day(day)`, added up per hour | bars, kWh |
|
|
70
|
+
| Week | `get_power_details(2, its Monday)` | bars per day, kWh |
|
|
71
|
+
| Month | `get_power_details(3, the 1st)` | bars per day, kWh |
|
|
72
|
+
| Year | `get_power_details(4, 1 January)` | bars per month, kWh |
|
|
73
|
+
| Lifetime | `get_power_details(5, today)` | bars per year, kWh |
|
|
74
|
+
|
|
75
|
+
A Day request to `get_power_details` returns a single bucket, so both Day views read the
|
|
76
|
+
5-minute power. There, home, battery and grid are sums of the cloud's directional flows
|
|
77
|
+
(solar → home, grid → battery, …), so they're marked derived (≈). Every series carries
|
|
78
|
+
the weakest evidence rating of the fields it uses (franklinwh-cloud's field dictionary);
|
|
79
|
+
INFERRED ones are labelled. No data stays null, never 0. Signs: battery discharge +,
|
|
80
|
+
charge −; grid import +, export −.
|
|
81
|
+
|
|
82
|
+
Caching: a period that today falls in is kept for 5 minutes, a finished one for 6 hours.
|
|
83
|
+
The cloud's rate limit is per account, shared with the FranklinWH app.
|
|
84
|
+
|
|
85
|
+
## Licence
|
|
86
|
+
|
|
87
|
+
MIT
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/franklinwh_ha_cloud_analytics/__init__.py
|
|
5
|
+
src/franklinwh_ha_cloud_analytics/demo.py
|
|
6
|
+
src/franklinwh_ha_cloud_analytics/payload.py
|
|
7
|
+
src/franklinwh_ha_cloud_analytics/periods.py
|
|
8
|
+
src/franklinwh_ha_cloud_analytics/service.py
|
|
9
|
+
src/franklinwh_ha_cloud_analytics.egg-info/PKG-INFO
|
|
10
|
+
src/franklinwh_ha_cloud_analytics.egg-info/SOURCES.txt
|
|
11
|
+
src/franklinwh_ha_cloud_analytics.egg-info/dependency_links.txt
|
|
12
|
+
src/franklinwh_ha_cloud_analytics.egg-info/requires.txt
|
|
13
|
+
src/franklinwh_ha_cloud_analytics.egg-info/top_level.txt
|
|
14
|
+
src/franklinwh_ha_cloud_analytics/static/cloud-analytics.css
|
|
15
|
+
src/franklinwh_ha_cloud_analytics/static/cloud-analytics.js
|
|
16
|
+
tests/test_page.py
|
|
17
|
+
tests/test_payload.py
|
franklinwh_ha_cloud_analytics-0.1.1/src/franklinwh_ha_cloud_analytics.egg-info/dependency_links.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
franklinwh_ha_cloud_analytics
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""The panel in a real browser: periods, stepping, units, nulls, empty periods.
|
|
2
|
+
|
|
3
|
+
Needs Playwright with a Chrome or Chromium; skipped where it isn't installed.
|
|
4
|
+
"""
|
|
5
|
+
import threading
|
|
6
|
+
from datetime import date
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
sync_api = pytest.importorskip("playwright.sync_api")
|
|
11
|
+
|
|
12
|
+
from franklinwh_ha_cloud_analytics.demo import serve # noqa: E402
|
|
13
|
+
|
|
14
|
+
TODAY = date(2026, 11, 9)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@pytest.fixture(scope="module")
|
|
18
|
+
def base():
|
|
19
|
+
srv = serve(port=0, today=TODAY)
|
|
20
|
+
threading.Thread(target=srv.serve_forever, daemon=True).start()
|
|
21
|
+
yield f"http://127.0.0.1:{srv.server_address[1]}"
|
|
22
|
+
srv.shutdown()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@pytest.fixture(scope="module")
|
|
26
|
+
def browser():
|
|
27
|
+
with sync_api.sync_playwright() as p:
|
|
28
|
+
try:
|
|
29
|
+
b = p.chromium.launch(channel="chrome", headless=True)
|
|
30
|
+
except Exception: # noqa: BLE001
|
|
31
|
+
try:
|
|
32
|
+
b = p.chromium.launch(headless=True)
|
|
33
|
+
except Exception as e: # noqa: BLE001
|
|
34
|
+
pytest.skip(f"no browser: {e}")
|
|
35
|
+
yield b
|
|
36
|
+
b.close()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@pytest.fixture
|
|
40
|
+
def page(browser, base):
|
|
41
|
+
ctx = browser.new_context(viewport={"width": 1100, "height": 800})
|
|
42
|
+
pg = ctx.new_page()
|
|
43
|
+
errors = []
|
|
44
|
+
pg.on("pageerror", lambda e: errors.append(str(e)))
|
|
45
|
+
# a fresh start once per test; a reload inside the test keeps what was remembered
|
|
46
|
+
pg.add_init_script("if (!sessionStorage.t) { localStorage.clear(); sessionStorage.t = 1; }")
|
|
47
|
+
pg.goto(base + "/")
|
|
48
|
+
pg.wait_for_selector(".fwhca-chart svg")
|
|
49
|
+
yield pg
|
|
50
|
+
assert errors == []
|
|
51
|
+
ctx.close()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _status(pg):
|
|
55
|
+
return pg.locator(".fwhca-status").inner_text()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_opens_on_day_5min_lines_in_kw(page):
|
|
59
|
+
assert page.input_value(".fwhca-period") == "day5"
|
|
60
|
+
assert page.locator(".fwhca-line").count() >= 3
|
|
61
|
+
assert page.locator("svg text").first.text_content() == "kW"
|
|
62
|
+
assert "Showing cloud data from the FranklinWH cloud" in _status(page)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_periods_switch_to_kwh_bars_and_remember(page):
|
|
66
|
+
page.select_option(".fwhca-period", "month")
|
|
67
|
+
page.wait_for_selector(".fwhca-bar-r")
|
|
68
|
+
assert page.locator("svg text").first.text_content() == "kWh"
|
|
69
|
+
assert page.input_value("input[type=date]") == "2026-11-01"
|
|
70
|
+
assert page.locator("[data-step=next]").is_disabled() # no future month
|
|
71
|
+
page.click("[data-step=prev]")
|
|
72
|
+
page.wait_for_function("document.querySelector('input[type=date]').value === '2026-10-01'")
|
|
73
|
+
page.reload()
|
|
74
|
+
page.wait_for_selector(".fwhca-bar-r")
|
|
75
|
+
assert page.input_value(".fwhca-period") == "month"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_week_snaps_to_monday_and_lifetime_has_no_dates(page):
|
|
79
|
+
page.select_option(".fwhca-period", "week")
|
|
80
|
+
page.wait_for_function("document.querySelector('input[type=date]').value === '2026-11-09'")
|
|
81
|
+
page.fill("input[type=date]", "2026-11-05")
|
|
82
|
+
page.dispatch_event("input[type=date]", "change")
|
|
83
|
+
page.wait_for_function("document.querySelector('input[type=date]').value === '2026-11-02'")
|
|
84
|
+
page.select_option(".fwhca-period", "lifetime")
|
|
85
|
+
page.wait_for_selector(".fwhca-bar-r")
|
|
86
|
+
assert page.locator(".fwhca-nav").is_hidden()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_hourly_says_it_is_derived_and_future_day_is_empty(page):
|
|
90
|
+
page.select_option(".fwhca-period", "dayhour")
|
|
91
|
+
page.wait_for_selector(".fwhca-bar-r")
|
|
92
|
+
assert "5-minute" in page.locator(".fwhca-note").inner_text()
|
|
93
|
+
assert "≈" in page.locator(".fwhca-legend").inner_text()
|
|
94
|
+
page.fill("input[type=date]", "2026-12-01")
|
|
95
|
+
page.dispatch_event("input[type=date]", "change")
|
|
96
|
+
page.wait_for_selector(".fwhca-empty")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_a_generator_that_never_ran_is_not_in_the_legend(page):
|
|
100
|
+
page.select_option(".fwhca-period", "year")
|
|
101
|
+
page.wait_for_selector(".fwhca-bar-r")
|
|
102
|
+
assert "Generator" not in page.locator(".fwhca-legend").inner_text()
|
|
103
|
+
assert page.locator(".fwhca-bar-r.c-generator").count() == 0
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_phone_width_has_no_horizontal_scroll(browser, base):
|
|
107
|
+
ctx = browser.new_context(viewport={"width": 375, "height": 740})
|
|
108
|
+
pg = ctx.new_page()
|
|
109
|
+
pg.goto(base + "/")
|
|
110
|
+
pg.wait_for_selector(".fwhca-chart svg")
|
|
111
|
+
assert pg.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
|
112
|
+
ctx.close()
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def test_period_is_one_dropdown_so_the_bar_is_one_line_on_a_phone(browser, base):
|
|
116
|
+
ctx = browser.new_context(viewport={"width": 375, "height": 740})
|
|
117
|
+
pg = ctx.new_page()
|
|
118
|
+
pg.goto(base + "/")
|
|
119
|
+
pg.wait_for_selector(".fwhca-chart svg")
|
|
120
|
+
assert pg.locator("select.fwhca-period option").count() == 6
|
|
121
|
+
period, nav = pg.locator(".fwhca-period").bounding_box(), pg.locator(".fwhca-nav").bounding_box()
|
|
122
|
+
assert abs(period["y"] - nav["y"]) < 4 # side by side, not stacked
|
|
123
|
+
ctx.close()
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_bars_can_be_stacked_and_the_choice_is_remembered(page):
|
|
127
|
+
page.select_option(".fwhca-period", "week")
|
|
128
|
+
page.wait_for_selector(".fwhca-bar-r")
|
|
129
|
+
page.click("[data-step=prev]") # a whole week of data
|
|
130
|
+
page.wait_for_function("document.querySelector('input[type=date]').value === '2026-11-02'")
|
|
131
|
+
page.wait_for_selector(".fwhca-bar-r")
|
|
132
|
+
btn = page.locator("[data-stack]")
|
|
133
|
+
assert btn.is_visible() and btn.get_attribute("aria-pressed") == "false"
|
|
134
|
+
xs = lambda: {round(float(r.get_attribute("x"))) for r in page.locator(".fwhca-bar-r").all()}
|
|
135
|
+
side_by_side = len(xs())
|
|
136
|
+
btn.click()
|
|
137
|
+
assert btn.get_attribute("aria-pressed") == "true"
|
|
138
|
+
assert len(xs()) == 7 < side_by_side # one column per day
|
|
139
|
+
assert "came from above zero" in page.locator(".fwhca-note").inner_text()
|
|
140
|
+
zero = page.evaluate("+document.querySelector('.fwhca-grid.zero').getAttribute('y1')")
|
|
141
|
+
home = page.locator(".fwhca-bar-r.c-home").first
|
|
142
|
+
assert float(home.get_attribute("y")) >= zero - 0.5 # home load is a use: below zero
|
|
143
|
+
assert "Home load: -" not in home.locator("title").text_content() # the real value
|
|
144
|
+
page.reload()
|
|
145
|
+
page.wait_for_selector(".fwhca-bar-r")
|
|
146
|
+
assert page.locator("[data-stack]").get_attribute("aria-pressed") == "true"
|
|
147
|
+
assert len(xs()) == 1 # this week: today only, one column
|
|
148
|
+
page.select_option(".fwhca-period", "day5")
|
|
149
|
+
page.wait_for_selector(".fwhca-line")
|
|
150
|
+
assert page.locator("[data-stack]").is_hidden() # lines don't stack
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Periods, the chart payload and the fetch, against the franklinwh-cloud response shapes."""
|
|
2
|
+
import asyncio
|
|
3
|
+
from datetime import date
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
import franklinwh_ha_cloud_analytics as ca
|
|
8
|
+
|
|
9
|
+
TODAY = date(2026, 11, 9)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# ── periods ──────────────────────────────────────────────────────────────────
|
|
13
|
+
@pytest.mark.parametrize("period,on,want", [
|
|
14
|
+
("day5", date(2026, 11, 5), date(2026, 11, 5)),
|
|
15
|
+
("dayhour", date(2026, 11, 5), date(2026, 11, 5)),
|
|
16
|
+
("week", date(2026, 11, 5), date(2026, 11, 2)), # Thursday → its Monday
|
|
17
|
+
("month", date(2026, 11, 5), date(2026, 11, 1)),
|
|
18
|
+
("year", date(2026, 11, 5), date(2026, 1, 1)),
|
|
19
|
+
("lifetime", date(2020, 1, 1), TODAY), # the date is ignored
|
|
20
|
+
])
|
|
21
|
+
def test_resolve_snaps_to_the_period_start(period, on, want):
|
|
22
|
+
assert ca.resolve(period, on, TODAY).start == want
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_navigation_steps_whole_periods_and_stops_at_today():
|
|
26
|
+
assert ca.resolve("month", date(2026, 1, 31), TODAY).prev == date(2025, 12, 1)
|
|
27
|
+
assert ca.resolve("month", date(2026, 10, 31), TODAY).next == date(2026, 11, 1)
|
|
28
|
+
assert ca.resolve("month", date(2026, 11, 5), TODAY).next is None # no future
|
|
29
|
+
assert ca.resolve("week", date(2026, 11, 5), TODAY).prev == date(2026, 10, 26)
|
|
30
|
+
assert ca.resolve("year", date(2025, 6, 1), TODAY).next == date(2026, 1, 1)
|
|
31
|
+
assert ca.resolve("day5", date(2026, 11, 8), TODAY).next == TODAY
|
|
32
|
+
life = ca.resolve("lifetime", TODAY, TODAY)
|
|
33
|
+
assert life.prev is None and life.next is None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_unknown_period_is_refused():
|
|
37
|
+
with pytest.raises(ValueError):
|
|
38
|
+
ca.resolve("ytd", TODAY, TODAY)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# ── kWh periods: bars of the cloud's own totals ──────────────────────────────
|
|
42
|
+
MONTH = {"deviceTimeArray": ["2026-11-01", "2026-11-02"],
|
|
43
|
+
"kwhLoadArray": [20.5, None], "kwhSuArray": [30.0, None],
|
|
44
|
+
"kwhGenArray": [0.0, None],
|
|
45
|
+
"kwhFhpDiArray": [8.0, None], "kwhFhpChgArray": [10.0, None],
|
|
46
|
+
"kwhUtiInArray": [2.0, None], "kwhUtiOutArray": [9.5, None]}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_kwh_period_is_bars_with_signs_nulls_and_evidence():
|
|
50
|
+
p = ca.build(ca.resolve("month", date(2026, 11, 1), TODAY), MONTH)
|
|
51
|
+
assert p["chart"] == "bars" and p["unit"] == "kWh" and p["source"] == "cloud"
|
|
52
|
+
assert p["labels"] == ["2026-11-01", "2026-11-02"]
|
|
53
|
+
s = {x["key"]: x for x in p["series"]}
|
|
54
|
+
assert s["home"]["values"] == [20.5, None] # no data is null, never 0
|
|
55
|
+
assert s["battery_charge"]["values"] == [-10.0, None] # charge −
|
|
56
|
+
assert s["battery_discharge"]["values"] == [8.0, None] # discharge +
|
|
57
|
+
assert s["grid_export"]["values"] == [-9.5, None] # export −
|
|
58
|
+
assert s["grid_import"]["values"] == [2.0, None] # import +
|
|
59
|
+
assert s["home"]["evidence"] == "CORROBORATED" and s["home"]["derived"] is False
|
|
60
|
+
assert s["generator"]["evidence"] == "INFERRED"
|
|
61
|
+
assert p["empty"] is False
|
|
62
|
+
assert p["date"] == "2026-11-01" and p["nav"] == {"prev": "2026-10-01", "next": None}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_a_period_with_no_data_says_empty():
|
|
66
|
+
p = ca.build(ca.resolve("year", date(2027, 1, 1), date(2027, 1, 1)), {})
|
|
67
|
+
assert p["empty"] is True and p["labels"] == []
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# ── Day (5-min): lines in kW, home / battery / grid derived from the flows ───
|
|
71
|
+
DAY = {"deviceTimeArray": ["2026-11-05 00:00", "2026-11-05 00:05", "2026-11-05 01:00"],
|
|
72
|
+
"powerSolarHomeArray": [1.0, 0.0, None], "powerSolarGirdArray": [0.5, 0.0, 0.0],
|
|
73
|
+
"powerSolarFhpArray": [0.5, 0.0, 0.0], "powerGirdHomeArray": [0.0, 0.2, 0.0],
|
|
74
|
+
"powerGirdFhpArray": [0.0, 1.0, 0.0], "powerFhpHomeArray": [0.0, 0.0, 0.6],
|
|
75
|
+
"powerFhpGirdArray": [0.0, 0.0, 0.0]}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_day_5min_lines_are_derived_kw_and_null_when_a_part_is_null():
|
|
79
|
+
p = ca.build(ca.resolve("day5", date(2026, 11, 5), TODAY), DAY)
|
|
80
|
+
assert p["chart"] == "line" and p["unit"] == "kW"
|
|
81
|
+
s = {x["key"]: x for x in p["series"]}
|
|
82
|
+
assert set(s) == {"home", "solar", "battery", "grid"}
|
|
83
|
+
assert s["home"]["values"][:2] == [1.0, 0.2]
|
|
84
|
+
assert s["solar"]["values"][:2] == [2.0, 0.0]
|
|
85
|
+
assert s["battery"]["values"][:2] == [-0.5, -1.0] # charging −
|
|
86
|
+
assert s["grid"]["values"][:2] == [-0.5, 1.2] # export −, import +
|
|
87
|
+
assert s["home"]["values"][2] is None # solar→home was null
|
|
88
|
+
assert s["home"]["derived"] is True and s["home"]["evidence"] == "CORROBORATED"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_day_hourly_integrates_the_5min_flows_into_kwh():
|
|
92
|
+
p = ca.build(ca.resolve("dayhour", date(2026, 11, 5), TODAY), DAY)
|
|
93
|
+
assert p["chart"] == "bars" and p["unit"] == "kWh"
|
|
94
|
+
assert p["labels"][:2] == ["00:00", "01:00"] and len(p["labels"]) == 24
|
|
95
|
+
s = {x["key"]: x for x in p["series"]}
|
|
96
|
+
assert s["home"]["values"][0] == pytest.approx((1.0 + 0.2) / 12)
|
|
97
|
+
assert s["battery_charge"]["values"][0] == pytest.approx(-(0.5 + 1.0) / 12)
|
|
98
|
+
assert s["home"]["values"][2] is None # an hour with no samples
|
|
99
|
+
assert s["home"]["derived"] is True
|
|
100
|
+
assert "5-minute" in p["note"]
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# ── fetch: the right call, cached ────────────────────────────────────────────
|
|
104
|
+
class FakeClient:
|
|
105
|
+
def __init__(self):
|
|
106
|
+
self.calls = []
|
|
107
|
+
|
|
108
|
+
async def get_power_details(self, type, timeperiod):
|
|
109
|
+
self.calls.append(("details", type, timeperiod))
|
|
110
|
+
return MONTH
|
|
111
|
+
|
|
112
|
+
async def get_power_by_day(self, dayTime):
|
|
113
|
+
self.calls.append(("by_day", dayTime))
|
|
114
|
+
return DAY
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_fetch_uses_the_library_call_for_each_period_and_caches():
|
|
118
|
+
c, cache = FakeClient(), ca.Cache()
|
|
119
|
+
run = lambda period, on, now=0.0: asyncio.run(
|
|
120
|
+
ca.fetch(c, period, on, today=TODAY, cache=cache, key="gw1", now=now))
|
|
121
|
+
run("week", date(2026, 11, 5))
|
|
122
|
+
run("dayhour", date(2026, 11, 5))
|
|
123
|
+
run("day5", date(2026, 11, 5)) # same day: cached
|
|
124
|
+
assert c.calls == [("details", 2, "2026-11-02"), ("by_day", "2026-11-05")]
|
|
125
|
+
run("month", date(2026, 11, 5)) # current month
|
|
126
|
+
run("month", date(2026, 11, 5), now=ca.Cache.CURRENT_S + 1) # stale → fetched again
|
|
127
|
+
run("month", date(2026, 10, 5))
|
|
128
|
+
run("month", date(2026, 10, 5), now=ca.Cache.CURRENT_S + 1) # finished month: still cached
|
|
129
|
+
assert c.calls[2:] == [("details", 3, "2026-11-01"), ("details", 3, "2026-11-01"),
|
|
130
|
+
("details", 3, "2026-10-01")]
|