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.
@@ -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,2 @@
1
+ include LICENSE README.md
2
+ recursive-include tests *.py
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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)