vledger 0.1.0__py3-none-any.whl
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.
- vledger/__init__.py +13 -0
- vledger/cli.py +255 -0
- vledger/clock.py +37 -0
- vledger/config.py +184 -0
- vledger/l0.py +396 -0
- vledger/layout.py +88 -0
- vledger-0.1.0.dist-info/METADATA +158 -0
- vledger-0.1.0.dist-info/RECORD +12 -0
- vledger-0.1.0.dist-info/WHEEL +5 -0
- vledger-0.1.0.dist-info/entry_points.txt +2 -0
- vledger-0.1.0.dist-info/licenses/LICENSE +28 -0
- vledger-0.1.0.dist-info/top_level.txt +1 -0
vledger/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# SPDX-License-Identifier: BSD-3-Clause
|
|
2
|
+
"""vledger — the vehicle ledger library.
|
|
3
|
+
|
|
4
|
+
Everything the integration derives is derived here, and only here
|
|
5
|
+
(ARC-02). The library knows nothing of Home Assistant (ARC-03) and depends
|
|
6
|
+
on nothing outside the standard library (CLI-06).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
#: One version number for the library, the integration and the derivation
|
|
10
|
+
#: logic recorded in L1 (ADR-0002, ABL-07). pyproject.toml and
|
|
11
|
+
#: custom_components/vledger/manifest.json carry the same string; a test
|
|
12
|
+
#: keeps the three in step.
|
|
13
|
+
__version__ = "0.1.0"
|
vledger/cli.py
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
# SPDX-License-Identifier: BSD-3-Clause
|
|
2
|
+
"""The ``vledger`` command line.
|
|
3
|
+
|
|
4
|
+
Every operation the library offers is a verb here, grouped by noun
|
|
5
|
+
(ADR-0005): ``vledger l0 …`` for the raw log. A verb reads and writes the
|
|
6
|
+
directory layout under ``--base`` and prints what it did. The library does
|
|
7
|
+
the work; this module parses arguments and spells results.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import sys
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from vledger import __version__, clock, l0
|
|
19
|
+
from vledger.layout import Subject
|
|
20
|
+
|
|
21
|
+
ENV_BASE = "VLEDGER_BASE"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Usage(Exception):
|
|
25
|
+
"""A complaint about the arguments, printed without a traceback."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# --- shared pieces ---------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
def _subject(args) -> Subject:
|
|
31
|
+
if args.vehicle and args.chargepoint:
|
|
32
|
+
raise Usage("--vehicle and --chargepoint exclude each other")
|
|
33
|
+
if args.vehicle:
|
|
34
|
+
return Subject("vehicle", args.vehicle)
|
|
35
|
+
if args.chargepoint:
|
|
36
|
+
return Subject("chargepoint", args.chargepoint)
|
|
37
|
+
raise Usage("name the stream: --vehicle ID or --chargepoint ID")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _base(args) -> Path:
|
|
41
|
+
return Path(args.base or os.environ.get(ENV_BASE) or ".")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _t(args) -> str:
|
|
45
|
+
return args.t or clock.to_text(clock.now())
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _json_arg(text: str):
|
|
49
|
+
"""JSON from the argument itself, from a file, or from stdin (``-``)."""
|
|
50
|
+
if text == "-":
|
|
51
|
+
return json.load(sys.stdin)
|
|
52
|
+
p = Path(text)
|
|
53
|
+
if p.is_file():
|
|
54
|
+
return json.loads(p.read_text(encoding="utf-8"))
|
|
55
|
+
try:
|
|
56
|
+
return json.loads(text)
|
|
57
|
+
except json.JSONDecodeError:
|
|
58
|
+
raise Usage(f"neither JSON, a file nor '-': {text!r}") from None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _kv(pairs: list[str]) -> dict:
|
|
62
|
+
out = {}
|
|
63
|
+
for pair in pairs or []:
|
|
64
|
+
if "=" not in pair:
|
|
65
|
+
raise Usage(f"expected key=value, got {pair!r}")
|
|
66
|
+
k, v = pair.split("=", 1)
|
|
67
|
+
try:
|
|
68
|
+
out[k] = json.loads(v)
|
|
69
|
+
except json.JSONDecodeError:
|
|
70
|
+
out[k] = v
|
|
71
|
+
return out
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _add_stream_args(sp: argparse.ArgumentParser) -> None:
|
|
75
|
+
sp.add_argument("--base", help=f"the data directory (default: ${ENV_BASE} or .)")
|
|
76
|
+
g = sp.add_mutually_exclusive_group()
|
|
77
|
+
g.add_argument("--vehicle", metavar="ID", help="the vehicle's subject id")
|
|
78
|
+
g.add_argument("--chargepoint", metavar="ID", help="the charge point's subject id")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _add_t(sp: argparse.ArgumentParser) -> None:
|
|
82
|
+
sp.add_argument("--t", help="the event's time, UTC ISO 8601 (default: now)")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# --- l0 verbs --------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
def cmd_l0_state(args) -> int:
|
|
88
|
+
subject = _subject(args)
|
|
89
|
+
line = l0.state(_t(args), subject, args.role, args.entity, args.state,
|
|
90
|
+
unit=args.unit, attrs=_kv(args.attr), measured_at=args.measured_at)
|
|
91
|
+
path = l0.append(_base(args), subject, line)
|
|
92
|
+
print(f"{path.name}: {l0.encode(line)}")
|
|
93
|
+
return 0
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def cmd_l0_start(args) -> int:
|
|
97
|
+
subject = _subject(args)
|
|
98
|
+
snapshot = _json_arg(args.snapshot) if args.snapshot else []
|
|
99
|
+
line = l0.start(_t(args), subject, vledger=args.vledger or __version__,
|
|
100
|
+
homeassistant=args.homeassistant, snapshot=snapshot)
|
|
101
|
+
path = l0.append(_base(args), subject, line)
|
|
102
|
+
print(f"{path.name}: start, {len(snapshot)} role(s) in the snapshot")
|
|
103
|
+
return 0
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def cmd_l0_stop(args) -> int:
|
|
107
|
+
subject = _subject(args)
|
|
108
|
+
line = l0.stop(_t(args), subject, reason=args.reason)
|
|
109
|
+
path = l0.append(_base(args), subject, line)
|
|
110
|
+
print(f"{path.name}: stop ({args.reason})")
|
|
111
|
+
return 0
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def cmd_l0_heartbeat(args) -> int:
|
|
115
|
+
subject = _subject(args)
|
|
116
|
+
line = l0.heartbeat(_t(args), subject, lines=args.lines)
|
|
117
|
+
path = l0.append(_base(args), subject, line)
|
|
118
|
+
print(f"{path.name}: heartbeat, {args.lines} line(s) since start")
|
|
119
|
+
return 0
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def cmd_l0_config(args) -> int:
|
|
123
|
+
subject = _subject(args)
|
|
124
|
+
line = l0.config(_t(args), subject, config=_json_arg(args.config))
|
|
125
|
+
path = l0.append(_base(args), subject, line)
|
|
126
|
+
print(f"{path.name}: config, {len(line['config'])} key(s)")
|
|
127
|
+
return 0
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def cmd_l0_read(args) -> int:
|
|
131
|
+
n = 0
|
|
132
|
+
for r in l0.read(_base(args), _subject(args), kind=args.kind, role=args.role,
|
|
133
|
+
since=args.since, until=args.until):
|
|
134
|
+
print(l0.encode(r.line))
|
|
135
|
+
n += 1
|
|
136
|
+
print(f"{n} line(s)", file=sys.stderr)
|
|
137
|
+
return 0
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def cmd_l0_validate(args) -> int:
|
|
141
|
+
report = l0.validate(_base(args), _subject(args))
|
|
142
|
+
if args.json:
|
|
143
|
+
print(json.dumps({
|
|
144
|
+
"files": report.files, "lines": report.lines, "by_kind": report.by_kind,
|
|
145
|
+
"versions": sorted(report.versions),
|
|
146
|
+
"problems": [p.__dict__ for p in report.problems],
|
|
147
|
+
}, indent=2))
|
|
148
|
+
else:
|
|
149
|
+
kinds = ", ".join(f"{k} {n}" for k, n in sorted(report.by_kind.items()))
|
|
150
|
+
versions = ", ".join(str(v) for v in sorted(report.versions)) or "none"
|
|
151
|
+
print(f"{report.files} file(s), {report.lines} line(s): {kinds or 'nothing'}")
|
|
152
|
+
print(f"schema version(s): {versions}")
|
|
153
|
+
for p in report.problems:
|
|
154
|
+
print(f" [{p.severity}] {p.where}: {p.what}")
|
|
155
|
+
print(f"{report.errors} error(s), {len(report.problems) - report.errors} warning(s)")
|
|
156
|
+
return 1 if report.errors else 0
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def cmd_l0_gaps(args) -> int:
|
|
160
|
+
found = l0.gaps(_base(args), _subject(args), tolerance_s=args.tolerance,
|
|
161
|
+
now=args.now, min_s=args.min)
|
|
162
|
+
if args.json:
|
|
163
|
+
print(json.dumps([g.__dict__ for g in found], indent=2))
|
|
164
|
+
else:
|
|
165
|
+
for g in found:
|
|
166
|
+
print(f"{g.start} {g.end} {g.seconds:10.0f} s {g.reason}")
|
|
167
|
+
print(f"{len(found)} gap(s)", file=sys.stderr)
|
|
168
|
+
return 0
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
# --- the parser ------------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
174
|
+
parser = argparse.ArgumentParser(prog="vledger", description="The vehicle ledger")
|
|
175
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
176
|
+
nouns = parser.add_subparsers(dest="noun", metavar="<noun>", required=True)
|
|
177
|
+
|
|
178
|
+
p_l0 = nouns.add_parser("l0", help="the raw log: write, read, validate, gaps")
|
|
179
|
+
verbs = p_l0.add_subparsers(dest="verb", metavar="<verb>", required=True)
|
|
180
|
+
|
|
181
|
+
sp = verbs.add_parser("state", help="append one state change")
|
|
182
|
+
_add_stream_args(sp)
|
|
183
|
+
_add_t(sp)
|
|
184
|
+
sp.add_argument("--role", required=True, choices=l0.ROLES)
|
|
185
|
+
sp.add_argument("--entity", required=True, help="the entity id")
|
|
186
|
+
sp.add_argument("--state", required=True, help="the state string, as Home Assistant holds it")
|
|
187
|
+
sp.add_argument("--unit", help="unit_of_measurement, when the entity has one")
|
|
188
|
+
sp.add_argument("--attr", action="append", metavar="KEY=VALUE",
|
|
189
|
+
help="an attribute; only the role-relevant ones are kept")
|
|
190
|
+
sp.add_argument("--measured-at", help="the source's own measurement time, if it gives one")
|
|
191
|
+
sp.set_defaults(func=cmd_l0_state)
|
|
192
|
+
|
|
193
|
+
sp = verbs.add_parser("start", help="capture begins: write the start marker and snapshot")
|
|
194
|
+
_add_stream_args(sp)
|
|
195
|
+
_add_t(sp)
|
|
196
|
+
sp.add_argument("--vledger", help="library version (default: this one)")
|
|
197
|
+
sp.add_argument("--homeassistant", required=True, help="Home Assistant version")
|
|
198
|
+
sp.add_argument("--snapshot", help="JSON list of {role, entity, state, unit?, since}; "
|
|
199
|
+
"inline, a file, or - for stdin")
|
|
200
|
+
sp.set_defaults(func=cmd_l0_start)
|
|
201
|
+
|
|
202
|
+
sp = verbs.add_parser("stop", help="capture ends orderly: write the stop marker")
|
|
203
|
+
_add_stream_args(sp)
|
|
204
|
+
_add_t(sp)
|
|
205
|
+
sp.add_argument("--reason", required=True, choices=l0.STOP_REASONS)
|
|
206
|
+
sp.set_defaults(func=cmd_l0_stop)
|
|
207
|
+
|
|
208
|
+
sp = verbs.add_parser("heartbeat", help="write a heartbeat")
|
|
209
|
+
_add_stream_args(sp)
|
|
210
|
+
_add_t(sp)
|
|
211
|
+
sp.add_argument("--lines", type=int, required=True, help="state lines since start")
|
|
212
|
+
sp.set_defaults(func=cmd_l0_heartbeat)
|
|
213
|
+
|
|
214
|
+
sp = verbs.add_parser("config", help="write the subject's complete configuration")
|
|
215
|
+
_add_stream_args(sp)
|
|
216
|
+
_add_t(sp)
|
|
217
|
+
sp.add_argument("--config", required=True, help="JSON object; inline, a file, or - for stdin")
|
|
218
|
+
sp.set_defaults(func=cmd_l0_config)
|
|
219
|
+
|
|
220
|
+
sp = verbs.add_parser("read", help="print a stream as JSON Lines, in order")
|
|
221
|
+
_add_stream_args(sp)
|
|
222
|
+
sp.add_argument("--kind", choices=l0.KINDS)
|
|
223
|
+
sp.add_argument("--role", choices=l0.ROLES)
|
|
224
|
+
sp.add_argument("--since", help="first time to print, inclusive")
|
|
225
|
+
sp.add_argument("--until", help="last time to print, inclusive")
|
|
226
|
+
sp.set_defaults(func=cmd_l0_read)
|
|
227
|
+
|
|
228
|
+
sp = verbs.add_parser("validate", help="check a stream against the schema and report")
|
|
229
|
+
_add_stream_args(sp)
|
|
230
|
+
sp.add_argument("--json", action="store_true", help="the report as JSON")
|
|
231
|
+
sp.set_defaults(func=cmd_l0_validate)
|
|
232
|
+
|
|
233
|
+
sp = verbs.add_parser("gaps", help="list the capture gaps the markers reveal")
|
|
234
|
+
_add_stream_args(sp)
|
|
235
|
+
sp.add_argument("--tolerance", type=float, default=300,
|
|
236
|
+
help="seconds a heartbeat may be late (default 300)")
|
|
237
|
+
sp.add_argument("--now", help="the time the stream is judged against (default: now)")
|
|
238
|
+
sp.add_argument("--min", type=float, default=0, help="shortest gap to list, seconds")
|
|
239
|
+
sp.add_argument("--json", action="store_true", help="the gaps as JSON")
|
|
240
|
+
sp.set_defaults(func=cmd_l0_gaps)
|
|
241
|
+
|
|
242
|
+
return parser
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def main(argv: list[str] | None = None) -> int:
|
|
246
|
+
args = build_parser().parse_args(argv)
|
|
247
|
+
try:
|
|
248
|
+
return args.func(args)
|
|
249
|
+
except (Usage, ValueError, TypeError, l0.TornLine, FileNotFoundError) as e:
|
|
250
|
+
print(f"vledger: {e}", file=sys.stderr)
|
|
251
|
+
return 2
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
if __name__ == "__main__":
|
|
255
|
+
sys.exit(main())
|
vledger/clock.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# SPDX-License-Identifier: BSD-3-Clause
|
|
2
|
+
"""Time as L0 spells it: UTC, ISO 8601, milliseconds, ``Z`` (ADR-0004).
|
|
3
|
+
|
|
4
|
+
Everything that touches a timestamp goes through here, so there is one
|
|
5
|
+
spelling and one parser. The library never asks the clock on its own —
|
|
6
|
+
Home Assistant gives the time of every event — except in the CLI, where
|
|
7
|
+
``now()`` is the default a human did not type.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from datetime import UTC, datetime
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def now() -> datetime:
|
|
16
|
+
return datetime.now(UTC)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def to_text(t: datetime) -> str:
|
|
20
|
+
"""``2026-10-09T07:12:03.412Z`` — always UTC, always milliseconds."""
|
|
21
|
+
if t.tzinfo is None:
|
|
22
|
+
raise ValueError("a naive datetime has no place in L0")
|
|
23
|
+
t = t.astimezone(UTC)
|
|
24
|
+
return t.strftime("%Y-%m-%dT%H:%M:%S.") + f"{t.microsecond // 1000:03d}Z"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def parse(text: str) -> datetime:
|
|
28
|
+
"""The inverse of :func:`to_text`; tolerant of what ISO 8601 allows."""
|
|
29
|
+
t = datetime.fromisoformat(text)
|
|
30
|
+
if t.tzinfo is None:
|
|
31
|
+
raise ValueError(f"timestamp without a zone: {text!r}")
|
|
32
|
+
return t.astimezone(UTC)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def month_of(text: str) -> str:
|
|
36
|
+
"""``2026-10`` — the month file a line belongs to, by its own time."""
|
|
37
|
+
return to_text(parse(text))[:7]
|
vledger/config.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
# SPDX-License-Identifier: BSD-3-Clause
|
|
2
|
+
"""The configuration of a vehicle or a charge point, as the ``config`` line
|
|
3
|
+
holds it (ADR-0007): the defaults, the vocabulary, and the two questions
|
|
4
|
+
asked of it — is it complete, and which tariff holds at a time.
|
|
5
|
+
|
|
6
|
+
The integration edits this structure in its options flow and writes it to
|
|
7
|
+
L0 verbatim; the derivation reads it back from L0. Both import what is
|
|
8
|
+
here rather than restating it (ADR-0005, consequence 2).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from copy import deepcopy
|
|
14
|
+
from datetime import date
|
|
15
|
+
|
|
16
|
+
from vledger import clock
|
|
17
|
+
from vledger.l0 import CHARGEPOINT_ROLES, VEHICLE_ROLES
|
|
18
|
+
|
|
19
|
+
#: A role whose change means the vehicle moved; one is mandatory (FZG-02).
|
|
20
|
+
MOVEMENT_ROLES = ("odometer", "position", "position_latitude",
|
|
21
|
+
"position_longitude", "trip_distance")
|
|
22
|
+
|
|
23
|
+
#: The enumerated roles and the one positive domain state each maps to
|
|
24
|
+
#: (FZG-05). A source value not mapped, and unavailable or unknown, holds
|
|
25
|
+
#: the last known domain state.
|
|
26
|
+
DOMAIN_STATES = {"charging_state": "charging", "plug_state": "plugged", "ignition": "on"}
|
|
27
|
+
|
|
28
|
+
FUELS = ("petrol", "diesel")
|
|
29
|
+
|
|
30
|
+
#: The thresholds and time constants of FZG-06 with the requirements'
|
|
31
|
+
#: defaults, units in the key. Always written out in full (ADR-0007).
|
|
32
|
+
DEFAULT_THRESHOLDS = {
|
|
33
|
+
"t_still_s": 1800, # FAH-01
|
|
34
|
+
"refuel_threshold_l": 3, # TNK-01
|
|
35
|
+
"t_settle_s": 360, # TNK-02
|
|
36
|
+
"charging_threshold_pct": 2, # LAD-04
|
|
37
|
+
"matching_tolerance_s": 21600, # BEL-05
|
|
38
|
+
"plausibility_pct": 15, # BEL-08
|
|
39
|
+
"heartbeat_s": 3600, # ERF-04
|
|
40
|
+
"outage_s": 86400, # HAI-08
|
|
41
|
+
"rolling_period_d": 30, # VER-06
|
|
42
|
+
"consumption_error_pct": 5, # VER-10
|
|
43
|
+
"heating_value_kwh_per_l": 8.9, # VER-05, petrol; diesel 9.8
|
|
44
|
+
"beta_per_k": 9.5e-4, # VER-08, petrol; diesel 8.0e-4
|
|
45
|
+
"temperature_tau_s": 10800, # VER-08
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
#: Per fuel, the thresholds whose default depends on it.
|
|
49
|
+
FUEL_THRESHOLDS = {
|
|
50
|
+
"petrol": {"heating_value_kwh_per_l": 8.9, "beta_per_k": 9.5e-4},
|
|
51
|
+
"diesel": {"heating_value_kwh_per_l": 9.8, "beta_per_k": 8.0e-4},
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
#: The vehicle parameters (FZG-04 and later), ``None`` when not set.
|
|
55
|
+
DEFAULT_PARAMETERS = {
|
|
56
|
+
"fuel": None,
|
|
57
|
+
"tank_capacity_l": None,
|
|
58
|
+
"battery_net_kwh": None,
|
|
59
|
+
"fuel_level_resolution_l": None, # measured on the reference vehicle, TASK-0002
|
|
60
|
+
"charging_loss_factor": 1.12, # LAD-06
|
|
61
|
+
"eta_el": 0.85, # VER-05
|
|
62
|
+
"eta_ice": 0.28, # VER-05, petrol; diesel 0.33
|
|
63
|
+
"charge_cycles_start": 0, # VER-11
|
|
64
|
+
"tank_fills_start": 0, # VER-11
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
FUEL_PARAMETERS = {"petrol": {"eta_ice": 0.28}, "diesel": {"eta_ice": 0.33}}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def vehicle(name: str, roles: dict, parameters: dict | None = None,
|
|
71
|
+
thresholds: dict | None = None) -> dict:
|
|
72
|
+
"""A vehicle's configuration, complete: what is given over the defaults.
|
|
73
|
+
|
|
74
|
+
``roles`` maps a role to ``{"entity": ..., "map"?: ..., "measured_at"?: ...}``.
|
|
75
|
+
Fuel-dependent defaults follow the fuel given; an explicit value always
|
|
76
|
+
wins. Refuses what the requirements refuse (FZG-02, FZG-05).
|
|
77
|
+
"""
|
|
78
|
+
for role, spec in roles.items():
|
|
79
|
+
if role not in VEHICLE_ROLES:
|
|
80
|
+
raise ValueError(f"unknown vehicle role {role!r}")
|
|
81
|
+
if not isinstance(spec, dict) or not spec.get("entity"):
|
|
82
|
+
raise ValueError(f"role {role!r} names no entity")
|
|
83
|
+
if role in DOMAIN_STATES:
|
|
84
|
+
positive = DOMAIN_STATES[role]
|
|
85
|
+
m = spec.get("map") or {}
|
|
86
|
+
if set(m) - {positive}:
|
|
87
|
+
raise ValueError(f"role {role!r} maps states other than {positive!r}")
|
|
88
|
+
elif "map" in spec:
|
|
89
|
+
raise ValueError(f"role {role!r} is not enumerated and takes no map")
|
|
90
|
+
if not any(r in MOVEMENT_ROLES for r in roles):
|
|
91
|
+
raise ValueError(f"a vehicle needs a movement role: one of {MOVEMENT_ROLES}")
|
|
92
|
+
params = dict(DEFAULT_PARAMETERS)
|
|
93
|
+
given = parameters or {}
|
|
94
|
+
fuel = given.get("fuel", params["fuel"])
|
|
95
|
+
if fuel is not None and fuel not in FUELS:
|
|
96
|
+
raise ValueError(f"fuel must be one of {FUELS}, not {fuel!r}")
|
|
97
|
+
if fuel:
|
|
98
|
+
params.update(FUEL_PARAMETERS[fuel])
|
|
99
|
+
unknown = set(given) - set(params)
|
|
100
|
+
if unknown:
|
|
101
|
+
raise ValueError(f"unknown parameters: {sorted(unknown)}")
|
|
102
|
+
params.update(given)
|
|
103
|
+
thr = dict(DEFAULT_THRESHOLDS)
|
|
104
|
+
if fuel:
|
|
105
|
+
thr.update(FUEL_THRESHOLDS[fuel])
|
|
106
|
+
given_thr = thresholds or {}
|
|
107
|
+
unknown = set(given_thr) - set(thr)
|
|
108
|
+
if unknown:
|
|
109
|
+
raise ValueError(f"unknown thresholds: {sorted(unknown)}")
|
|
110
|
+
thr.update(given_thr)
|
|
111
|
+
return {"name": name, "roles": deepcopy(roles), "parameters": params, "thresholds": thr}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def chargepoint(name: str, latitude: float, longitude: float, radius_m: float,
|
|
115
|
+
tariffs: list[dict], meter: dict | None = None) -> dict:
|
|
116
|
+
"""A charge point's configuration (LAD-01, LAD-02)."""
|
|
117
|
+
if radius_m <= 0:
|
|
118
|
+
raise ValueError("radius_m must be positive")
|
|
119
|
+
if meter is not None and (not isinstance(meter, dict) or not meter.get("entity")):
|
|
120
|
+
raise ValueError("meter names no entity")
|
|
121
|
+
for t in tariffs:
|
|
122
|
+
date.fromisoformat(t["from"])
|
|
123
|
+
if t["eur_per_kwh"] < 0:
|
|
124
|
+
raise ValueError("a tariff is not negative")
|
|
125
|
+
return {"name": name, "latitude": float(latitude), "longitude": float(longitude),
|
|
126
|
+
"radius_m": float(radius_m), "meter": deepcopy(meter), "tariffs": deepcopy(tariffs)}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def missing(config: dict) -> list[str]:
|
|
130
|
+
"""What an enabled derivation would need and the vehicle does not set.
|
|
131
|
+
|
|
132
|
+
The configuration never refuses an incomplete vehicle (ADR-0007); this
|
|
133
|
+
names what will come out flagged or empty, so the flow can say so.
|
|
134
|
+
"""
|
|
135
|
+
out = []
|
|
136
|
+
roles, p = config["roles"], config["parameters"]
|
|
137
|
+
if "fuel_level" in roles and p["tank_capacity_l"] is None:
|
|
138
|
+
out.append("tank_capacity_l: fuel_level needs it to convert % and to count tank fills")
|
|
139
|
+
if "fuel_level" in roles and p["fuel"] is None:
|
|
140
|
+
out.append("fuel: consumption metrics need the heating value")
|
|
141
|
+
if "soc" in roles and p["battery_net_kwh"] is None:
|
|
142
|
+
out.append("battery_net_kwh: battery-side energy and charging loss need it")
|
|
143
|
+
if "fuel_level" in roles and p["fuel_level_resolution_l"] is None:
|
|
144
|
+
out.append("fuel_level_resolution_l: the consumption error threshold needs it")
|
|
145
|
+
return out
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def tariff_at(tariffs: list[dict], t: str) -> dict | None:
|
|
149
|
+
"""The tariff valid at time ``t`` (ADR-0007, point 2; LAD-02, VER-07).
|
|
150
|
+
|
|
151
|
+
The entry with the greatest ``from`` not after ``t``'s date; among
|
|
152
|
+
equal ``from``, the one appended last — which is how a wrongly entered
|
|
153
|
+
tariff is corrected. ``None`` before the first tariff.
|
|
154
|
+
"""
|
|
155
|
+
day = clock.parse(t).date()
|
|
156
|
+
best = None
|
|
157
|
+
for entry in tariffs:
|
|
158
|
+
start = date.fromisoformat(entry["from"])
|
|
159
|
+
if start <= day and (best is None or start >= date.fromisoformat(best["from"])):
|
|
160
|
+
best = entry
|
|
161
|
+
return best
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def domain_state(role: str, value: str, mapping: dict) -> str | None:
|
|
165
|
+
"""The domain state a source value means, or ``None`` to hold (FZG-05)."""
|
|
166
|
+
positive = DOMAIN_STATES[role]
|
|
167
|
+
if value in ("unavailable", "unknown"):
|
|
168
|
+
return None
|
|
169
|
+
if value in (mapping.get(positive) or []):
|
|
170
|
+
return positive
|
|
171
|
+
if value in (mapping.get("not") or []):
|
|
172
|
+
return f"not_{positive}"
|
|
173
|
+
return None
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def is_chargepoint(config: dict) -> bool:
|
|
177
|
+
return "tariffs" in config
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
__all__ = [
|
|
181
|
+
"CHARGEPOINT_ROLES", "DEFAULT_PARAMETERS", "DEFAULT_THRESHOLDS", "DOMAIN_STATES",
|
|
182
|
+
"FUELS", "MOVEMENT_ROLES", "VEHICLE_ROLES", "chargepoint", "domain_state",
|
|
183
|
+
"is_chargepoint", "missing", "tariff_at", "vehicle",
|
|
184
|
+
]
|
vledger/l0.py
ADDED
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
# SPDX-License-Identifier: BSD-3-Clause
|
|
2
|
+
"""L0, the raw log: writing it, reading it back, checking it, and the
|
|
3
|
+
capture gaps its markers reveal (ADR-0004).
|
|
4
|
+
|
|
5
|
+
A line is a plain ``dict`` — the JSON object itself — and this module is
|
|
6
|
+
the only place that knows which keys it has. Nothing here interprets a
|
|
7
|
+
state: values stay the strings Home Assistant reported (ERF-11).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
from collections.abc import Iterator
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from vledger import clock, layout
|
|
19
|
+
from vledger.layout import Subject
|
|
20
|
+
|
|
21
|
+
#: The schema version this module writes, and the highest it reads.
|
|
22
|
+
VERSION = 1
|
|
23
|
+
|
|
24
|
+
KINDS = ("state", "start", "stop", "heartbeat", "config")
|
|
25
|
+
STOP_REASONS = ("shutdown", "unload", "reload")
|
|
26
|
+
|
|
27
|
+
#: The roles a vehicle's entities may be assigned to, and a charge point's.
|
|
28
|
+
#: The sensor-pair variant of position logs under two roles of its own.
|
|
29
|
+
VEHICLE_ROLES = (
|
|
30
|
+
"odometer", "position", "position_latitude", "position_longitude",
|
|
31
|
+
"trip_distance", "fuel_level", "soc", "charging_state", "plug_state",
|
|
32
|
+
"ignition", "outside_temperature", "fuel_price",
|
|
33
|
+
)
|
|
34
|
+
CHARGEPOINT_ROLES = ("energy_meter", "power")
|
|
35
|
+
ROLES = VEHICLE_ROLES + CHARGEPOINT_ROLES
|
|
36
|
+
|
|
37
|
+
#: The role-relevant attributes, per role: what a state line carries in
|
|
38
|
+
#: ``attrs`` and what counts as a change worth a line (ERF-01). Fixed for
|
|
39
|
+
#: schema version 1 — adding to it is a version bump (ADR-0004, consequence 1).
|
|
40
|
+
RELEVANT_ATTRS: dict[str, tuple[str, ...]] = {
|
|
41
|
+
"position": ("latitude", "longitude", "gps_accuracy", "source_type"),
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
#: The heartbeat interval a stream is read with when no config line says
|
|
45
|
+
#: otherwise (ERF-04); seconds.
|
|
46
|
+
DEFAULT_HEARTBEAT_S = 3600
|
|
47
|
+
|
|
48
|
+
Line = dict
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def relevant_attrs(role: str, attributes: dict) -> dict:
|
|
52
|
+
"""The subset of an entity's attributes a state line of ``role`` carries."""
|
|
53
|
+
keep = RELEVANT_ATTRS.get(role, ())
|
|
54
|
+
return {k: attributes[k] for k in keep if k in attributes}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# --- building lines --------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
def _envelope(t: str, kind: str, subject: Subject) -> Line:
|
|
60
|
+
clock.parse(t) # refuse a timestamp L0 could not spell
|
|
61
|
+
return {"v": VERSION, "t": t, "kind": kind, "subject": subject.id}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def state(t: str, subject: Subject, role: str, entity: str, value: str, *,
|
|
65
|
+
unit: str | None = None, attrs: dict | None = None,
|
|
66
|
+
measured_at: str | None = None) -> Line:
|
|
67
|
+
"""One change of state or of a role-relevant attribute (ERF-01, ERF-02)."""
|
|
68
|
+
if role not in ROLES:
|
|
69
|
+
raise ValueError(f"unknown role {role!r}")
|
|
70
|
+
if not isinstance(value, str):
|
|
71
|
+
raise TypeError("a state is the string Home Assistant holds, not a number")
|
|
72
|
+
line = _envelope(t, "state", subject)
|
|
73
|
+
line.update(role=role, entity=entity, state=value)
|
|
74
|
+
if unit is not None:
|
|
75
|
+
line["unit"] = unit
|
|
76
|
+
kept = relevant_attrs(role, attrs or {})
|
|
77
|
+
if kept:
|
|
78
|
+
line["attrs"] = kept
|
|
79
|
+
if measured_at is not None:
|
|
80
|
+
clock.parse(measured_at)
|
|
81
|
+
line["measured_at"] = measured_at
|
|
82
|
+
return line
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def start(t: str, subject: Subject, *, vledger: str, homeassistant: str,
|
|
86
|
+
snapshot: list[dict]) -> Line:
|
|
87
|
+
"""Capture begins: a snapshot of every assigned role as it stands (ERF-04)."""
|
|
88
|
+
for entry in snapshot:
|
|
89
|
+
missing = {"role", "entity", "state", "since"} - set(entry)
|
|
90
|
+
if missing:
|
|
91
|
+
raise ValueError(f"snapshot entry lacks {sorted(missing)}: {entry}")
|
|
92
|
+
clock.parse(entry["since"])
|
|
93
|
+
line = _envelope(t, "start", subject)
|
|
94
|
+
line.update(vledger=vledger, homeassistant=homeassistant, snapshot=list(snapshot))
|
|
95
|
+
return line
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def stop(t: str, subject: Subject, *, reason: str) -> Line:
|
|
99
|
+
"""Orderly end of capture (ERF-04)."""
|
|
100
|
+
if reason not in STOP_REASONS:
|
|
101
|
+
raise ValueError(f"stop reason must be one of {STOP_REASONS}, not {reason!r}")
|
|
102
|
+
line = _envelope(t, "stop", subject)
|
|
103
|
+
line["reason"] = reason
|
|
104
|
+
return line
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def heartbeat(t: str, subject: Subject, *, lines: int) -> Line:
|
|
108
|
+
"""Proof of life at a fixed interval; ``lines`` counts state lines since start."""
|
|
109
|
+
line = _envelope(t, "heartbeat", subject)
|
|
110
|
+
line["lines"] = int(lines)
|
|
111
|
+
return line
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def config(t: str, subject: Subject, *, config: dict) -> Line:
|
|
115
|
+
"""The subject's complete configuration, at start and on every change (ERF-05)."""
|
|
116
|
+
if not isinstance(config, dict):
|
|
117
|
+
raise TypeError("a config line carries the configuration as an object")
|
|
118
|
+
line = _envelope(t, "config", subject)
|
|
119
|
+
line["config"] = config
|
|
120
|
+
return line
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# --- writing ---------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
def encode(line: Line) -> str:
|
|
126
|
+
"""One line of JSON, compact, UTF-8 as is, no trailing newline."""
|
|
127
|
+
return json.dumps(line, ensure_ascii=False, separators=(",", ":"))
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def append(base: Path, subject: Subject, line: Line) -> Path:
|
|
131
|
+
"""Append one line to the month file its own time names (ADR-0004, point 3).
|
|
132
|
+
|
|
133
|
+
Written whole and flushed to disk before returning: a line is on disk or
|
|
134
|
+
it is not, and the reader's torn-line rule (ERF-07) covers the crash in
|
|
135
|
+
between. Blocking — the integration calls this off the event loop.
|
|
136
|
+
"""
|
|
137
|
+
if line.get("subject") != subject.id:
|
|
138
|
+
raise ValueError("line and stream disagree on the subject")
|
|
139
|
+
path = layout.l0_file(base, subject, clock.month_of(line["t"]))
|
|
140
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
141
|
+
with open(path, "a", encoding="utf-8") as f:
|
|
142
|
+
f.write(encode(line) + "\n")
|
|
143
|
+
f.flush()
|
|
144
|
+
os.fsync(f.fileno())
|
|
145
|
+
return path
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
# --- reading ---------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
@dataclass(frozen=True)
|
|
151
|
+
class Read:
|
|
152
|
+
"""One line read back, with where it came from."""
|
|
153
|
+
|
|
154
|
+
line: Line
|
|
155
|
+
path: Path
|
|
156
|
+
number: int # 1-based line number in the file
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class TornLine(Exception):
|
|
160
|
+
"""A line that is not complete JSON and is not the file's last line."""
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _raw_lines(path: Path) -> Iterator[tuple[int, str, bool]]:
|
|
164
|
+
"""(number, text, is_last) for every line, including a torn last one."""
|
|
165
|
+
data = path.read_bytes()
|
|
166
|
+
if not data:
|
|
167
|
+
return
|
|
168
|
+
text = data.decode("utf-8", errors="replace")
|
|
169
|
+
parts = text.split("\n")
|
|
170
|
+
# A file the writer closed ends in "\n", so the split leaves an empty
|
|
171
|
+
# tail; a torn file does not.
|
|
172
|
+
torn_tail = parts[-1] != ""
|
|
173
|
+
if not torn_tail:
|
|
174
|
+
parts.pop()
|
|
175
|
+
for i, part in enumerate(parts, start=1):
|
|
176
|
+
yield i, part, i == len(parts) and torn_tail
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def read_file(path: Path) -> Iterator[Read]:
|
|
180
|
+
"""Every line of one month file, in order; a torn last line is skipped (ERF-07)."""
|
|
181
|
+
for number, text, torn in _raw_lines(path):
|
|
182
|
+
try:
|
|
183
|
+
line = json.loads(text)
|
|
184
|
+
except json.JSONDecodeError:
|
|
185
|
+
if torn:
|
|
186
|
+
return
|
|
187
|
+
raise TornLine(f"{path}:{number}: not JSON and not the last line") from None
|
|
188
|
+
if torn:
|
|
189
|
+
# Complete JSON without its newline: the crash came after the
|
|
190
|
+
# text and before the newline. The line is whole; keep it.
|
|
191
|
+
pass
|
|
192
|
+
if not isinstance(line, dict):
|
|
193
|
+
raise TornLine(f"{path}:{number}: not a JSON object")
|
|
194
|
+
if line.get("v", 0) > VERSION:
|
|
195
|
+
raise ValueError(
|
|
196
|
+
f"{path}:{number}: schema version {line.get('v')} is newer than "
|
|
197
|
+
f"this reader ({VERSION})"
|
|
198
|
+
)
|
|
199
|
+
yield Read(line, path, number)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def read(base: Path, subject: Subject, *, kind: str | None = None,
|
|
203
|
+
role: str | None = None, since: str | None = None,
|
|
204
|
+
until: str | None = None) -> Iterator[Read]:
|
|
205
|
+
"""A stream in order, across its month files, optionally narrowed.
|
|
206
|
+
|
|
207
|
+
Lines of a kind this version does not know are skipped here (ADR-0004,
|
|
208
|
+
point 1); :func:`validate` reports them.
|
|
209
|
+
"""
|
|
210
|
+
lo = clock.parse(since) if since else None
|
|
211
|
+
hi = clock.parse(until) if until else None
|
|
212
|
+
for path in layout.l0_files(base, subject):
|
|
213
|
+
for r in read_file(path):
|
|
214
|
+
if r.line.get("kind") not in KINDS:
|
|
215
|
+
continue
|
|
216
|
+
if kind and r.line["kind"] != kind:
|
|
217
|
+
continue
|
|
218
|
+
if role and r.line.get("role") != role:
|
|
219
|
+
continue
|
|
220
|
+
if lo or hi:
|
|
221
|
+
t = clock.parse(r.line["t"])
|
|
222
|
+
if lo and t < lo:
|
|
223
|
+
continue
|
|
224
|
+
if hi and t > hi:
|
|
225
|
+
continue
|
|
226
|
+
yield r
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
# --- validating ------------------------------------------------------------
|
|
230
|
+
|
|
231
|
+
@dataclass(frozen=True)
|
|
232
|
+
class Problem:
|
|
233
|
+
severity: str # "error" or "warning"
|
|
234
|
+
where: str # "<file>:<line>" or the file
|
|
235
|
+
what: str
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
@dataclass
|
|
239
|
+
class Report:
|
|
240
|
+
files: int = 0
|
|
241
|
+
lines: int = 0
|
|
242
|
+
by_kind: dict[str, int] = field(default_factory=dict)
|
|
243
|
+
versions: set[int] = field(default_factory=set)
|
|
244
|
+
problems: list[Problem] = field(default_factory=list)
|
|
245
|
+
|
|
246
|
+
@property
|
|
247
|
+
def errors(self) -> int:
|
|
248
|
+
return sum(1 for p in self.problems if p.severity == "error")
|
|
249
|
+
|
|
250
|
+
def problem(self, severity: str, where: str, what: str) -> None:
|
|
251
|
+
self.problems.append(Problem(severity, where, what))
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
_REQUIRED: dict[str, tuple[str, ...]] = {
|
|
255
|
+
"state": ("role", "entity", "state"),
|
|
256
|
+
"start": ("vledger", "homeassistant", "snapshot"),
|
|
257
|
+
"stop": ("reason",),
|
|
258
|
+
"heartbeat": ("lines",),
|
|
259
|
+
"config": ("config",),
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _check(line: Line, where: str, subject: Subject, month: str, report: Report) -> None:
|
|
264
|
+
for key in ("v", "t", "kind", "subject"):
|
|
265
|
+
if key not in line:
|
|
266
|
+
report.problem("error", where, f"missing envelope key {key!r}")
|
|
267
|
+
return
|
|
268
|
+
try:
|
|
269
|
+
clock.parse(line["t"])
|
|
270
|
+
except ValueError:
|
|
271
|
+
report.problem("error", where, f"unreadable t {line['t']!r}")
|
|
272
|
+
return
|
|
273
|
+
if clock.month_of(line["t"]) != month:
|
|
274
|
+
report.problem("error", where, f"t {line['t']} is not in month file {month}")
|
|
275
|
+
if line["subject"] != subject.id:
|
|
276
|
+
report.problem("error", where, f"subject {line['subject']!r} is not {subject.id!r}")
|
|
277
|
+
kind = line["kind"]
|
|
278
|
+
if kind not in KINDS:
|
|
279
|
+
report.problem("warning", where, f"unknown kind {kind!r}, skipped by readers")
|
|
280
|
+
return
|
|
281
|
+
for key in _REQUIRED[kind]:
|
|
282
|
+
if key not in line:
|
|
283
|
+
report.problem("error", where, f"{kind} line lacks {key!r}")
|
|
284
|
+
if kind == "state":
|
|
285
|
+
if line.get("role") not in ROLES:
|
|
286
|
+
report.problem("error", where, f"unknown role {line.get('role')!r}")
|
|
287
|
+
if not isinstance(line.get("state"), str):
|
|
288
|
+
report.problem("error", where, "state is not a string")
|
|
289
|
+
extra = set(line.get("attrs") or {}) - set(RELEVANT_ATTRS.get(line.get("role", ""), ()))
|
|
290
|
+
if extra:
|
|
291
|
+
report.problem("error", where, f"attrs not relevant to the role: {sorted(extra)}")
|
|
292
|
+
elif kind == "stop" and line.get("reason") not in STOP_REASONS:
|
|
293
|
+
report.problem("error", where, f"unknown stop reason {line.get('reason')!r}")
|
|
294
|
+
elif kind == "config" and not isinstance(line.get("config"), dict):
|
|
295
|
+
report.problem("error", where, "config is not an object")
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def validate(base: Path, subject: Subject) -> Report:
|
|
299
|
+
"""Check a stream against the schema and report (CLI-02).
|
|
300
|
+
|
|
301
|
+
Errors are lines the writer could not have written; warnings are lines
|
|
302
|
+
a reader will skip or that look odd (time running backwards).
|
|
303
|
+
"""
|
|
304
|
+
report = Report()
|
|
305
|
+
last_t = None
|
|
306
|
+
for path in layout.l0_files(base, subject):
|
|
307
|
+
report.files += 1
|
|
308
|
+
month = path.stem
|
|
309
|
+
for number, text, torn in _raw_lines(path):
|
|
310
|
+
where = f"{path.name}:{number}"
|
|
311
|
+
try:
|
|
312
|
+
line = json.loads(text)
|
|
313
|
+
except json.JSONDecodeError:
|
|
314
|
+
if torn:
|
|
315
|
+
report.problem("warning", where, "torn last line, skipped by readers")
|
|
316
|
+
else:
|
|
317
|
+
report.problem("error", where, "not JSON")
|
|
318
|
+
continue
|
|
319
|
+
if not isinstance(line, dict):
|
|
320
|
+
report.problem("error", where, "not a JSON object")
|
|
321
|
+
continue
|
|
322
|
+
report.lines += 1
|
|
323
|
+
v = line.get("v")
|
|
324
|
+
if isinstance(v, int):
|
|
325
|
+
report.versions.add(v)
|
|
326
|
+
if v > VERSION:
|
|
327
|
+
report.problem("error", where, f"schema version {v} is newer than this reader")
|
|
328
|
+
_check(line, where, subject, month, report)
|
|
329
|
+
kind = line.get("kind")
|
|
330
|
+
if kind in KINDS:
|
|
331
|
+
report.by_kind[kind] = report.by_kind.get(kind, 0) + 1
|
|
332
|
+
try:
|
|
333
|
+
t = clock.parse(line["t"])
|
|
334
|
+
except (KeyError, ValueError):
|
|
335
|
+
continue
|
|
336
|
+
if last_t and t < last_t:
|
|
337
|
+
report.problem("warning", where, "t runs backwards")
|
|
338
|
+
last_t = t
|
|
339
|
+
return report
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
# --- gaps ------------------------------------------------------------------
|
|
343
|
+
|
|
344
|
+
@dataclass(frozen=True)
|
|
345
|
+
class Gap:
|
|
346
|
+
"""A span in which nothing was captured (ERF-04)."""
|
|
347
|
+
|
|
348
|
+
start: str
|
|
349
|
+
end: str
|
|
350
|
+
seconds: float
|
|
351
|
+
reason: str # "crash", "stopped", "silence" or "open"
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def gaps(base: Path, subject: Subject, *, tolerance_s: float = 300,
|
|
355
|
+
now: str | None = None, min_s: float = 0) -> list[Gap]:
|
|
356
|
+
"""The capture gaps a stream's markers reveal (ADR-0004).
|
|
357
|
+
|
|
358
|
+
``crash``: a start without a stop before it — nothing from the last line
|
|
359
|
+
to the start. ``stopped``: an orderly stop and the next start — capture
|
|
360
|
+
was off. ``silence``: longer than the heartbeat interval plus tolerance
|
|
361
|
+
between two lines while running. ``open``: the stream ends without a
|
|
362
|
+
stop and ``now`` is further away than that — capture may have died.
|
|
363
|
+
The heartbeat interval comes from the latest config line
|
|
364
|
+
(``thresholds.heartbeat_s``), else :data:`DEFAULT_HEARTBEAT_S`.
|
|
365
|
+
"""
|
|
366
|
+
found: list[Gap] = []
|
|
367
|
+
last: str | None = None
|
|
368
|
+
running = False
|
|
369
|
+
interval = DEFAULT_HEARTBEAT_S
|
|
370
|
+
|
|
371
|
+
def add(a: str, b: str, reason: str) -> None:
|
|
372
|
+
seconds = (clock.parse(b) - clock.parse(a)).total_seconds()
|
|
373
|
+
if seconds >= min_s:
|
|
374
|
+
found.append(Gap(a, b, seconds, reason))
|
|
375
|
+
|
|
376
|
+
for r in read(base, subject):
|
|
377
|
+
line, t = r.line, r.line["t"]
|
|
378
|
+
kind = line["kind"]
|
|
379
|
+
if kind == "start":
|
|
380
|
+
if last is not None:
|
|
381
|
+
add(last, t, "crash" if running else "stopped")
|
|
382
|
+
running = True
|
|
383
|
+
elif last is not None and running:
|
|
384
|
+
if (clock.parse(t) - clock.parse(last)).total_seconds() > interval + tolerance_s:
|
|
385
|
+
add(last, t, "silence")
|
|
386
|
+
if kind == "config":
|
|
387
|
+
interval = (line.get("config", {}).get("thresholds") or {}).get(
|
|
388
|
+
"heartbeat_s", interval)
|
|
389
|
+
if kind == "stop":
|
|
390
|
+
running = False
|
|
391
|
+
last = t
|
|
392
|
+
if running and last is not None:
|
|
393
|
+
end = now or clock.to_text(clock.now())
|
|
394
|
+
if (clock.parse(end) - clock.parse(last)).total_seconds() > interval + tolerance_s:
|
|
395
|
+
add(last, end, "open")
|
|
396
|
+
return found
|
vledger/layout.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# SPDX-License-Identifier: BSD-3-Clause
|
|
2
|
+
"""Where things live on disk (ADR-0004, point 3).
|
|
3
|
+
|
|
4
|
+
::
|
|
5
|
+
|
|
6
|
+
<base>/
|
|
7
|
+
vehicle-<subject>/
|
|
8
|
+
l0/2026-10.jsonl one file per UTC month, by the line's own t
|
|
9
|
+
receipts.jsonl append-only, apart from L0
|
|
10
|
+
l1/… the derivation, regenerable
|
|
11
|
+
chargepoint-<subject>/
|
|
12
|
+
l0/2026-10.jsonl
|
|
13
|
+
l1/…
|
|
14
|
+
|
|
15
|
+
A subject is a UUID the integration minted; this module only knows how to
|
|
16
|
+
spell its directory.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import re
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
KINDS = ("vehicle", "chargepoint")
|
|
26
|
+
_DIR = re.compile(r"^(vehicle|chargepoint)-(.+)$")
|
|
27
|
+
_MONTH_FILE = re.compile(r"^(\d{4}-\d{2})\.jsonl$")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class Subject:
|
|
32
|
+
"""A vehicle or a charge point: what a stream belongs to."""
|
|
33
|
+
|
|
34
|
+
kind: str
|
|
35
|
+
id: str
|
|
36
|
+
|
|
37
|
+
def __post_init__(self) -> None:
|
|
38
|
+
if self.kind not in KINDS:
|
|
39
|
+
raise ValueError(f"subject kind must be one of {KINDS}, not {self.kind!r}")
|
|
40
|
+
if not self.id or "/" in self.id:
|
|
41
|
+
raise ValueError(f"not a subject id: {self.id!r}")
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def dirname(self) -> str:
|
|
45
|
+
return f"{self.kind}-{self.id}"
|
|
46
|
+
|
|
47
|
+
@classmethod
|
|
48
|
+
def from_dirname(cls, name: str) -> Subject:
|
|
49
|
+
m = _DIR.match(name)
|
|
50
|
+
if not m:
|
|
51
|
+
raise ValueError(f"not a subject directory: {name!r}")
|
|
52
|
+
return cls(m.group(1), m.group(2))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def subject_dir(base: Path, subject: Subject) -> Path:
|
|
56
|
+
return base / subject.dirname
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def l0_dir(base: Path, subject: Subject) -> Path:
|
|
60
|
+
return subject_dir(base, subject) / "l0"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def l0_file(base: Path, subject: Subject, month: str) -> Path:
|
|
64
|
+
"""The month file a line of time ``month`` (``YYYY-MM``) goes to."""
|
|
65
|
+
return l0_dir(base, subject) / f"{month}.jsonl"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def l0_files(base: Path, subject: Subject) -> list[Path]:
|
|
69
|
+
"""Every month file of a stream, oldest first; nothing else in the directory."""
|
|
70
|
+
d = l0_dir(base, subject)
|
|
71
|
+
if not d.is_dir():
|
|
72
|
+
return []
|
|
73
|
+
return sorted(p for p in d.iterdir() if _MONTH_FILE.match(p.name))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def receipts_file(base: Path, subject: Subject) -> Path:
|
|
77
|
+
return subject_dir(base, subject) / "receipts.jsonl"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def subjects(base: Path) -> list[Subject]:
|
|
81
|
+
"""Every subject under ``base``, by directory name."""
|
|
82
|
+
if not base.is_dir():
|
|
83
|
+
return []
|
|
84
|
+
found = []
|
|
85
|
+
for p in sorted(base.iterdir()):
|
|
86
|
+
if p.is_dir() and _DIR.match(p.name):
|
|
87
|
+
found.append(Subject.from_dirname(p.name))
|
|
88
|
+
return found
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: vledger
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Vehicle ledger: trips, charging, refuelling and cost, derived from a raw log of Home Assistant states
|
|
5
|
+
Author-email: Michael Lenz <michael.lenz@nullvector.org>
|
|
6
|
+
License-Expression: BSD-3-Clause
|
|
7
|
+
Project-URL: Repository, https://github.com/michael-lenz/ha-vledger
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Operating System :: OS Independent
|
|
10
|
+
Classifier: Topic :: Home Automation
|
|
11
|
+
Requires-Python: >=3.13
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
License-File: LICENSE
|
|
14
|
+
Provides-Extra: dev
|
|
15
|
+
Requires-Dist: pytest; extra == "dev"
|
|
16
|
+
Requires-Dist: pytest-asyncio; extra == "dev"
|
|
17
|
+
Requires-Dist: ruff; extra == "dev"
|
|
18
|
+
Provides-Extra: ha
|
|
19
|
+
Requires-Dist: pytest-homeassistant-custom-component; extra == "ha"
|
|
20
|
+
Dynamic: license-file
|
|
21
|
+
|
|
22
|
+
# ha-vledger
|
|
23
|
+
|
|
24
|
+
> **Pre-alpha. Do not install this.** It captures a raw log and nothing
|
|
25
|
+
> else yet: no trips, no charging sessions, no consumption, no cost. The
|
|
26
|
+
> log format is decided and will be read by every later version, but the
|
|
27
|
+
> integration has run on exactly one vehicle, the options may change
|
|
28
|
+
> between releases, and there is no support. It is public so that the
|
|
29
|
+
> author can test it through HACS and so that the design can be read.
|
|
30
|
+
> When it is ready for other people, this notice goes away.
|
|
31
|
+
|
|
32
|
+
A vehicle ledger for Home Assistant. It keeps a raw log of the handful of
|
|
33
|
+
vehicle states that matter to a ledger — odometer, position, fuel level,
|
|
34
|
+
state of charge, charging state — and derives from it what the vehicle's
|
|
35
|
+
own app never tells you reliably: trips, charging sessions, refuellings,
|
|
36
|
+
consumption and cost. Passively, from whatever integration already exposes
|
|
37
|
+
the vehicle, without a line of manufacturer-specific code, and without
|
|
38
|
+
touching anything else the vehicle reports: it is a ledger, not a monitor.
|
|
39
|
+
|
|
40
|
+
**Status:** capture works. The integration sets up a vehicle or a charge
|
|
41
|
+
point through the UI and writes its raw log — the format, and the
|
|
42
|
+
`vledger l0` verbs that write, read, validate it and list its gaps, are the
|
|
43
|
+
library's. Nothing derives from the log yet: no trips, no sessions, no
|
|
44
|
+
metrics, no receipts. Every behaviour described below is the design, held
|
|
45
|
+
as requirements in the project's register (`ha-vledger-pm`); a section is
|
|
46
|
+
marked *(planned)* until it exists.
|
|
47
|
+
|
|
48
|
+
## What it does *(planned)*
|
|
49
|
+
|
|
50
|
+
- **Captures, losslessly.** Every change of a source entity you assign to
|
|
51
|
+
a role becomes one record in a raw log (L0): JSON Lines, append-only, one
|
|
52
|
+
stream per vehicle and per charge point, rotated monthly, kept outside
|
|
53
|
+
the Home Assistant recorder and its retention. Start, stop and heartbeat
|
|
54
|
+
markers make any capture gap visible; nothing is interpolated across one.
|
|
55
|
+
- **Derives, deterministically.** Trips (between standstills), charging
|
|
56
|
+
sessions (from the charging state), refuellings (a fuel rise at unchanged
|
|
57
|
+
odometer) and metrics are computed from L0, receipts and configuration
|
|
58
|
+
(L1) — the same logic live in Home Assistant and in batch from the
|
|
59
|
+
command line, recomputable from scratch at any time. Every derived value
|
|
60
|
+
carries a quality flag: `measured`, `receipt`, `estimated` or
|
|
61
|
+
`incomplete`.
|
|
62
|
+
- **Takes receipts.** Price and exact quantity come from you: a refuelling
|
|
63
|
+
or charging receipt, entered from a notification, a dashboard or an
|
|
64
|
+
action, matched to the detected event by time. Receipt values beat sensor
|
|
65
|
+
values; a detected event without a receipt stays visible as unconfirmed.
|
|
66
|
+
- **Knows your charge points.** Home, work, anywhere fixed: position,
|
|
67
|
+
radius, tariff, optionally a meter. A charge point with a tariff but no
|
|
68
|
+
meter still yields cost, estimated through a charging loss factor; a
|
|
69
|
+
tariff of 0 is cost 0. Anything else is a foreign charge and asks for a
|
|
70
|
+
receipt.
|
|
71
|
+
- **Reports.** Per month, year and rolling period: distance, litres and kWh,
|
|
72
|
+
fuel and electricity cost, €/100 km per energy carrier, the electric
|
|
73
|
+
share two ways (energy by heating value, and an estimated distance
|
|
74
|
+
share), charge cycles and tank-fill equivalents. Fuel consumption is
|
|
75
|
+
tank-to-tank between any two receipts, corrected by the fuel level sensor,
|
|
76
|
+
so a tank that is never filled up still gets a figure.
|
|
77
|
+
|
|
78
|
+
## What it is built of
|
|
79
|
+
|
|
80
|
+
One repository, two packages, one version number (ADR-0002):
|
|
81
|
+
|
|
82
|
+
```
|
|
83
|
+
src/vledger/ the library and the vledger CLI: all derivation
|
|
84
|
+
logic, no Home Assistant, no dependencies;
|
|
85
|
+
published to PyPI as `vledger`
|
|
86
|
+
custom_components/vledger/ the Home Assistant integration, a shell over the
|
|
87
|
+
library: capture, config and options flows,
|
|
88
|
+
entities, actions, diagnostics; pins the library
|
|
89
|
+
by the same version in manifest.json
|
|
90
|
+
tests/ pytest: library tests on L0 fixtures, integration
|
|
91
|
+
tests with pytest-homeassistant-custom-component
|
|
92
|
+
docs/ design documentation
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
A vehicle is a config entry; its source entities are assigned to roles
|
|
96
|
+
(`odometer`, `position`, `fuel_level`, `soc`, `charging_state`, …), and
|
|
97
|
+
what the vehicle can do follows from the roles it has. Everything is
|
|
98
|
+
configured in the UI; no YAML.
|
|
99
|
+
|
|
100
|
+
## Documentation
|
|
101
|
+
|
|
102
|
+
| Document | What it is |
|
|
103
|
+
|---|---|
|
|
104
|
+
| [docs/user-guide.md](docs/user-guide.md) | What a participant types: the `vledger` command, verb by verb |
|
|
105
|
+
| [docs/l0-format.md](docs/l0-format.md) | The raw log's layout, version 1 — the specification a reader of their own files needs |
|
|
106
|
+
| [docs/glossary.md](docs/glossary.md) | The one English spelling of every domain term, and what it means |
|
|
107
|
+
| [docs/releasing.md](docs/releasing.md) | What the person cutting a release does, in order |
|
|
108
|
+
|
|
109
|
+
Decisions, requirements and the work queue are records in the project's
|
|
110
|
+
register, `ha-vledger-pm`, not prose here.
|
|
111
|
+
|
|
112
|
+
## Installing
|
|
113
|
+
|
|
114
|
+
Not yet — see the notice at the top. For the author's own test
|
|
115
|
+
instances: in HACS, *Integrations → ⋮ → Custom repositories*, add
|
|
116
|
+
`https://github.com/michael-lenz/ha-vledger` as an *Integration*, then
|
|
117
|
+
install it and restart; or copy `custom_components/vledger` into the
|
|
118
|
+
configuration directory by hand. Either way Home Assistant installs the
|
|
119
|
+
library from PyPI (`vledger==<version>`, pinned in the manifest), so the
|
|
120
|
+
version has to be published first — [docs/releasing.md](docs/releasing.md).
|
|
121
|
+
Then *Settings → Devices & services → Add integration → Vehicle Ledger*:
|
|
122
|
+
the [user guide](docs/user-guide.md) walks the four steps. The library on
|
|
123
|
+
its own: `pip install vledger`, which brings the `vledger` command.
|
|
124
|
+
|
|
125
|
+
## Developing
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
pip install -e ".[dev]" # library and CLI
|
|
129
|
+
pip install -e ".[dev,ha]" # plus the Home Assistant test stack, for the integration
|
|
130
|
+
python -m pytest
|
|
131
|
+
ruff check src tests custom_components
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
The integration's manifest pins a library version that may not be on PyPI
|
|
135
|
+
yet; Home Assistant skips the install when the package already imports, so
|
|
136
|
+
a development instance needs the editable install above first.
|
|
137
|
+
|
|
138
|
+
The `ha` extra pulls in a full Home Assistant core and its test plugin.
|
|
139
|
+
On a Debian or Ubuntu system Python that install can fail to build one of
|
|
140
|
+
its transitive dependencies against the distribution's patched
|
|
141
|
+
setuptools; a virtual environment (`python3 -m venv .venv`) does not have
|
|
142
|
+
that problem, and is the recommended place for it anyway. Without the
|
|
143
|
+
extra, `python -m pytest` runs the library's tests and leaves `tests/ha`
|
|
144
|
+
out. The library's own tests, the integration's against the oldest and
|
|
145
|
+
the newest Home Assistant, and HACS's validation run on every push
|
|
146
|
+
(`.github/workflows/tests.yml`); a tag `vX.Y.Z` publishes the release
|
|
147
|
+
(`.github/workflows/release.yml`, [docs/releasing.md](docs/releasing.md)).
|
|
148
|
+
|
|
149
|
+
## Privacy
|
|
150
|
+
|
|
151
|
+
The position history is personal data. It stays on your instance — nothing
|
|
152
|
+
is transmitted anywhere — and is redacted from logs and diagnostics, but
|
|
153
|
+
the default storage path lies under the Home Assistant configuration
|
|
154
|
+
directory and is therefore part of your backups.
|
|
155
|
+
|
|
156
|
+
## License
|
|
157
|
+
|
|
158
|
+
BSD-3-Clause — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
vledger/__init__.py,sha256=5AHlUxjyoY71cWDd1lLqLT2ApqXJu34vCsve1jpWjRY,540
|
|
2
|
+
vledger/cli.py,sha256=f2lT9o0ZmFpnVeFAZYEeX57Ldx2QqNhmqHxVSWRHe1I,9645
|
|
3
|
+
vledger/clock.py,sha256=WwC4RNmodXFDn2NLCkKUxezF9qaRDPojpBz-vYk4hK4,1226
|
|
4
|
+
vledger/config.py,sha256=2ufi0APBhKvChXOG31Fwhb1tAu587rdDEFi1VsKxZ4I,7751
|
|
5
|
+
vledger/l0.py,sha256=D_QOl8iAF7LQOI_NIt37H4vjHVOJ0EpIr-sOpAcktFA,14972
|
|
6
|
+
vledger/layout.py,sha256=Z3-F7U7ONEQbWXd0etlYCkoE4bQESVN9bHCOp8X7H54,2544
|
|
7
|
+
vledger-0.1.0.dist-info/licenses/LICENSE,sha256=5aQX-ywcHqanI7f3A70MFTqh7HEgypkQovNP0gVs1y4,1562
|
|
8
|
+
vledger-0.1.0.dist-info/METADATA,sha256=jEDCyR5_29k1ETEhNtdvmeWlz7qMw1zwfzQrxeEUjEQ,8018
|
|
9
|
+
vledger-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
10
|
+
vledger-0.1.0.dist-info/entry_points.txt,sha256=Ov8dZeoJ9TMVfGvLXcGm59VCY5sU_24NQKgjljOQvxc,45
|
|
11
|
+
vledger-0.1.0.dist-info/top_level.txt,sha256=kELH8P3xcZbidA-1PHwRG4lQmt6bE0TTGEn7RYYpFIQ,8
|
|
12
|
+
vledger-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
BSD 3-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026, Michael Lenz <michael.lenz@nullvector.org>, <michael.lenz@nullvector.space>
|
|
4
|
+
|
|
5
|
+
Redistribution and use in source and binary forms, with or without
|
|
6
|
+
modification, are permitted provided that the following conditions are met:
|
|
7
|
+
|
|
8
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
9
|
+
list of conditions and the following disclaimer.
|
|
10
|
+
|
|
11
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
12
|
+
this list of conditions and the following disclaimer in the documentation
|
|
13
|
+
and/or other materials provided with the distribution.
|
|
14
|
+
|
|
15
|
+
3. Neither the name of the copyright holder nor the names of its
|
|
16
|
+
contributors may be used to endorse or promote products derived from
|
|
17
|
+
this software without specific prior written permission.
|
|
18
|
+
|
|
19
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
20
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
21
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
22
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
23
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
24
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
25
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
26
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
27
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
28
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
vledger
|