vledger 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
vledger-0.1.0/LICENSE ADDED
@@ -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.
vledger-0.1.0/PKG-INFO ADDED
@@ -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,137 @@
1
+ # ha-vledger
2
+
3
+ > **Pre-alpha. Do not install this.** It captures a raw log and nothing
4
+ > else yet: no trips, no charging sessions, no consumption, no cost. The
5
+ > log format is decided and will be read by every later version, but the
6
+ > integration has run on exactly one vehicle, the options may change
7
+ > between releases, and there is no support. It is public so that the
8
+ > author can test it through HACS and so that the design can be read.
9
+ > When it is ready for other people, this notice goes away.
10
+
11
+ A vehicle ledger for Home Assistant. It keeps a raw log of the handful of
12
+ vehicle states that matter to a ledger — odometer, position, fuel level,
13
+ state of charge, charging state — and derives from it what the vehicle's
14
+ own app never tells you reliably: trips, charging sessions, refuellings,
15
+ consumption and cost. Passively, from whatever integration already exposes
16
+ the vehicle, without a line of manufacturer-specific code, and without
17
+ touching anything else the vehicle reports: it is a ledger, not a monitor.
18
+
19
+ **Status:** capture works. The integration sets up a vehicle or a charge
20
+ point through the UI and writes its raw log — the format, and the
21
+ `vledger l0` verbs that write, read, validate it and list its gaps, are the
22
+ library's. Nothing derives from the log yet: no trips, no sessions, no
23
+ metrics, no receipts. Every behaviour described below is the design, held
24
+ as requirements in the project's register (`ha-vledger-pm`); a section is
25
+ marked *(planned)* until it exists.
26
+
27
+ ## What it does *(planned)*
28
+
29
+ - **Captures, losslessly.** Every change of a source entity you assign to
30
+ a role becomes one record in a raw log (L0): JSON Lines, append-only, one
31
+ stream per vehicle and per charge point, rotated monthly, kept outside
32
+ the Home Assistant recorder and its retention. Start, stop and heartbeat
33
+ markers make any capture gap visible; nothing is interpolated across one.
34
+ - **Derives, deterministically.** Trips (between standstills), charging
35
+ sessions (from the charging state), refuellings (a fuel rise at unchanged
36
+ odometer) and metrics are computed from L0, receipts and configuration
37
+ (L1) — the same logic live in Home Assistant and in batch from the
38
+ command line, recomputable from scratch at any time. Every derived value
39
+ carries a quality flag: `measured`, `receipt`, `estimated` or
40
+ `incomplete`.
41
+ - **Takes receipts.** Price and exact quantity come from you: a refuelling
42
+ or charging receipt, entered from a notification, a dashboard or an
43
+ action, matched to the detected event by time. Receipt values beat sensor
44
+ values; a detected event without a receipt stays visible as unconfirmed.
45
+ - **Knows your charge points.** Home, work, anywhere fixed: position,
46
+ radius, tariff, optionally a meter. A charge point with a tariff but no
47
+ meter still yields cost, estimated through a charging loss factor; a
48
+ tariff of 0 is cost 0. Anything else is a foreign charge and asks for a
49
+ receipt.
50
+ - **Reports.** Per month, year and rolling period: distance, litres and kWh,
51
+ fuel and electricity cost, €/100 km per energy carrier, the electric
52
+ share two ways (energy by heating value, and an estimated distance
53
+ share), charge cycles and tank-fill equivalents. Fuel consumption is
54
+ tank-to-tank between any two receipts, corrected by the fuel level sensor,
55
+ so a tank that is never filled up still gets a figure.
56
+
57
+ ## What it is built of
58
+
59
+ One repository, two packages, one version number (ADR-0002):
60
+
61
+ ```
62
+ src/vledger/ the library and the vledger CLI: all derivation
63
+ logic, no Home Assistant, no dependencies;
64
+ published to PyPI as `vledger`
65
+ custom_components/vledger/ the Home Assistant integration, a shell over the
66
+ library: capture, config and options flows,
67
+ entities, actions, diagnostics; pins the library
68
+ by the same version in manifest.json
69
+ tests/ pytest: library tests on L0 fixtures, integration
70
+ tests with pytest-homeassistant-custom-component
71
+ docs/ design documentation
72
+ ```
73
+
74
+ A vehicle is a config entry; its source entities are assigned to roles
75
+ (`odometer`, `position`, `fuel_level`, `soc`, `charging_state`, …), and
76
+ what the vehicle can do follows from the roles it has. Everything is
77
+ configured in the UI; no YAML.
78
+
79
+ ## Documentation
80
+
81
+ | Document | What it is |
82
+ |---|---|
83
+ | [docs/user-guide.md](docs/user-guide.md) | What a participant types: the `vledger` command, verb by verb |
84
+ | [docs/l0-format.md](docs/l0-format.md) | The raw log's layout, version 1 — the specification a reader of their own files needs |
85
+ | [docs/glossary.md](docs/glossary.md) | The one English spelling of every domain term, and what it means |
86
+ | [docs/releasing.md](docs/releasing.md) | What the person cutting a release does, in order |
87
+
88
+ Decisions, requirements and the work queue are records in the project's
89
+ register, `ha-vledger-pm`, not prose here.
90
+
91
+ ## Installing
92
+
93
+ Not yet — see the notice at the top. For the author's own test
94
+ instances: in HACS, *Integrations → ⋮ → Custom repositories*, add
95
+ `https://github.com/michael-lenz/ha-vledger` as an *Integration*, then
96
+ install it and restart; or copy `custom_components/vledger` into the
97
+ configuration directory by hand. Either way Home Assistant installs the
98
+ library from PyPI (`vledger==<version>`, pinned in the manifest), so the
99
+ version has to be published first — [docs/releasing.md](docs/releasing.md).
100
+ Then *Settings → Devices & services → Add integration → Vehicle Ledger*:
101
+ the [user guide](docs/user-guide.md) walks the four steps. The library on
102
+ its own: `pip install vledger`, which brings the `vledger` command.
103
+
104
+ ## Developing
105
+
106
+ ```bash
107
+ pip install -e ".[dev]" # library and CLI
108
+ pip install -e ".[dev,ha]" # plus the Home Assistant test stack, for the integration
109
+ python -m pytest
110
+ ruff check src tests custom_components
111
+ ```
112
+
113
+ The integration's manifest pins a library version that may not be on PyPI
114
+ yet; Home Assistant skips the install when the package already imports, so
115
+ a development instance needs the editable install above first.
116
+
117
+ The `ha` extra pulls in a full Home Assistant core and its test plugin.
118
+ On a Debian or Ubuntu system Python that install can fail to build one of
119
+ its transitive dependencies against the distribution's patched
120
+ setuptools; a virtual environment (`python3 -m venv .venv`) does not have
121
+ that problem, and is the recommended place for it anyway. Without the
122
+ extra, `python -m pytest` runs the library's tests and leaves `tests/ha`
123
+ out. The library's own tests, the integration's against the oldest and
124
+ the newest Home Assistant, and HACS's validation run on every push
125
+ (`.github/workflows/tests.yml`); a tag `vX.Y.Z` publishes the release
126
+ (`.github/workflows/release.yml`, [docs/releasing.md](docs/releasing.md)).
127
+
128
+ ## Privacy
129
+
130
+ The position history is personal data. It stays on your instance — nothing
131
+ is transmitted anywhere — and is redacted from logs and diagnostics, but
132
+ the default storage path lies under the Home Assistant configuration
133
+ directory and is therefore part of your backups.
134
+
135
+ ## License
136
+
137
+ BSD-3-Clause — see [LICENSE](LICENSE).
@@ -0,0 +1,47 @@
1
+ # One repository, two packages (ADR-0002): this file builds only the library.
2
+ # The integration under custom_components/ is shipped by HACS and pins the
3
+ # library by the same version number.
4
+ [build-system]
5
+ requires = ["setuptools>=69"]
6
+ build-backend = "setuptools.build_meta"
7
+
8
+ [project]
9
+ name = "vledger"
10
+ version = "0.1.0"
11
+ description = "Vehicle ledger: trips, charging, refuelling and cost, derived from a raw log of Home Assistant states"
12
+ readme = "README.md"
13
+ license = "BSD-3-Clause"
14
+ license-files = ["LICENSE"]
15
+ authors = [{ name = "Michael Lenz", email = "michael.lenz@nullvector.org" }]
16
+ requires-python = ">=3.13"
17
+ # No runtime dependencies, by requirement (CLI-06): the standard library is
18
+ # the whole platform the library runs on.
19
+ dependencies = []
20
+ classifiers = [
21
+ "Programming Language :: Python :: 3",
22
+ "Operating System :: OS Independent",
23
+ "Topic :: Home Automation",
24
+ ]
25
+
26
+ [project.urls]
27
+ Repository = "https://github.com/michael-lenz/ha-vledger"
28
+
29
+ [project.scripts]
30
+ vledger = "vledger.cli:main"
31
+
32
+ [project.optional-dependencies]
33
+ # Two extras on purpose: the library's own loop needs no Home Assistant
34
+ # (ARC-03), and the HA test stack is heavy. Integration tests (QUA-02)
35
+ # install both.
36
+ dev = ["pytest", "pytest-asyncio", "ruff"]
37
+ ha = ["pytest-homeassistant-custom-component"]
38
+
39
+ [tool.setuptools.packages.find]
40
+ where = ["src"]
41
+
42
+ [tool.pytest.ini_options]
43
+ testpaths = ["tests"]
44
+ asyncio_mode = "auto"
45
+
46
+ [tool.ruff]
47
+ src = ["src", "tests", "custom_components"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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"
@@ -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())