aaos-logging-drive 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.
- aaos_logging_drive-0.1.0/LICENSE +21 -0
- aaos_logging_drive-0.1.0/MANIFEST.in +2 -0
- aaos_logging_drive-0.1.0/PKG-INFO +145 -0
- aaos_logging_drive-0.1.0/README.md +125 -0
- aaos_logging_drive-0.1.0/pyproject.toml +29 -0
- aaos_logging_drive-0.1.0/setup.cfg +4 -0
- aaos_logging_drive-0.1.0/src/aaos_logging_drive/__init__.py +20 -0
- aaos_logging_drive-0.1.0/src/aaos_logging_drive/api.py +134 -0
- aaos_logging_drive-0.1.0/src/aaos_logging_drive/battery_capacity.py +27 -0
- aaos_logging_drive-0.1.0/src/aaos_logging_drive/client.py +116 -0
- aaos_logging_drive-0.1.0/src/aaos_logging_drive/const.py +64 -0
- aaos_logging_drive-0.1.0/src/aaos_logging_drive/energy.py +143 -0
- aaos_logging_drive-0.1.0/src/aaos_logging_drive/history.py +458 -0
- aaos_logging_drive-0.1.0/src/aaos_logging_drive/measurements.py +98 -0
- aaos_logging_drive-0.1.0/src/aaos_logging_drive/model.py +297 -0
- aaos_logging_drive-0.1.0/src/aaos_logging_drive/py.typed +0 -0
- aaos_logging_drive-0.1.0/src/aaos_logging_drive/repository.py +227 -0
- aaos_logging_drive-0.1.0/src/aaos_logging_drive.egg-info/PKG-INFO +145 -0
- aaos_logging_drive-0.1.0/src/aaos_logging_drive.egg-info/SOURCES.txt +21 -0
- aaos_logging_drive-0.1.0/src/aaos_logging_drive.egg-info/dependency_links.txt +1 -0
- aaos_logging_drive-0.1.0/src/aaos_logging_drive.egg-info/requires.txt +2 -0
- aaos_logging_drive-0.1.0/src/aaos_logging_drive.egg-info/top_level.txt +1 -0
- aaos_logging_drive-0.1.0/tests/test_standalone.py +240 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 devmcage
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: aaos-logging-drive
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Read-only AAOS Logging Google Drive synchronization and recorded vehicle data processing
|
|
5
|
+
Author: AAOS Logging contributors
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Classifier: Development Status :: 3 - Alpha
|
|
8
|
+
Classifier: Operating System :: OS Independent
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
13
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
14
|
+
Requires-Python: >=3.11
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Requires-Dist: aiohttp<4,>=3.12
|
|
18
|
+
Requires-Dist: tzdata>=2024.1
|
|
19
|
+
Dynamic: license-file
|
|
20
|
+
|
|
21
|
+
# AAOS Logging Drive Python library
|
|
22
|
+
|
|
23
|
+
`aaos-logging-drive` is a standalone Python library for read-only access to AAOS
|
|
24
|
+
Logging exports in Google Drive. The import name is `aaos_logging_drive`.
|
|
25
|
+
Python 3.11 or newer is required. Home Assistant is **not** a dependency.
|
|
26
|
+
|
|
27
|
+
Install version **0.1.0** from PyPI:
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
python -m pip install aaos-logging-drive==0.1.0
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
For local development, install from the source project instead:
|
|
34
|
+
|
|
35
|
+
```sh
|
|
36
|
+
python -m pip install ./packages/aaos-logging-drive
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Read recorded trips
|
|
40
|
+
|
|
41
|
+
The caller supplies an `aiohttp.ClientSession` and an asynchronous token provider.
|
|
42
|
+
Use a Google OAuth grant restricted to `https://www.googleapis.com/auth/drive.readonly`.
|
|
43
|
+
The application owns consent, token storage and refresh; this library neither
|
|
44
|
+
bundles credentials nor starts a hosted authentication service.
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
import asyncio
|
|
48
|
+
import os
|
|
49
|
+
|
|
50
|
+
import aiohttp
|
|
51
|
+
from aaos_logging_drive import AAOSDriveClient
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
async def token_provider():
|
|
55
|
+
# For a long-running application, return a fresh OAuth access token here.
|
|
56
|
+
return os.environ["AAOS_DRIVE_ACCESS_TOKEN"]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
async def main():
|
|
60
|
+
async with aiohttp.ClientSession() as session:
|
|
61
|
+
client = AAOSDriveClient.from_google_drive(
|
|
62
|
+
session,
|
|
63
|
+
token_provider,
|
|
64
|
+
os.environ["AAOS_DRIVE_FOLDER"], # Car folder ID or Drive folder URL.
|
|
65
|
+
history_path="car-history.sqlite",
|
|
66
|
+
)
|
|
67
|
+
snapshot = await client.refresh()
|
|
68
|
+
print(snapshot.manifest.raw["generationId"])
|
|
69
|
+
trips = await client.list_trips()
|
|
70
|
+
if trips:
|
|
71
|
+
trip = trips[-1]
|
|
72
|
+
page = await client.get_history(
|
|
73
|
+
"telemetry.speedKph", trip_id=trip["tripId"]
|
|
74
|
+
)
|
|
75
|
+
while True:
|
|
76
|
+
for point in page["points"]:
|
|
77
|
+
print(point["time"], point["value"])
|
|
78
|
+
if page["next_cursor"] is None:
|
|
79
|
+
break
|
|
80
|
+
page = await client.get_history(
|
|
81
|
+
"telemetry.speedKph", trip_id=trip["tripId"],
|
|
82
|
+
cursor=page["next_cursor"],
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
asyncio.run(main())
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
`refresh()` selects a complete immutable Drive V3 generation, checks all artifact
|
|
90
|
+
sizes/hashes and imports its detailed trip archives. Temporary publication races
|
|
91
|
+
are retried. A failed import retains the preceding complete snapshot/history.
|
|
92
|
+
Separate trips never share a power-estimation window. Missing data stays unknown.
|
|
93
|
+
|
|
94
|
+
## API and persistence
|
|
95
|
+
|
|
96
|
+
- `client.refresh()` returns a `Snapshot` with the latest recorded values and export metadata.
|
|
97
|
+
- `client.history_fields()` returns field descriptions, units, counts and time bounds.
|
|
98
|
+
- `client.list_trips(period="")` returns reviewed trip summaries; filter by `YYYY-MM` or `YYYY-MM-DD`.
|
|
99
|
+
- `client.get_trip(trip_id="")` returns the exact original trip, including its arrays; blank selects latest.
|
|
100
|
+
- `client.get_history(field, start=None, end=None, limit=2000, cursor=None, trip_id=None)` returns original-time readings with provenance and a continuation cursor. Bounds use Unix milliseconds.
|
|
101
|
+
- `client.accepted_head` is a copy of the accepted generation checkpoint. Persist it in your application and pass it back as `accepted_head=` on restart to maintain rollback protection.
|
|
102
|
+
|
|
103
|
+
For an established source, also persist `snapshot.manifest.raw["datasetId"]` and
|
|
104
|
+
`["vehicleId"]`, and restore them as `expected_identity={"datasetId": ..., "vehicleId": ...}`.
|
|
105
|
+
The library follows a changed identity only with a verified published merge;
|
|
106
|
+
when local history exists, all previously imported trips must be preserved.
|
|
107
|
+
Save checkpoints only after a successful `refresh()`. They contain source identity
|
|
108
|
+
and hashes, not OAuth tokens. History can be queried offline after a previous
|
|
109
|
+
complete import. Without `history_path`, only the validated current snapshot is available.
|
|
110
|
+
|
|
111
|
+
The session is caller-owned and is never closed by the library. All SQLite and
|
|
112
|
+
archive decoding work uses `asyncio.to_thread` by default. Applications can pass
|
|
113
|
+
another asynchronous executor. Low-level `ReadOnlyDrive`, `DriveRepository`,
|
|
114
|
+
`HistoryStore`, `Manifest`, `archive_trips`, `review_energy`, `capacity_telemetry`
|
|
115
|
+
and `sync_history` are also available. `AAOSDriveClient(repository, history)`
|
|
116
|
+
supports custom transports and an already initialized history store.
|
|
117
|
+
|
|
118
|
+
## Supported data
|
|
119
|
+
|
|
120
|
+
Drive sync V3, day-file format 1, trip schema 17 and the optional powertrain-v1
|
|
121
|
+
extension are supported. Fuel/hybrid readings, nullable boolean states, route and
|
|
122
|
+
weather samples, metadata, original trip JSON, and future export fields are retained.
|
|
123
|
+
Fuel estimates exclude unobserved intervals and stationary use; hybrid battery
|
|
124
|
+
energy remains separate. Battery-energy recovery is labeled as estimated, and
|
|
125
|
+
capacity retention is not verified state of health. Historical recordings cannot
|
|
126
|
+
acquire measurements that were never logged.
|
|
127
|
+
|
|
128
|
+
The local history format and processing version are unchanged by the extraction
|
|
129
|
+
from the Home Assistant integration. Applications must not use one history file
|
|
130
|
+
concurrently from different processes. The generic hourly aggregation and opaque
|
|
131
|
+
statistics checkpoint are available for adapters; this package does not register
|
|
132
|
+
entities or write to Home Assistant Recorder.
|
|
133
|
+
|
|
134
|
+
## Build and test
|
|
135
|
+
|
|
136
|
+
```sh
|
|
137
|
+
python -m pip install build twine
|
|
138
|
+
python -m build packages/aaos-logging-drive --outdir dist
|
|
139
|
+
python -m twine check dist/*
|
|
140
|
+
python -m unittest discover -s packages/aaos-logging-drive/tests -v
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
The wheel and source distribution contain only this library, its license, and its
|
|
144
|
+
standalone tests/documentation. They do not contain the Home Assistant wrapper,
|
|
145
|
+
car data, authentication configuration or repository verification files.
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# AAOS Logging Drive Python library
|
|
2
|
+
|
|
3
|
+
`aaos-logging-drive` is a standalone Python library for read-only access to AAOS
|
|
4
|
+
Logging exports in Google Drive. The import name is `aaos_logging_drive`.
|
|
5
|
+
Python 3.11 or newer is required. Home Assistant is **not** a dependency.
|
|
6
|
+
|
|
7
|
+
Install version **0.1.0** from PyPI:
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
python -m pip install aaos-logging-drive==0.1.0
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
For local development, install from the source project instead:
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
python -m pip install ./packages/aaos-logging-drive
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Read recorded trips
|
|
20
|
+
|
|
21
|
+
The caller supplies an `aiohttp.ClientSession` and an asynchronous token provider.
|
|
22
|
+
Use a Google OAuth grant restricted to `https://www.googleapis.com/auth/drive.readonly`.
|
|
23
|
+
The application owns consent, token storage and refresh; this library neither
|
|
24
|
+
bundles credentials nor starts a hosted authentication service.
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
import asyncio
|
|
28
|
+
import os
|
|
29
|
+
|
|
30
|
+
import aiohttp
|
|
31
|
+
from aaos_logging_drive import AAOSDriveClient
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
async def token_provider():
|
|
35
|
+
# For a long-running application, return a fresh OAuth access token here.
|
|
36
|
+
return os.environ["AAOS_DRIVE_ACCESS_TOKEN"]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
async def main():
|
|
40
|
+
async with aiohttp.ClientSession() as session:
|
|
41
|
+
client = AAOSDriveClient.from_google_drive(
|
|
42
|
+
session,
|
|
43
|
+
token_provider,
|
|
44
|
+
os.environ["AAOS_DRIVE_FOLDER"], # Car folder ID or Drive folder URL.
|
|
45
|
+
history_path="car-history.sqlite",
|
|
46
|
+
)
|
|
47
|
+
snapshot = await client.refresh()
|
|
48
|
+
print(snapshot.manifest.raw["generationId"])
|
|
49
|
+
trips = await client.list_trips()
|
|
50
|
+
if trips:
|
|
51
|
+
trip = trips[-1]
|
|
52
|
+
page = await client.get_history(
|
|
53
|
+
"telemetry.speedKph", trip_id=trip["tripId"]
|
|
54
|
+
)
|
|
55
|
+
while True:
|
|
56
|
+
for point in page["points"]:
|
|
57
|
+
print(point["time"], point["value"])
|
|
58
|
+
if page["next_cursor"] is None:
|
|
59
|
+
break
|
|
60
|
+
page = await client.get_history(
|
|
61
|
+
"telemetry.speedKph", trip_id=trip["tripId"],
|
|
62
|
+
cursor=page["next_cursor"],
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
asyncio.run(main())
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
`refresh()` selects a complete immutable Drive V3 generation, checks all artifact
|
|
70
|
+
sizes/hashes and imports its detailed trip archives. Temporary publication races
|
|
71
|
+
are retried. A failed import retains the preceding complete snapshot/history.
|
|
72
|
+
Separate trips never share a power-estimation window. Missing data stays unknown.
|
|
73
|
+
|
|
74
|
+
## API and persistence
|
|
75
|
+
|
|
76
|
+
- `client.refresh()` returns a `Snapshot` with the latest recorded values and export metadata.
|
|
77
|
+
- `client.history_fields()` returns field descriptions, units, counts and time bounds.
|
|
78
|
+
- `client.list_trips(period="")` returns reviewed trip summaries; filter by `YYYY-MM` or `YYYY-MM-DD`.
|
|
79
|
+
- `client.get_trip(trip_id="")` returns the exact original trip, including its arrays; blank selects latest.
|
|
80
|
+
- `client.get_history(field, start=None, end=None, limit=2000, cursor=None, trip_id=None)` returns original-time readings with provenance and a continuation cursor. Bounds use Unix milliseconds.
|
|
81
|
+
- `client.accepted_head` is a copy of the accepted generation checkpoint. Persist it in your application and pass it back as `accepted_head=` on restart to maintain rollback protection.
|
|
82
|
+
|
|
83
|
+
For an established source, also persist `snapshot.manifest.raw["datasetId"]` and
|
|
84
|
+
`["vehicleId"]`, and restore them as `expected_identity={"datasetId": ..., "vehicleId": ...}`.
|
|
85
|
+
The library follows a changed identity only with a verified published merge;
|
|
86
|
+
when local history exists, all previously imported trips must be preserved.
|
|
87
|
+
Save checkpoints only after a successful `refresh()`. They contain source identity
|
|
88
|
+
and hashes, not OAuth tokens. History can be queried offline after a previous
|
|
89
|
+
complete import. Without `history_path`, only the validated current snapshot is available.
|
|
90
|
+
|
|
91
|
+
The session is caller-owned and is never closed by the library. All SQLite and
|
|
92
|
+
archive decoding work uses `asyncio.to_thread` by default. Applications can pass
|
|
93
|
+
another asynchronous executor. Low-level `ReadOnlyDrive`, `DriveRepository`,
|
|
94
|
+
`HistoryStore`, `Manifest`, `archive_trips`, `review_energy`, `capacity_telemetry`
|
|
95
|
+
and `sync_history` are also available. `AAOSDriveClient(repository, history)`
|
|
96
|
+
supports custom transports and an already initialized history store.
|
|
97
|
+
|
|
98
|
+
## Supported data
|
|
99
|
+
|
|
100
|
+
Drive sync V3, day-file format 1, trip schema 17 and the optional powertrain-v1
|
|
101
|
+
extension are supported. Fuel/hybrid readings, nullable boolean states, route and
|
|
102
|
+
weather samples, metadata, original trip JSON, and future export fields are retained.
|
|
103
|
+
Fuel estimates exclude unobserved intervals and stationary use; hybrid battery
|
|
104
|
+
energy remains separate. Battery-energy recovery is labeled as estimated, and
|
|
105
|
+
capacity retention is not verified state of health. Historical recordings cannot
|
|
106
|
+
acquire measurements that were never logged.
|
|
107
|
+
|
|
108
|
+
The local history format and processing version are unchanged by the extraction
|
|
109
|
+
from the Home Assistant integration. Applications must not use one history file
|
|
110
|
+
concurrently from different processes. The generic hourly aggregation and opaque
|
|
111
|
+
statistics checkpoint are available for adapters; this package does not register
|
|
112
|
+
entities or write to Home Assistant Recorder.
|
|
113
|
+
|
|
114
|
+
## Build and test
|
|
115
|
+
|
|
116
|
+
```sh
|
|
117
|
+
python -m pip install build twine
|
|
118
|
+
python -m build packages/aaos-logging-drive --outdir dist
|
|
119
|
+
python -m twine check dist/*
|
|
120
|
+
python -m unittest discover -s packages/aaos-logging-drive/tests -v
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
The wheel and source distribution contain only this library, its license, and its
|
|
124
|
+
standalone tests/documentation. They do not contain the Home Assistant wrapper,
|
|
125
|
+
car data, authentication configuration or repository verification files.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "aaos-logging-drive"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Read-only AAOS Logging Google Drive synchronization and recorded vehicle data processing"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{name = "AAOS Logging contributors"}]
|
|
14
|
+
dependencies = ["aiohttp>=3.12,<4", "tzdata>=2024.1"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 3 - Alpha",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.11",
|
|
20
|
+
"Programming Language :: Python :: 3.12",
|
|
21
|
+
"Programming Language :: Python :: 3.13",
|
|
22
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[tool.setuptools.packages.find]
|
|
26
|
+
where = ["src"]
|
|
27
|
+
|
|
28
|
+
[tool.setuptools.package-data]
|
|
29
|
+
aaos_logging_drive = ["py.typed"]
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Read-only AAOS Logging Drive client and recorded vehicle data processing."""
|
|
2
|
+
|
|
3
|
+
from .api import DriveAuthError, DriveError, DriveNotFoundError, ReadOnlyDrive, folder_id
|
|
4
|
+
from .battery_capacity import capacity_telemetry
|
|
5
|
+
from .client import AAOSDriveClient
|
|
6
|
+
from .const import READ_SCOPE, readonly_grant
|
|
7
|
+
from .energy import EnergyReview, effective_telemetry, review_energy
|
|
8
|
+
from .history import HistoryStore, sync_history
|
|
9
|
+
from .model import Artifact, ExportNotReady, InvalidExport, Manifest, Snapshot, archive_trips, trip_summary
|
|
10
|
+
from .repository import DriveRepository
|
|
11
|
+
|
|
12
|
+
__version__ = "0.1.0"
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"AAOSDriveClient", "Artifact", "DriveAuthError", "DriveError", "DriveNotFoundError",
|
|
16
|
+
"DriveRepository", "EnergyReview", "ExportNotReady", "HistoryStore", "InvalidExport",
|
|
17
|
+
"Manifest", "READ_SCOPE", "ReadOnlyDrive", "Snapshot", "archive_trips",
|
|
18
|
+
"capacity_telemetry", "effective_telemetry", "folder_id", "readonly_grant",
|
|
19
|
+
"review_energy", "sync_history", "trip_summary",
|
|
20
|
+
]
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Google Drive API reads only. OAuth token refresh belongs to the caller."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import random
|
|
7
|
+
import re
|
|
8
|
+
from typing import Awaitable, Callable
|
|
9
|
+
|
|
10
|
+
import aiohttp
|
|
11
|
+
|
|
12
|
+
from .const import API_ROOT, FOLDER_MIME, MAX_FILE_BYTES
|
|
13
|
+
from .model import InvalidExport
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class DriveError(Exception):
|
|
17
|
+
"""A transient Drive read failed."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class DriveAuthError(DriveError):
|
|
21
|
+
"""The Drive grant needs renewed consent."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class DriveNotFoundError(DriveError):
|
|
25
|
+
"""A file vanished or is not yet visible during a generation replacement."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class RetryableDriveError(DriveError):
|
|
29
|
+
"""A rate limit or temporary Google service error."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, message, retry_after=0):
|
|
32
|
+
super().__init__(message)
|
|
33
|
+
self.retry_after = retry_after
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def folder_id(value: str) -> str:
|
|
37
|
+
value = value.strip()
|
|
38
|
+
match = re.fullmatch(r"https://drive\.google\.com/drive/(?:u/\d+/)?folders/([\w-]+)(?:\?.*)?", value)
|
|
39
|
+
result = match[1] if match else value
|
|
40
|
+
if not re.fullmatch(r"[A-Za-z0-9_-]+", result):
|
|
41
|
+
raise InvalidExport("Enter a Google Drive folder ID or folder URL")
|
|
42
|
+
return result
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ReadOnlyDrive:
|
|
46
|
+
def __init__(self, session: aiohttp.ClientSession, token: Callable[[], Awaitable[str]]):
|
|
47
|
+
self.session = session
|
|
48
|
+
self.token = token
|
|
49
|
+
|
|
50
|
+
async def _read(self, path: str, params: dict, limit: int, media: bool = False):
|
|
51
|
+
for attempt in range(3):
|
|
52
|
+
try:
|
|
53
|
+
return await self._read_once(path, params, limit, media)
|
|
54
|
+
except (RetryableDriveError, aiohttp.ClientError, TimeoutError) as err:
|
|
55
|
+
if attempt == 2:
|
|
56
|
+
raise DriveError("Google Drive is temporarily unavailable after three read attempts") from err
|
|
57
|
+
delay = max(2 ** attempt + random.random(), getattr(err, "retry_after", 0))
|
|
58
|
+
await asyncio.sleep(min(delay, 60))
|
|
59
|
+
|
|
60
|
+
async def _read_once(self, path: str, params: dict, limit: int, media: bool = False):
|
|
61
|
+
access_token = await self.token()
|
|
62
|
+
async with self.session.get(
|
|
63
|
+
f"{API_ROOT}/{path}", params=params,
|
|
64
|
+
headers={"Authorization": f"Bearer {access_token}"},
|
|
65
|
+
timeout=aiohttp.ClientTimeout(total=180), allow_redirects=False,
|
|
66
|
+
) as response:
|
|
67
|
+
if response.status == 401:
|
|
68
|
+
raise DriveAuthError("Google Drive authorization expired")
|
|
69
|
+
if response.status == 403:
|
|
70
|
+
# Quota exhaustion is transient; insufficient grants require reauth.
|
|
71
|
+
try:
|
|
72
|
+
error = await response.json()
|
|
73
|
+
reasons = {e.get("reason") for e in error.get("error", {}).get("errors", [])}
|
|
74
|
+
except (ValueError, aiohttp.ClientError):
|
|
75
|
+
reasons = set()
|
|
76
|
+
if reasons & {"insufficientPermissions", "authError"}:
|
|
77
|
+
raise DriveAuthError("Google Drive read-only permission is required")
|
|
78
|
+
if reasons & {"rateLimitExceeded", "userRateLimitExceeded"}:
|
|
79
|
+
raise RetryableDriveError("Google Drive read rate limit reached")
|
|
80
|
+
raise DriveError("Google Drive denied the read (permission, API configuration or quota)")
|
|
81
|
+
if response.status in {429, 500, 502, 503, 504}:
|
|
82
|
+
try:
|
|
83
|
+
retry_after = max(0, min(float(getattr(response, "headers", {}).get("Retry-After", "0")), 60))
|
|
84
|
+
except ValueError:
|
|
85
|
+
retry_after = 0
|
|
86
|
+
raise RetryableDriveError(f"Temporary Google Drive HTTP {response.status}", retry_after)
|
|
87
|
+
if response.status == 404:
|
|
88
|
+
raise DriveNotFoundError("A Google Drive file is not visible or accessible")
|
|
89
|
+
if response.status >= 300:
|
|
90
|
+
raise DriveError(f"Google Drive read failed with HTTP {response.status}")
|
|
91
|
+
data = bytearray()
|
|
92
|
+
async for chunk in response.content.iter_chunked(64 * 1024):
|
|
93
|
+
data.extend(chunk)
|
|
94
|
+
if len(data) > limit:
|
|
95
|
+
raise InvalidExport("Drive response exceeds the import limit")
|
|
96
|
+
if media:
|
|
97
|
+
return bytes(data)
|
|
98
|
+
from .model import json_object
|
|
99
|
+
return json_object(bytes(data))
|
|
100
|
+
|
|
101
|
+
async def metadata(self, file_id: str) -> dict:
|
|
102
|
+
return await self._read(f"files/{folder_id(file_id)}", {
|
|
103
|
+
"fields": "id,name,mimeType,size,sha256Checksum,trashed", "supportsAllDrives": "true",
|
|
104
|
+
}, 1024 * 1024)
|
|
105
|
+
|
|
106
|
+
async def children(self, parent: str) -> list[dict]:
|
|
107
|
+
parent = folder_id(parent)
|
|
108
|
+
result, page_token = [], None
|
|
109
|
+
while True:
|
|
110
|
+
params = {
|
|
111
|
+
"q": f"'{parent}' in parents and trashed = false", "spaces": "drive",
|
|
112
|
+
"fields": "nextPageToken,incompleteSearch,files(id,name,mimeType,size,sha256Checksum)",
|
|
113
|
+
"pageSize": "1000", "supportsAllDrives": "true", "includeItemsFromAllDrives": "true",
|
|
114
|
+
}
|
|
115
|
+
if page_token:
|
|
116
|
+
params["pageToken"] = page_token
|
|
117
|
+
page = await self._read("files", params, 8 * 1024 * 1024)
|
|
118
|
+
if page.get("incompleteSearch"):
|
|
119
|
+
raise DriveError("Drive returned an incomplete folder listing; retry later")
|
|
120
|
+
result.extend(page.get("files", []))
|
|
121
|
+
page_token = page.get("nextPageToken")
|
|
122
|
+
if not page_token:
|
|
123
|
+
return result
|
|
124
|
+
|
|
125
|
+
async def child_folder(self, parent: str, name: str) -> str:
|
|
126
|
+
matches = [f for f in await self.children(parent) if f.get("name") == name and f.get("mimeType") == FOLDER_MIME]
|
|
127
|
+
if len(matches) != 1:
|
|
128
|
+
raise InvalidExport(f"Expected one {name} folder; sync AAOS Logging V3 first")
|
|
129
|
+
return matches[0]["id"]
|
|
130
|
+
|
|
131
|
+
async def download(self, file_id: str, limit: int = MAX_FILE_BYTES) -> bytes:
|
|
132
|
+
return await self._read(f"files/{folder_id(file_id)}", {
|
|
133
|
+
"alt": "media", "supportsAllDrives": "true",
|
|
134
|
+
}, limit, media=True)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Paired, timestamped usable-capacity estimate; never a certified SoH sensor."""
|
|
2
|
+
|
|
3
|
+
from math import isfinite
|
|
4
|
+
|
|
5
|
+
FIELDS = frozenset({"batteryCurrentCapacityWh", "batteryNominalCapacityWh", "batteryCapacityRetentionPercent"})
|
|
6
|
+
NOTICE = ("Estimated battery capacity retention, not verified battery state of health. "
|
|
7
|
+
"Current usable capacity / nominal usable capacity when new × 100. "
|
|
8
|
+
"Temperature and battery-management estimates affect this value; it does not measure permanent degradation. "
|
|
9
|
+
"Values above 100% are not capped. Missing capacities are not inferred from charge level, range or remaining energy.")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def positive(value):
|
|
13
|
+
try:
|
|
14
|
+
return value if type(value) in (int, float) and isfinite(value) and value > 0 else None
|
|
15
|
+
except OverflowError:
|
|
16
|
+
return None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def capacity_telemetry(point):
|
|
20
|
+
"""Return a derived view, without modifying the original exported trip."""
|
|
21
|
+
result = dict(point)
|
|
22
|
+
current = positive(point.get("batteryCurrentCapacityWh"))
|
|
23
|
+
nominal = positive(point.get("batteryNominalCapacityWh"))
|
|
24
|
+
ratio = current / nominal * 100 if current is not None and nominal is not None else None
|
|
25
|
+
result.update(batteryCurrentCapacityWh=current, batteryNominalCapacityWh=nominal,
|
|
26
|
+
batteryCapacityRetentionPercent=positive(ratio))
|
|
27
|
+
return result
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Framework-independent synchronization and local-history API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from copy import deepcopy
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import aiohttp
|
|
12
|
+
|
|
13
|
+
from .api import DriveNotFoundError, ReadOnlyDrive, folder_id
|
|
14
|
+
from .history import HistoryStore, sync_history
|
|
15
|
+
from .model import ExportNotReady, Snapshot
|
|
16
|
+
from .repository import DriveRepository
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AAOSDriveClient:
|
|
20
|
+
"""Coordinate complete imports, without owning credentials or HTTP sessions.
|
|
21
|
+
|
|
22
|
+
The low-level constructor accepts a repository and an initialized optional
|
|
23
|
+
HistoryStore. Use from_google_drive for automatic history initialization.
|
|
24
|
+
Persist accepted_head in the host application to retain rollback protection
|
|
25
|
+
across restarts; restore it through the factory's accepted_head argument.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(self, repository: DriveRepository, history: HistoryStore | None = None,
|
|
29
|
+
*, executor=asyncio.to_thread):
|
|
30
|
+
self.repository = repository
|
|
31
|
+
self.history = history
|
|
32
|
+
self.executor = executor
|
|
33
|
+
self._lock = asyncio.Lock()
|
|
34
|
+
self._initialize_history = False
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def from_google_drive(cls, session: aiohttp.ClientSession, token_provider, vehicle_folder: str,
|
|
38
|
+
*, history_path: str | Path | None = None,
|
|
39
|
+
expected_identity: dict | None = None, accepted_head: dict | None = None,
|
|
40
|
+
executor=asyncio.to_thread) -> AAOSDriveClient:
|
|
41
|
+
"""Use a caller-owned aiohttp session and async access-token provider.
|
|
42
|
+
|
|
43
|
+
Google OAuth consent and token refresh remain the caller's responsibility.
|
|
44
|
+
No credential is written to the local history database.
|
|
45
|
+
"""
|
|
46
|
+
repository = DriveRepository(ReadOnlyDrive(session, token_provider), folder_id(vehicle_folder),
|
|
47
|
+
executor, expected_identity)
|
|
48
|
+
repository.accepted_head = dict(accepted_head) if accepted_head else None
|
|
49
|
+
history = HistoryStore(history_path) if history_path is not None else None
|
|
50
|
+
client = cls(repository, history, executor=executor)
|
|
51
|
+
client._initialize_history = history is not None
|
|
52
|
+
return client
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def snapshot(self) -> Snapshot | None:
|
|
56
|
+
return self.repository.snapshot
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def accepted_head(self) -> dict | None:
|
|
60
|
+
head = self.repository.accepted_head
|
|
61
|
+
return deepcopy(head) if head else None
|
|
62
|
+
|
|
63
|
+
async def refresh(self) -> Snapshot:
|
|
64
|
+
"""Read the latest complete generation and retain old data on failure.
|
|
65
|
+
|
|
66
|
+
Publication races are retried three times. A generation is accepted only
|
|
67
|
+
after every required archive is validated and local history is complete.
|
|
68
|
+
"""
|
|
69
|
+
async with self._lock:
|
|
70
|
+
if self._initialize_history:
|
|
71
|
+
await self.executor(self.history.initialize)
|
|
72
|
+
self._initialize_history = False
|
|
73
|
+
for attempt in range(3):
|
|
74
|
+
try:
|
|
75
|
+
snapshot = await self.repository.refresh(accept=False)
|
|
76
|
+
if self.history is not None:
|
|
77
|
+
await sync_history(self.history, self.repository, snapshot, self.executor)
|
|
78
|
+
break
|
|
79
|
+
except (ExportNotReady, DriveNotFoundError):
|
|
80
|
+
if attempt == 2:
|
|
81
|
+
raise
|
|
82
|
+
await asyncio.sleep(2 ** attempt)
|
|
83
|
+
self.repository.accept_snapshot(snapshot)
|
|
84
|
+
snapshot.checked_at = datetime.now(timezone.utc)
|
|
85
|
+
if snapshot.previous_identity is not None:
|
|
86
|
+
self.repository.expected_identity = {
|
|
87
|
+
key: snapshot.manifest.raw[key] for key in ("datasetId", "vehicleId")
|
|
88
|
+
}
|
|
89
|
+
return snapshot
|
|
90
|
+
|
|
91
|
+
async def _query(self, method: str, *args) -> Any:
|
|
92
|
+
async with self._lock:
|
|
93
|
+
if self.history is None:
|
|
94
|
+
raise ValueError("Configure history_path to query complete recorded history")
|
|
95
|
+
if self._initialize_history:
|
|
96
|
+
await self.executor(self.history.initialize)
|
|
97
|
+
self._initialize_history = False
|
|
98
|
+
if not (await self.executor(self.history.state))["generation_id"]:
|
|
99
|
+
raise ValueError("Import a complete generation before querying history")
|
|
100
|
+
return await self.executor(getattr(self.history, method), *args)
|
|
101
|
+
|
|
102
|
+
async def history_fields(self) -> dict:
|
|
103
|
+
return await self._query("overview")
|
|
104
|
+
|
|
105
|
+
async def list_trips(self, period: str = "") -> list[dict]:
|
|
106
|
+
return await self._query("summaries", period)
|
|
107
|
+
|
|
108
|
+
async def get_trip(self, trip_id: str = "") -> dict:
|
|
109
|
+
"""Return the exact original trip, including its raw sample arrays."""
|
|
110
|
+
return await self._query("trip", trip_id)
|
|
111
|
+
|
|
112
|
+
async def get_history(self, field: str, *, start: int | None = None, end: int | None = None,
|
|
113
|
+
limit: int = 2000, cursor: dict | None = None,
|
|
114
|
+
trip_id: str | None = None) -> dict:
|
|
115
|
+
"""Return timestamped readings and a generation-bound next_cursor."""
|
|
116
|
+
return await self._query("points", field, start, end, limit, cursor, trip_id)
|