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 +28 -0
- vledger-0.1.0/PKG-INFO +158 -0
- vledger-0.1.0/README.md +137 -0
- vledger-0.1.0/pyproject.toml +47 -0
- vledger-0.1.0/setup.cfg +4 -0
- vledger-0.1.0/src/vledger/__init__.py +13 -0
- vledger-0.1.0/src/vledger/cli.py +255 -0
- vledger-0.1.0/src/vledger/clock.py +37 -0
- vledger-0.1.0/src/vledger/config.py +184 -0
- vledger-0.1.0/src/vledger/l0.py +396 -0
- vledger-0.1.0/src/vledger/layout.py +88 -0
- vledger-0.1.0/src/vledger.egg-info/PKG-INFO +158 -0
- vledger-0.1.0/src/vledger.egg-info/SOURCES.txt +18 -0
- vledger-0.1.0/src/vledger.egg-info/dependency_links.txt +1 -0
- vledger-0.1.0/src/vledger.egg-info/entry_points.txt +2 -0
- vledger-0.1.0/src/vledger.egg-info/requires.txt +8 -0
- vledger-0.1.0/src/vledger.egg-info/top_level.txt +1 -0
- vledger-0.1.0/tests/test_config.py +75 -0
- vledger-0.1.0/tests/test_l0.py +187 -0
- vledger-0.1.0/tests/test_version.py +18 -0
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).
|
vledger-0.1.0/README.md
ADDED
|
@@ -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"]
|
vledger-0.1.0/setup.cfg
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"
|
|
@@ -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())
|