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 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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ vledger = vledger.cli:main
@@ -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