From 054bfd38c5f38c0c74cd068c77659a43d1136e6a Mon Sep 17 00:00:00 2001 From: wippofax Date: Sat, 10 Oct 2026 21:55:35 +0200 Subject: [PATCH 1/3] Add experimental MyNISSAN EU vehicle SoC support Add the nissanconnect vehicle module with validated battery, range and automatic odometer retrieval through the European MyNISSAN service. Reuse tokens, honor request backoff and expose private diagnostics with request counts without changing shared charging policy. Include an isolated connection-test worker, synthetic tests and documentation of service-access, privacy and tested-model limits. The companion settings contribution owns the form and PHP endpoints. --- .../modules/vehicles/nissanconnect/NOTICE.md | 46 ++ .../modules/vehicles/nissanconnect/README.md | 119 +++ .../vehicles/nissanconnect/__init__.py | 0 .../modules/vehicles/nissanconnect/config.py | 22 + .../vehicles/nissanconnect/connection_test.py | 73 ++ .../nissanconnect/docs/diagnostics.md | 197 +++++ .../vehicles/nissanconnect/docs/privacy.md | 98 +++ .../vehicles/nissanconnect/docs/source-map.md | 68 ++ .../nissanconnect/docs/user-information.md | 34 + .../modules/vehicles/nissanconnect/soc.py | 45 ++ .../vehicles/nissanconnect/src/__init__.py | 1 + .../modules/vehicles/nissanconnect/src/api.py | 705 ++++++++++++++++++ .../vehicles/nissanconnect/tests/__init__.py | 1 + .../vehicles/nissanconnect/tests/api_test.py | 387 ++++++++++ .../vehicles/nissanconnect/tests/conftest.py | 13 + .../nissanconnect/tests/diagnostics_test.py | 261 +++++++ .../vehicles/nissanconnect/tests/helpers.py | 60 ++ .../nissanconnect/tests/odometer_test.py | 146 ++++ .../nissanconnect/tests/probe_test.py | 99 +++ .../vehicles/nissanconnect/tests/soc_test.py | 116 +++ .../vehicles/nissanconnect/tools/__init__.py | 1 + .../vehicles/nissanconnect/tools/probe.py | 78 ++ 22 files changed, 2570 insertions(+) create mode 100644 packages/modules/vehicles/nissanconnect/NOTICE.md create mode 100644 packages/modules/vehicles/nissanconnect/README.md create mode 100644 packages/modules/vehicles/nissanconnect/__init__.py create mode 100644 packages/modules/vehicles/nissanconnect/config.py create mode 100644 packages/modules/vehicles/nissanconnect/connection_test.py create mode 100644 packages/modules/vehicles/nissanconnect/docs/diagnostics.md create mode 100644 packages/modules/vehicles/nissanconnect/docs/privacy.md create mode 100644 packages/modules/vehicles/nissanconnect/docs/source-map.md create mode 100644 packages/modules/vehicles/nissanconnect/docs/user-information.md create mode 100644 packages/modules/vehicles/nissanconnect/soc.py create mode 100644 packages/modules/vehicles/nissanconnect/src/__init__.py create mode 100644 packages/modules/vehicles/nissanconnect/src/api.py create mode 100644 packages/modules/vehicles/nissanconnect/tests/__init__.py create mode 100644 packages/modules/vehicles/nissanconnect/tests/api_test.py create mode 100644 packages/modules/vehicles/nissanconnect/tests/conftest.py create mode 100644 packages/modules/vehicles/nissanconnect/tests/diagnostics_test.py create mode 100644 packages/modules/vehicles/nissanconnect/tests/helpers.py create mode 100644 packages/modules/vehicles/nissanconnect/tests/odometer_test.py create mode 100644 packages/modules/vehicles/nissanconnect/tests/probe_test.py create mode 100644 packages/modules/vehicles/nissanconnect/tests/soc_test.py create mode 100644 packages/modules/vehicles/nissanconnect/tools/__init__.py create mode 100644 packages/modules/vehicles/nissanconnect/tools/probe.py diff --git a/packages/modules/vehicles/nissanconnect/NOTICE.md b/packages/modules/vehicles/nissanconnect/NOTICE.md new file mode 100644 index 0000000000..7582a94ec5 --- /dev/null +++ b/packages/modules/vehicles/nissanconnect/NOTICE.md @@ -0,0 +1,46 @@ +# Third-party attribution + +This openWB integration follows the repository's GNU GPL version 3 license. +The adapted MIT source retains the notice below; this file does not relicense +the complete module under MIT or grant access to Nissan services. + +The MyNISSAN EU protocol and login flow in `src/api.py` are adapted from +[HomeAssistant-NissanConnect](https://github.com/dan-r/HomeAssistant-NissanConnect/tree/b0bafe9108d4574e243aa182324584dbc68369ad), +revision b0bafe9108d4574e243aa182324584dbc68369ad. +Its client credits @mitchellrj and @Tobiaswk and explicitly states that portions +were relicensed from Apache License, Version 2.0 with permission. We preserve +that upstream provenance statement and rely on the reference's published MIT +grant. The earlier kamereon-python client credits Richard Mitchell (2020); +dartnissanconnect credits Tobias Westergaard Kjeldsen (2021). + +Adapted for openWB by wippofax, 2026-09-11 through 2026-10-10: standard-library +transport, bounded authentication and battery validation, passive queries, +retry/error handling, native module integration and synthetic tests. The +reference project is not responsible for these modifications. + +The retained MIT notice below accompanies the adapted source. See the +[user information](docs/user-information.md) for the unresolved service-access +question; software licensing does not settle that question. Nissan and MyNISSAN are referenced +to identify compatibility; this is an independent, unofficial integration. + +MIT License + +Copyright (c) 2024 Daniel Raper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/modules/vehicles/nissanconnect/README.md b/packages/modules/vehicles/nissanconnect/README.md new file mode 100644 index 0000000000..d3c9c07438 --- /dev/null +++ b/packages/modules/vehicles/nissanconnect/README.md @@ -0,0 +1,119 @@ +# Nissan – MyNISSAN EU (experimental) + +This module reads data stored by Nissan's European MyNISSAN service and provides +openWB with battery state of charge, the original battery measurement time, +and range and odometer values when available. Existing openWB scheduling, +display and charging control consume these values through the standard vehicle +interface. + +**Tested in practice with the Leaf ZE1; other models are unverified.** +Read [Before using MyNISSAN EU](docs/user-information.md) for service-access and +privacy information. This is an unofficial integration; specific permission for +this app-API access has not been established. + +## Compatibility + +- A European MyNISSAN account must work in the official app and have an associated + vehicle. NissanConnect EV and the existing `leaf` module use a different API. +- The client has no Leaf model-code filter. Another vehicle must provide the + same required battery fields through these EU endpoints; presence in the + MyNISSAN app alone does not establish compatibility. Other regions are unverified. +- Login changes, additional consent or MFA can interrupt access. Complete any + required account steps in the official app; the module does not automate them. +- Practical reports cover a 2019 Leaf ZE1 and openWB 2.2.3 at Core revision + `3ced7f9cef3c70980ee1fab670f0d0717d1809ca`. They do not separately establish + odometer accuracy, sustained token renewal or every charging-limit scenario. +- The Python module requires Python 3.9+. The settings connection test additionally + requires the matching settings build, Linux, PHP 7.4+ with `proc_open`, and + Python at `/usr/bin/python3`. No new Python package is required by this module. + +## Configuration + +Choose **Nissan – MyNISSAN EU (experimental)** in the vehicle's SoC settings. +The internal module type is `nissanconnect`; saved custom names are preserved. + +| Field | Meaning | +| --- | --- | +| `user_id` | MyNISSAN account email address | +| `password` | MyNISSAN account password | +| `vin` | Optional for a single vehicle; required to select among multiple vehicles | + +A single associated vehicle is selected automatically. Ambiguous selection or an +unknown VIN produces an error. Configure query intervals using the normal openWB +vehicle settings. **Test connection** queries the current form values without +saving settings or updating charging control; save separately to enable them. + +## Data freshness and errors + +Queries read cached server data. They do not wake the vehicle, request a fresh +vehicle measurement or send charging commands. A successful query can return an +old measurement; the module preserves its timestamp rather than inventing one. +The pinned Status page displays that measurement time, not the latest HTTP request. + +Invalid battery data produces an error. After a successful battery query, the +module automatically attempts to retrieve the odometer, subject to retry pauses +and the request budget. There is no separate user setting to enable it. When +Nissan supplies a valid value, it is passed to openWB in kilometres for the +existing Status page, charge-log and CSV consumers. Missing or invalid odometer +data, or a failed odometer query, does not invalidate a valid SoC result. Missing +or invalid range data likewise leaves a valid battery result usable. + +The odometer comes from `totalMileage`. Its measurement time is unavailable and +must not be inferred from the battery timestamp or the time of retrieval. + +Core decides whether to accept a measurement, calculate SoC from metered energy +or expose an error. The module does not add a charging interlock or override +Core's zero-SoC error policy. See [diagnostics and error behavior](docs/diagnostics.md) +for scheduling, timestamp rejection, retry delays and the known refresh indicator. +SoC DEBUG logs also report this vehicle module's request totals for the last +24 hours, since local midnight and since observation started, with the start +timestamp and coverage flags. +The counters reset when the module is restarted or recreated; separate settings +connection tests are excluded from these totals. + +## Development and offline tests + +The [source map](docs/source-map.md) describes `src`, `tests`, `tools` and `docs` +and the stable integration entry points. Tests use synthetic responses and +block real network access. From the Core repository root, with Python 3.9+, +pytest and Flake8 installed: + +```sh +PYTHONPATH=packages python -m pytest -q packages/modules/vehicles/nissanconnect/tests \ + --confcutdir=packages/modules/vehicles/nissanconnect/tests +python -m flake8 packages/modules/vehicles/nissanconnect +``` + +The companion `openWB/openwb-ui-settings` repository owns the Vue form, both PHP +sources under `public/modules/vehicles/nissanconnect/` and their UI/PHP tests. +Its regular build copies the PHP sources unchanged to their deployed URLs. +See that repository's `docs/nissanconnect-source-map.md`, +`tests/nissanconnect/README.md` and pinned `docs/nissanconnect-2.2.3.md` guide. +Module tests do not replace complete Core CI or live vehicle validation. + +## Optional private-terminal query + +On a development computer, run the tool yourself in a private terminal: + +```sh +python -I -B packages/modules/vehicles/nissanconnect/tools/probe.py +``` + +It asks for confirmation, reads account details using hidden input and displays +cached battery/odometer results. It refuses redirected output and recognizable +agent sessions; these checks cannot detect all recording or monitoring. Keep +credentials and results out of public issues, Git and cloud-assistant sessions. +The tool does not connect to a wallbox or save openWB settings. + +## Contribution and attribution + +This integration was initiated and contributed by [wippofax](https://github.com/wippofax). + +Core owns the Python module; settings owns the form and PHP endpoints. Review +changes together and state the tested versions and limitations. Built settings +assets do not belong in the Core source contribution. + +See [NOTICE.md](NOTICE.md) for source attribution and the retained MIT notice, +and [privacy and diagnostic data](docs/privacy.md) for data flows and the +inherited Core logging limitation. An open-source license does not grant +permission to access Nissan's service. diff --git a/packages/modules/vehicles/nissanconnect/__init__.py b/packages/modules/vehicles/nissanconnect/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/vehicles/nissanconnect/config.py b/packages/modules/vehicles/nissanconnect/config.py new file mode 100644 index 0000000000..ca50b25485 --- /dev/null +++ b/packages/modules/vehicles/nissanconnect/config.py @@ -0,0 +1,22 @@ +"""Native openWB configuration; credentials follow its existing storage lifecycle.""" +from typing import Optional + + +class NissanConnectConfiguration: + """EU account credentials and optional VIN; never log or export this object.""" + + def __init__(self, user_id: Optional[str] = None, password: Optional[str] = None, + vin: Optional[str] = None): + self.user_id = user_id + self.password = password + self.vin = vin + + +class NissanConnect: + """Descriptor defaults consumed by the standard module loader and settings UI.""" + + def __init__(self, name: str = "Nissan – MyNISSAN EU (experimental)", + type: str = "nissanconnect", configuration: NissanConnectConfiguration = None): + self.name = name + self.type = type + self.configuration = configuration or NissanConnectConfiguration() diff --git a/packages/modules/vehicles/nissanconnect/connection_test.py b/packages/modules/vehicles/nissanconnect/connection_test.py new file mode 100644 index 0000000000..9965845aef --- /dev/null +++ b/packages/modules/vehicles/nissanconnect/connection_test.py @@ -0,0 +1,73 @@ +"""One passive settings test. Credentials arrive on stdin, never in argv or files.""" +import json +import logging +import os +import re +import sys + +if __package__: + from .src.api import AuthenticationError, NissanClient, NissanError, RateLimitError, VIN_PATTERN +else: + sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + from src.api import AuthenticationError, NissanClient, NissanError, RateLimitError, VIN_PATTERN + + +# Python validates decoded strings in characters; PHP also limits HTTP bytes. +MAX_STDIN_CHARACTERS = 8192 +MAX_USER_ID_CHARACTERS = 320 +MAX_PASSWORD_CHARACTERS = 4096 +ALLOWED_INPUT_FIELDS = frozenset({"user_id", "password", "vin"}) +AGENT_ENVIRONMENT_VARIABLES = ("CODEX_THREAD_ID", "CODEX_SANDBOX", "CODEX_CI") + + +def run_test(payload, client_factory=NissanClient): + """Validate one unsaved test and return diagnostics or a fixed public error code. + + No MQTT/configuration writes. PHP owns cross-request locking and cooldowns; + this short-lived worker does not share scheduled-client tokens or backoff. + """ + if not isinstance(payload, dict) or set(payload) - ALLOWED_INPUT_FIELDS: + return {"success": False, "code": "invalid_input"} + user = payload.get("user_id") + password = payload.get("password") + vin = payload.get("vin") + if vin == "": + vin = None + if (not isinstance(user, str) or not user.strip() or len(user) > MAX_USER_ID_CHARACTERS + or not isinstance(password, str) or not password or len(password) > MAX_PASSWORD_CHARACTERS + or (vin is not None and (not isinstance(vin, str) + or not re.fullmatch(VIN_PATTERN, vin.strip().upper())))): + return {"success": False, "code": "invalid_input"} + client = None + try: + client = client_factory(user.strip(), password, vin.strip().upper() if vin else None) + client.fetch_battery(include_odometer=True) + return {"success": True, "code": "success", "query": client.last_query} + except AuthenticationError: + code = "authentication_failed" + except RateLimitError: + code = "rate_limited" + except NissanError: + code = "query_failed" + except Exception: + # Neither exception messages nor tracebacks belong in the web response. + return {"success": False, "code": "internal_error"} + return {"success": False, "code": code, "query": client.last_query if client else None} + + +def main(): + logging.disable(logging.CRITICAL) + if any(os.environ.get(name) for name in AGENT_ENVIRONMENT_VARIABLES): + result = {"success": False, "code": "agent_environment"} + else: + try: + body = sys.stdin.read(MAX_STDIN_CHARACTERS + 1) + result = run_test(json.loads(body)) if len(body) <= MAX_STDIN_CHARACTERS else { + "success": False, "code": "invalid_input"} + except Exception: + result = {"success": False, "code": "invalid_input"} + print(json.dumps(result, allow_nan=False)) + + +if __name__ == "__main__": + main() diff --git a/packages/modules/vehicles/nissanconnect/docs/diagnostics.md b/packages/modules/vehicles/nissanconnect/docs/diagnostics.md new file mode 100644 index 0000000000..31f1961b31 --- /dev/null +++ b/packages/modules/vehicles/nissanconnect/docs/diagnostics.md @@ -0,0 +1,197 @@ +# Query diagnostics, errors and connection tests + +## Requests, measurements and scheduling + +The module reads cached Nissan data; it sends no wakeup, refresh or charging +commands. Core calls the updater according to its ordinary vehicle settings +or a manual SoC refresh. A scheduled invocation can be deferred by client +backoff without making an HTTP request. + +In the inspected openWB 2.2.3 Core (`3ced7f9cef3c70980ee1fab670f0d0717d1809ca`): + +- Status displays `soc_timestamp`, set from Nissan's battery measurement time. + It need not change after a successful query and is independent of percentage changes. +- `soc_request_timestamp` records a Core updater attempt, not proof of an HTTP request. +- Switching modules does not reset that request timestamp or force an immediate + query. Normal scheduling applies the currently saved interval to it. +- Manual refresh bypasses the scheduler interval, but not the client's retry + delay or Core's measurement timestamp guard. +- Core rejects a measurement more than 60 seconds older than its stored value, + logging `Not updating SoC, because timestamp is older.` at DEBUG. A recent + manual value can therefore remain visible after successful Nissan retrieval. + +These source-level behaviors do not identify the cause of a particular private +trial. Later Core versions may differ; the module does not replace these policies. + +## Scheduled-query diagnostics + +Each updater invocation emits one JSON summary at DEBUG through the existing +SoC logger (`soc.log` when that context permits DEBUG). The standalone probe +and settings worker do not write these scheduled-query records. + +| Fields | Meaning | +| --- | --- | +| `outcome`, `phase` | Success, failure or deferred invocation; current query phase | +| `request_started_at` | First actual HTTP attempt in this invocation; null when deferred | +| `completed_at`, `duration_seconds` | Completion time and elapsed monotonic seconds | +| `http_requests`, `last_http_status` | HTTP attempts in this invocation (including login/token requests and failed attempts), and latest response status if available | +| `http_requests_last_24h` | Request attempts in the rolling preceding 24 hours, for this vehicle module instance | +| `http_requests_since_midnight` | Request attempts since 00:00 in the wallbox's local timezone, for this vehicle module instance | +| `http_requests_since_start` | Total request attempts since `http_requests_counting_since`, including attempts older than 24 hours | +| `http_requests_counting_since` | UTC timestamp at which the current counting period started | +| `http_requests_local_midnight` | Current day's local midnight, including its UTC offset | +| `http_requests_last_24h_complete`, `http_requests_since_midnight_complete` | Whether observation covers the entire respective window | +| `http_requests_counter_reset_reason` | Why the current observation period started: `module_started`, `clock_changed` or `capacity_limit` | +| `last_contact_at` | Latest actual HTTP attempt, including failures; retained during backoff | +| `last_success_at` | Latest successful battery query, retained after failure | +| `measurement_at`, `data_age_seconds` | Original battery measurement and its age at completion | +| `last_measurement_at` | Measurement time from the latest successful battery query | +| `soc`, `range_km` | Parsed battery percentage and optional range; null without valid battery data | +| `odometer_km`, `odometer_outcome` | Total kilometres when available and success/unavailable/failed/deferred/not_requested status | +| `odometer_http_status`, `odometer_retry_after_seconds` | Odometer endpoint status when available and remaining separate pause | +| `odometer_received_at` | Reception time of a valid odometer; its measurement time is unknown | +| `retry_after_seconds` | Remaining client pause | +| `provider_retry_after_seconds` | Remaining provider advice, excluding the client's own error backoff | +| `source` | `nissan_cached_api` | + +Times are ISO 8601 UTC, except `http_requests_local_midnight`, which includes +the wallbox-local UTC offset. A deferred invocation has zero HTTP requests and no new +request start. A small negative data age can indicate clock skew. `outcome=success` +means the client retrieved valid battery data, before Core decides whether to +accept the returned state. Odometer reception time never substitutes for battery +or odometer measurement time. + +Module summaries exclude usernames, passwords, VINs, tokens, cookies, URLs, raw +responses and transport exceptions. Vehicle values and times remain private; +see the separate [inherited logging limitation](privacy.md#logs-and-retention). + +## Rolling and daily request totals + +Search scheduled SoC DEBUG logs for `http_requests_last_24h` and +`http_requests_since_midnight`; `http_requests_since_start` gives the cumulative +total since the timestamp in `http_requests_counting_since`. This total keeps +growing across midnights and when requests expire from the rolling window. +The totals count each HTTP attempt immediately +before transport, including login, token renewal, vehicle selection, battery, +odometer and failed attempts. A paused invocation adds no request. An attempt is +not proof that the server received it. Totals are updated even while DEBUG is +disabled and are reported when the next updater invocation is logged. + +History belongs to one configured vehicle's module instance. Automatic updates +and the normal manual SoC refresh share it; a replacement API client within that +instance keeps the history. Other vehicles, the MyNISSAN app, private probes and +the separately executed settings connection tests are excluded. These are not +account-wide provider statistics; the settings test still shows only its own +`http_requests` count. + +Request timestamps and a cumulative count are retained in memory; no new file +or MQTT topic is created. Restarting or recreating the vehicle module resets +history and the cumulative count together with their start timestamp. The +`http_requests_counting_since` value identifies the observed period and each +`*_complete` flag remains false until its full window has been observed. A zero +with incomplete coverage is not a claim that no earlier requests occurred. + +The rolling window uses elapsed monotonic seconds and excludes events exactly +24 hours old. The daily window starts at local midnight, including on 23-hour +and 25-hour days. A system-clock correction exceeding five seconds or a backward +monotonic clock starts a new observation period. A 100,000-event memory bound +also resets coverage explicitly instead of silently dropping counted history. +Each reset restarts the cumulative count and its start timestamp together. +The reset reason remains visible. These diagnostics do not change polling, +backoff, charging control or Nissan rate limits. + +## Validation, retries and Core fallback + +SoC must be finite and within 0–100%; a genuine zero is valid. Battery time must +include a timezone, be positive and be no more than five minutes ahead of the +local clock. Invalid required fields fail; missing or invalid range/odometer +values remain unavailable rather than being fabricated or copied from an earlier +response. Their absence does not invalidate a valid battery result. + +The transport verifies HTTPS certificates, restricts destinations and redirects, +and limits each response to 1 MiB. Socket timeouts are at most ten seconds; +no new request starts after a 60-second query budget. This is not a hard deadline +for a socket operation already running. Tokens are reused in memory; changing +account configuration or restarting the module creates a new client. + +Failed battery queries use pauses of 5, 10, 20, 40 and then at most 60 minutes. +Success resets that sequence. HTTP 429/503 honor `Retry-After` in seconds or +HTTP-date form, bounded to 5 minutes through 24 hours for the scheduled client. +Deferred invocations make no HTTP request and do not extend the pause. Actual +retry time also depends on Core's polling interval. Authorization failure clears +rejected tokens; ordinary service/network failures retain authentication for reuse. + +After valid battery data, the module automatically attempts to read +`totalMileage` from the cockpit endpoint using the same token and request budget, +subject to retry pauses. Users do not need to enable a separate odometer option. +Only data availability is conditional: unavailable or invalid mileage and query +errors leave the successful battery result usable. Odometer endpoint errors pause +that endpoint for five minutes without forcing a second login. Provider rate-limit +advice can also defer subsequent requests. + +The existing Core wrapper may calculate SoC from metered charging energy after +failed or stale API data when its required inputs are available. This depends on +connection state, previous SoC, meter readings, battery capacity and efficiency; +the pinned fallback also treats a previous zero SoC as absent. If fallback fails, +normal Core fault handling applies. In the pinned version, three consecutive +failed updates lead to publication of zero before the next update; deferred +client calls can count as failed updates without being HTTP requests. Zero does +not mean unknown to the charge-limit logic, so an API failure does not guarantee +charging stops at the intended SoC target. The module neither suppresses errors +nor changes that policy, and exposes no separate `calculate_soc` setting. + +## Settings connection test + +**Test connection** uses current form values without saving them or publishing +results to MQTT/charging control. It displays selected battery/odometer values, +query times, original battery time and battery data age in the browser's timezone. +The result also shows **Nissan HTTP requests (this test)** from `http_requests`, +including authentication, vehicle selection, battery and odometer attempts. +Failed attempts count too; this is not proof that Nissan received every request. +The count is shown on success and failure when available. Missing or invalid +counts and interrupted responses show **Not available**, never an assumed zero. +This is a per-test count, not a persistent total across tests or scheduled queries. +Displaying it triggers no additional Nissan requests. + +Changing input clears the result. An in-flight result from the previous input +is discarded, while any resulting cooldown remains associated with its account. + +The settings build delivers both PHP files from `public/modules/vehicles/nissanconnect/`. +The endpoint URL is `/openWB/web/settings/modules/vehicles/nissanconnect/test_connection.php`; +it invokes Core's `packages/modules/vehicles/nissanconnect/connection_test.py`. +Production requires Linux, PHP 7.4+ with `proc_open` and Python 3.9+ at +`/usr/bin/python3`. UI-only installation cannot supply the Python worker. + +The endpoint accepts same-origin JSON POST and inherits web-server access control. +Origin checks are not authentication; reverse proxies must preserve the actual +scheme/host relationship. Credentials go through POST and worker stdin, never +command arguments. Responses use `no-store`, stderr is discarded and errors use +fixed codes. Run actual account tests privately; the agent-environment guard is +only a precaution. See [privacy](privacy.md) for storage and transport boundaries. + +Only one settings test runs at a time. Complete battery success, including zero +SoC, starts a five-minute account cooldown at completion. Account changes are +allowed; returning to an account retains its pause, even with changed password +or VIN. Ordinary failure or a busy response adds no account cooldown. Provider +advice is independent and applies across accounts when its scope is unknown: +429 without usable advice defaults to five minutes; valid 429/503 advice is +capped at 24 hours; 503 without usable advice adds no manual-test pause. + +The server stores account cooldown digests and expiry times in private temporary +state. It remains authoritative after a reload or across browser tabs. This +manual-test state is separate from scheduled-client authentication and backoff. +The PHP parent terminates a still-running worker after 70 seconds; the browser +times out after 75 seconds. Closing the page does not guarantee cancellation of +a server query already running. These limits are covered by synthetic tests; +they do not prove every target host's process behavior. + +## Color-theme refresh indicator + +In the inspected 2.2.3 Color theme, the pending indicator clears only when the +connected-vehicle SoC object is republished with changed contents. Cached data +or a rejected older measurement can leave it pending after the query finishes. +A change in percentage, time, range or fault data can trigger that publication; +the percentage alone is not decisive. The theme has no timeout for this flag. +Reloading resets the local indicator, not vehicle measurements. This separate +theme behavior is not worked around by inventing times or publishing chargepoint +state from the Nissan module. diff --git a/packages/modules/vehicles/nissanconnect/docs/privacy.md b/packages/modules/vehicles/nissanconnect/docs/privacy.md new file mode 100644 index 0000000000..ca08c815bf --- /dev/null +++ b/packages/modules/vehicles/nissanconnect/docs/privacy.md @@ -0,0 +1,98 @@ +# Privacy and diagnostic data + +See [Before using MyNISSAN EU](user-information.md) for the short user notice. +This technical description covers module behavior and source-reviewed openWB +data paths. It is not verification of an installation or a GDPR/BDSG compliance +assessment. Inherited Core observations refer to revision +`3ced7f9cef3c70980ee1fab670f0d0717d1809ca`. + +## Data paths + +| Data | Processing and storage | +| --- | --- | +| Entered username, password and optional VIN | Browser form and existing Vue configuration store; an unsaved test sends them to the same-origin PHP endpoint and then worker stdin. The component adds no local/session storage; parent state and browser autofill remain separate. | +| Saved account configuration | Ordinary Core configuration, including `openWB/vehicle//soc_module/config`, retained MQTT state and persistence, permitted subscribers and backups. No module-level encryption or expiry is added. | +| Login data, cookies and tokens | Python client memory and fixed Nissan/OneID/Kamereon HTTPS services in `src/api.py`. Changing configuration creates a new client; process exit releases references but does not promise secure memory erasure. | +| Selected account/vehicle identifiers | Client memory and authenticated API requests. They are excluded from module diagnostics; no response cache is written by the client. | +| SoC, battery time, range and odometer when available | Parsed results, native `CarState`, existing Core state/MQTT, display, applicable histories and backups. Core owns their lifecycle. | +| Query diagnostics and request totals | Client memory; scheduled DEBUG summary; selected fields returned by the settings test. Values and times can reveal vehicle activity even without identifiers. | +| Test cooldown metadata | Expiry times, a random HMAC key and keyed normalized-account digests in `openwb-nissanconnect-test/state` under the PHP temporary directory; account countdowns also exist in component memory. The digests are pseudonymous, not anonymous. | + +The private-terminal probe reads hidden input and prints results locally. It +does not use MQTT or the settings endpoint. Its terminal/agent checks cannot +prevent screen sharing, recording or host inspection. + +## Transport and access + +Nissan connections verify HTTPS certificates. Python `urllib` can inherit +environment/system proxies and trusts the host certificate configuration. +The browser-to-wallbox connection uses the settings page's HTTP/HTTPS transport; +password masking and same-origin POST do not encrypt HTTP. + +The endpoint has no separate authentication. Origin, content-type and Fetch +Metadata checks restrict browser cross-site submissions; a direct HTTP client +can supply those headers. Web-server/MQTT access controls remain installation +responsibilities. This module adds no developer analytics or model-provider +destination. Existing Cloud, MQTT bridges, remote access, proxies, monitoring and +backup services have their own recipients and retention. + +The inspected local broker bridge exports `soc_module/#` and `get/#` to the +public broker; access depends on its ACLs. An ordinary external status bridge +can export `openWB/vehicle/#`, including configuration. The Cloud generator uses +a different control path. This source review establishes neither that all +credentials are uploaded to Cloud nor that they always remain local. + +## Logs and retention + +Module diagnostics exclude credentials, identifiers, tokens, URLs and raw +responses. The settings worker disables logging; PHP discards worker stderr, +returns fixed errors and uses `Cache-Control: no-store`. Server/proxy logging of +request bodies is outside module control. + +Request totals retain request timestamps and a cumulative count in the native +vehicle module's memory, with a bounded event count. Restart or module recreation +discards that history. No additional state file or MQTT topic is introduced. +Logged totals and their coverage times follow the same private-log lifecycle as +the other query diagnostics; they can reveal query activity. Separate settings +tests do not read or combine the native module's request history. + +**Inherited Core debug-log redaction is incomplete.** At the inspected version, +shared MQTT handlers log payloads, the central filter does not mask `user_id` or +`vin` by default, and an escaped quotation mark in a JSON password can leave a +suffix visible. This was reproduced with synthetic input, not a real account. +The module's own safe summaries and three UI console-payload removals do not +fix shared backend logging. Keep logs private and use detailed logging for +bounded diagnosis; do not weaken a password to accommodate a log parser. + +Log rotation is size/event based, not a guaranteed deletion deadline. Reducing +the log level does not remove existing records, exports or backups. Treat their +sharing as a separate disclosure; do not submit private logs to issues or cloud +assistants. + +The settings test does not save account settings or vehicle responses to disk. +Its temporary state contains a local key, account digests and expiry times. +Expired accounts are pruned on load and persisted on the next successful write; +an early return can leave expired records on disk. No timer guarantees deletion +after five minutes. Provider pauses are capped at 24 hours; state is bounded to +1024 active account entries and 128 KiB. + +The endpoint checks Linux UID, a trusted temporary parent, a real 0700 directory +and regular 0600 state file owned by that UID. It rejects unsafe modes, symlinks, +multiple hard links and descriptor/path mismatches without silently repairing +or deleting them. Root and other code using the same UID remain outside this +boundary. Linux filesystem and synthetic HTTP checks have passed; that does not +establish permissions or isolation on every installation. + +## Backups and removal + +Normal openWB backups can include configuration, MQTT databases, broker settings, +logs and histories. In the inspected backup script, encryption depends on a +configured key file; compression and checksums alone provide no confidentiality. +No actual user's backup encryption or remote retention was inspected. + +Removing module files does not remove saved credentials. Replacing the active +configuration does not erase old logs, SD images, database snapshots, exported +settings, password-manager copies or previously shared data. Restoring a backup +can restore old credentials. Protect retained recovery copies and account for +their lifecycle separately. Battery and odometer values share these existing +storage/access boundaries; the odometer has no known measurement timestamp. diff --git a/packages/modules/vehicles/nissanconnect/docs/source-map.md b/packages/modules/vehicles/nissanconnect/docs/source-map.md new file mode 100644 index 0000000000..08d5181912 --- /dev/null +++ b/packages/modules/vehicles/nissanconnect/docs/source-map.md @@ -0,0 +1,68 @@ +# Nissan module source map + +This map describes the implemented source layout. Paths in the tree are relative +to the Core repository root. Production code, synthetic tests, manual tools and +documentation have separate directories; native integration entry points remain +at the module root. This is not a deployment inventory. + +## Directory and file responsibilities + +```text +packages/modules/vehicles/nissanconnect/ +├── __init__.py # Native Python package marker +├── config.py # openWB configuration and defaults +├── soc.py # openWB adapter: client measurements to CarState +├── connection_test.py # Production stdin worker used by the PHP endpoint +├── README.md # Introduction, configuration and test commands +├── NOTICE.md # Source attribution and license notices +├── src/ +│ ├── __init__.py # Internal implementation package +│ └── api.py # Authentication, transport, retries and in-memory request counters +├── tests/ +│ ├── __init__.py # Offline-test package +│ ├── conftest.py # Automatic network denial +│ ├── helpers.py # Shared synthetic responses, transport and clocks +│ ├── api_test.py # Authentication, validation and retry tests +│ ├── diagnostics_test.py # Query counters, calendar boundaries and stdin-worker contracts +│ ├── odometer_test.py # Odometer parsing, availability and query tests +│ ├── probe_test.py # Terminal guards and both isolated script entry points +│ └── soc_test.py # Native adapter and configuration-change tests +├── tools/ +│ ├── __init__.py # Optional manual-tool package +│ └── probe.py # Query in a private local terminal +└── docs/ + ├── source-map.md # This map and integration boundaries + ├── user-information.md # Short service-access and privacy notice + ├── diagnostics.md # Logs, implemented error behavior and connection tests + └── privacy.md # Data flows, storage and privacy limits +``` + +## Integration boundaries + +- `soc.py` remains `modules.vehicles.nissanconnect.soc`, the vehicle type's + dynamic Core entry point. Scheduling and charge policy remain Core-owned. +- `config.py` retains the existing configuration import convention. +- `connection_test.py` is a production stdin worker despite its filename. The + settings PHP endpoint calls this exact module-root path with isolated Python. + It loads the internal `src.api` implementation without importing Core services. +- `tools/probe.py` supports both a package import and direct isolated execution. + Its synthetic startup test cannot create a real API client. +- Tests import shared fixtures from `tests/helpers.py`, never another test file. + `tests/conftest.py` denies network access within this test package. +- Timeout, size and dimensional constants live beside their implementation; + names distinguish seconds, Unix seconds, milliseconds, bytes and characters. + +## Companion repository and reading guide + +`openWB/openwb-ui-settings` owns the Vue form and both PHP endpoint sources, +including their UI/PHP tests. Its overview is `docs/nissanconnect-source-map.md`. +Core owns the Python client, native adapter and production stdin worker. + +Start with [README.md](../README.md) for setup and executable test commands, +[user-information.md](user-information.md) for the short user notice, and +[diagnostics.md](diagnostics.md) for the query/connection-test contract. +README owns compatibility and setup guidance; diagnostics owns implemented +query/error behavior. Detailed project research is not part of the module docs. + +Keep this map, imports, test commands and companion paths synchronized with +source moves. Source layout changes do not update an installed wallbox. diff --git a/packages/modules/vehicles/nissanconnect/docs/user-information.md b/packages/modules/vehicles/nissanconnect/docs/user-information.md new file mode 100644 index 0000000000..71318cb31e --- /dev/null +++ b/packages/modules/vehicles/nissanconnect/docs/user-information.md @@ -0,0 +1,34 @@ +# Before using MyNISSAN EU + +This experimental integration has practical test reports for the **Leaf ZE1** +in Europe. Other Nissan models are unverified. It reads data already stored by +Nissan and does not wake the vehicle; a successful request can return old data. + +**Service access.** This is an unofficial client, not a Nissan-approved +integration. Specific permission for this app-API access has not been +established. Nissan's account and service terms are separate from this code's +open-source license; the experimental label does not resolve that question. +Login or API changes can interrupt operation. Consult the terms applicable +to your account; [Nissan's terms index](https://www.nissan.de/rechtliches/agb.html) +provides the published German service terms. + +**Account data.** The browser sends entered details to your wallbox; it contacts +the Nissan login/data services over HTTPS. Saving settings stores credentials +in ordinary openWB configuration, which can be present in MQTT and backups, +without encryption added by this module. Password masking does not protect +storage, and an HTTP settings page does not encrypt browser-to-wallbox traffic. +The connection test itself does not save account settings or update charging. + +**Logs and sharing.** Detailed SoC logs contain vehicle values and times. +The reviewed Core debug-log filtering can also leave account identifiers or +password fragments visible. Keep settings, backups and logs private; do not +attach them to public issues or send them to cloud assistants. Use detailed +logging only for a bounded diagnostic session. + +**Other data paths and removal.** This module adds no developer analytics. +Existing Cloud, MQTT bridges, remote access and backup services have separate +data paths; their effective recipients and retention were not verified here. +Removing the module does not erase saved settings or old logs/backups, and +restoring a backup can restore old credentials. Keep needed recovery backups +protected. See [privacy and diagnostic data](privacy.md) for the reviewed paths +and limits; this information is not a GDPR compliance certification. diff --git a/packages/modules/vehicles/nissanconnect/soc.py b/packages/modules/vehicles/nissanconnect/soc.py new file mode 100644 index 0000000000..2cf0c76a43 --- /dev/null +++ b/packages/modules/vehicles/nissanconnect/soc.py @@ -0,0 +1,45 @@ +"""Adapt Nissan measurements to the existing openWB vehicle wrapper and scheduler.""" +import json +import logging + +from modules.common.abstract_device import DeviceDescriptor +from modules.common.abstract_vehicle import VehicleUpdateData +from modules.common.component_state import CarState +from modules.common.configurable_vehicle import ConfigurableVehicle +from modules.vehicles.nissanconnect.src.api import NissanClient, RequestCounter +from modules.vehicles.nissanconnect.config import NissanConnect + + +log = logging.getLogger(__name__) + + +def create_vehicle(vehicle_config: NissanConnect, vehicle: int): + """Keep one client per configuration; delegate scheduling/error policy to Core.""" + client = None + configuration_key = None + # Per configured vehicle, including client replacement after edited credentials. + # Restart/recreation discards history; no file, MQTT topic or Core state is added. + request_counter = RequestCounter() + + def updater(vehicle_update_data: VehicleUpdateData) -> CarState: + nonlocal client, configuration_key + config = vehicle_config.configuration + current = (config.user_id, config.password, config.vin) + # Keep tokens between updates; discard them when the account/vehicle changes. + if client is None or current != configuration_key: + client = NissanClient(*current, request_counter=request_counter) + configuration_key = current + try: + battery = client.fetch_battery(include_odometer=True) + finally: + # No account identifiers or secrets; battery values/times are still private. + if isinstance(client.last_query, dict): + log.debug("Nissan vehicle %s query: %s", vehicle, json.dumps(client.last_query, sort_keys=True)) + return CarState(soc=battery.soc_percent, range=battery.range_km, soc_timestamp=battery.measurement_unix_seconds, + odometer=battery.odometer_km) + + return ConfigurableVehicle(vehicle_config=vehicle_config, component_updater=updater, + vehicle=vehicle, calc_while_charging=False) + + +device_descriptor = DeviceDescriptor(configuration_factory=NissanConnect) diff --git a/packages/modules/vehicles/nissanconnect/src/__init__.py b/packages/modules/vehicles/nissanconnect/src/__init__.py new file mode 100644 index 0000000000..a40af5b46f --- /dev/null +++ b/packages/modules/vehicles/nissanconnect/src/__init__.py @@ -0,0 +1 @@ +"""Internal Nissan API implementation.""" diff --git a/packages/modules/vehicles/nissanconnect/src/api.py b/packages/modules/vehicles/nissanconnect/src/api.py new file mode 100644 index 0000000000..d4a74ca4ff --- /dev/null +++ b/packages/modules/vehicles/nissanconnect/src/api.py @@ -0,0 +1,705 @@ +"""MyNISSAN EU/OneID client. Cached battery/odometer data; no vehicle commands. + +Protocol adapted from HomeAssistant-NissanConnect; see NOTICE.md for attribution. +Adapted for openWB by wippofax, 2026-09-11 through 2026-10-10, under GPLv3. +Credentials, cookies and tokens stay in memory. Never log transport payloads. +""" +import base64 +from collections import deque +from dataclasses import dataclass, field, replace +from datetime import datetime, timezone +from email.utils import parsedate_to_datetime +import hashlib +from html.parser import HTMLParser +from http import HTTPStatus +from http.client import HTTPException +from http.cookiejar import CookieJar +import json +import math +import re +import secrets +import ssl +import time +from typing import Optional +from urllib.error import HTTPError, URLError +from urllib.parse import parse_qs, quote, urlencode, urljoin, urlsplit +from urllib.request import build_opener, HTTPCookieProcessor, HTTPRedirectHandler, HTTPSHandler, Request + + +AUTH_BASE_URL = "https://login.mynissan-account.com/" +BFF_BASE_URL = "https://nci-bff-web-prod.apps.eu2.kamereon.io/bff-web/" +USERS_BASE_URL = "https://alliance-platform-usersadapter-prod.apps.eu2.kamereon.io/user-adapter/" +CARS_BASE_URL = "https://alliance-platform-caradapter-prod.apps.eu2.kamereon.io/car-adapter/" +OAUTH_CALLBACK_URI = "com://wso2.service.nci" +# Public application identifier from the reference implementation; not a secret. +OAUTH_CLIENT_ID = "ZM3WK7ax1OtQKYQ8Qqzcv5VgiA8a" +OAUTH_LOGIN_SCOPE = "openid name profile email offline_access" +# Transport/query limits use seconds and bytes, not decoded character counts. +MAX_RESPONSE_BYTES = 1024 * 1024 +SOCKET_TIMEOUT_SECONDS = 10 +QUERY_BUDGET_SECONDS = 60 +MAX_MEASUREMENT_FUTURE_SKEW_SECONDS = 300 +DEFAULT_HTTPS_PORT = 443 +MAX_LOGIN_REDIRECTS = 10 +MAX_AUTHENTICATED_REQUEST_ATTEMPTS = 2 + +# Keep separate policies separate even when their current values are equal. +DEFAULT_TOKEN_LIFETIME_SECONDS = 3600 +TOKEN_REFRESH_MARGIN_SECONDS = 30 +DEFAULT_RETRY_AFTER_SECONDS = 300 +MIN_RETRY_AFTER_SECONDS = 300 +MAX_RETRY_AFTER_SECONDS = 86400 +INITIAL_FAILURE_BACKOFF_SECONDS = 300 +MAX_FAILURE_BACKOFF_SECONDS = 3600 +MAX_FAILURE_BACKOFF_STEPS = 5 +FAILURE_BACKOFF_MULTIPLIER = 2 +ODOMETER_FAILURE_BACKOFF_SECONDS = 300 +MAX_SOC_PERCENT = 100 +DURATION_DECIMAL_PLACES = 3 +DATA_AGE_DECIMAL_PLACES = 1 +REQUEST_COUNTER_WINDOW_SECONDS = 24 * 60 * 60 +REQUEST_COUNTER_CLOCK_SKEW_SECONDS = 5 +MAX_REQUEST_COUNTER_EVENTS = 100000 + +PKCE_VERIFIER_RANDOM_BYTES = 64 +OAUTH_STATE_RANDOM_BYTES = 32 +VIN_PATTERN = r"[A-HJ-NPR-Z0-9]{17}" +JSON_API_CONTENT_TYPE = "application/vnd.api+json" +OAUTH_REFRESH_SCOPE = "openid profile vehicles" +DIAGNOSTIC_SOURCE = "nissan_cached_api" +INVALID_BATTERY_VALUE_MESSAGE = "Nissan returned an invalid battery value." +INVALID_LOGIN_REDIRECT_MESSAGE = "Unexpected redirect during Nissan login." +INVALID_LOGIN_FORM_DESTINATION_MESSAGE = "Unexpected Nissan login form destination." +REDIRECT_STATUSES = (HTTPStatus.MOVED_PERMANENTLY, HTTPStatus.FOUND, HTTPStatus.SEE_OTHER, + HTTPStatus.TEMPORARY_REDIRECT, HTTPStatus.PERMANENT_REDIRECT) + + +class RequestCounter: + """In-memory request history owned by one native vehicle module instance. + + Store timestamps and a cumulative count, never account or vehicle identifiers. + The cumulative count survives expiry of individual request timestamps. + Monotonic time defines the rolling window; local calendar time defines the + current day, which may last 23 or 25 hours. Keep an event while either window + needs it. Resets expose a new coverage start instead of claiming a full day. + The ordinary vehicle updater serializes access; no cross-process sharing. + """ + + def __init__(self): + self._events = deque() + self._requests_since_start_count = 0 + self._started_monotonic_seconds = None + self._started_unix_seconds = None + self._last_monotonic_seconds = None + self._reset_reason = "module_started" + + def _reset(self, monotonic_seconds, unix_seconds, reason): + self._events.clear() + self._requests_since_start_count = 0 + self._started_monotonic_seconds = monotonic_seconds + self._started_unix_seconds = unix_seconds + self._reset_reason = reason + + def observe(self, monotonic_seconds, unix_seconds): + """Advance coverage and expiry even when backoff sends no request.""" + if self._started_monotonic_seconds is None: + self._reset(monotonic_seconds, unix_seconds, "module_started") + elif (monotonic_seconds < self._last_monotonic_seconds or abs( + (unix_seconds - self._started_unix_seconds) - + (monotonic_seconds - self._started_monotonic_seconds)) > REQUEST_COUNTER_CLOCK_SKEW_SECONDS): + self._reset(monotonic_seconds, unix_seconds, "clock_changed") + self._last_monotonic_seconds = monotonic_seconds + # A naive local midnight is converted using that date's system timezone + # rules, not the current fixed UTC offset (which can differ after DST). + midnight = datetime.fromtimestamp(unix_seconds).replace(hour=0, minute=0, second=0, microsecond=0, fold=0) + midnight_unix_seconds = midnight.timestamp() + cutoff_monotonic_seconds = monotonic_seconds - REQUEST_COUNTER_WINDOW_SECONDS + while (self._events and self._events[0][0] <= cutoff_monotonic_seconds + and self._events[0][1] < midnight_unix_seconds): + self._events.popleft() + return midnight + + def record(self, monotonic_seconds, unix_seconds): + """Count immediately before transport, including requests that fail.""" + self.observe(monotonic_seconds, unix_seconds) + if len(self._events) >= MAX_REQUEST_COUNTER_EVENTS: + # Bound memory without silently truncating an allegedly complete sum. + self._reset(monotonic_seconds, unix_seconds, "capacity_limit") + self._events.append((monotonic_seconds, unix_seconds)) + self._requests_since_start_count += 1 + + def snapshot(self, monotonic_seconds, unix_seconds): + midnight = self.observe(monotonic_seconds, unix_seconds) + midnight_unix_seconds = midnight.timestamp() + cutoff_monotonic_seconds = monotonic_seconds - REQUEST_COUNTER_WINDOW_SECONDS + return { + "http_requests_last_24h": sum(event[0] > cutoff_monotonic_seconds for event in self._events), + "http_requests_since_midnight": sum(event[1] >= midnight_unix_seconds for event in self._events), + "http_requests_since_start": self._requests_since_start_count, + "http_requests_counting_since": datetime.fromtimestamp( + self._started_unix_seconds, timezone.utc).isoformat(), + "http_requests_local_midnight": midnight.astimezone().isoformat(), + "http_requests_last_24h_complete": self._started_monotonic_seconds <= cutoff_monotonic_seconds, + "http_requests_since_midnight_complete": self._started_unix_seconds < midnight_unix_seconds, + "http_requests_counter_reset_reason": self._reset_reason, + } + + +class NissanError(Exception): + """A deliberately bounded message safe for openWB's error reporting.""" + + +class AuthenticationError(NissanError): + pass + + +class RateLimitError(NissanError): + pass + + +@dataclass +class HttpResponse: + status: int + headers: dict = field(repr=False) + body: str = field(repr=False) + + +class _NoRedirect(HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + return None + + +class PrivateTransport: + """TLS-verified transport without response logging, netrc or automatic redirects. + + openWB's standard requests hook logs complete authentication responses. urllib + avoids that hook and urllib3's debug logging of URLs containing VINs/codes. + CookieJar is memory-only; no cookie or token cache is written to disk. + urllib may inherit system/environment proxies; TLS still uses the local + trust store. This transport is not an independent network sandbox. + """ + + def __init__(self): + self._cookies = CookieJar() + self._opener = build_opener( + HTTPSHandler(context=ssl.create_default_context()), + HTTPCookieProcessor(self._cookies), _NoRedirect()) + + def clear_cookies(self): + self._cookies.clear() + + def request(self, method, url, headers=None, form=None, json_body=None, timeout_seconds=SOCKET_TIMEOUT_SECONDS): + request_headers = dict(headers or {}) + data = None + if form is not None: + data = urlencode(form).encode("utf-8") + request_headers["Content-Type"] = "application/x-www-form-urlencoded" + elif json_body is not None: + data = json.dumps(json_body).encode("utf-8") + request_headers["Content-Type"] = JSON_API_CONTENT_TYPE + try: + request = Request(url, data=data, headers=request_headers, method=method) + try: + response = self._opener.open(request, timeout=timeout_seconds) + except HTTPError as error: + response = error + with response: + body = response.read(MAX_RESPONSE_BYTES + 1) + if len(body) > MAX_RESPONSE_BYTES: + raise NissanError("Nissan response is too large.") + return HttpResponse(response.code, dict(response.headers), body.decode("utf-8")) + except (URLError, OSError, HTTPException, ValueError): + raise NissanError("Nissan is unreachable or returned an invalid response.") from None + + +class _LoginForm(HTMLParser): + def __init__(self): + super().__init__() + self.forms = [] + self.current = None + + def handle_starttag(self, tag, attrs): + attrs = dict(attrs) + if tag == "form": + self.current = {"action": attrs.get("action"), "inputs": {}} + elif tag == "input" and self.current is not None and attrs.get("name"): + self.current["inputs"][attrs["name"]] = attrs.get("value", "") + + def handle_endtag(self, tag): + if tag == "form" and self.current is not None: + self.forms.append(self.current) + self.current = None + + @classmethod + def parse(cls, body): + parser = cls() + parser.feed(body) + for form in parser.forms: + if {"sessionDataKey", "password"}.issubset(form["inputs"]): + return form + return None + + +def _origin(url): + try: + parsed = urlsplit(url) + if parsed.username or parsed.password or parsed.fragment or parsed.scheme != "https": + return None + return parsed.hostname, parsed.port or DEFAULT_HTTPS_PORT + except ValueError: + return None + + +def _object(response): + try: + body = json.loads(response.body) + except (ValueError, TypeError): + raise NissanError("Nissan returned an invalid JSON response.") from None + if not isinstance(body, dict) or body.get("errors") or body.get("error"): + raise NissanError("Nissan reported an error or returned an unknown response format.") + return body + + +def _number(value, maximum=None): + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise NissanError(INVALID_BATTERY_VALUE_MESSAGE) + try: + value = float(value) + except OverflowError: + raise NissanError(INVALID_BATTERY_VALUE_MESSAGE) from None + if not math.isfinite(value) or value < 0 or (maximum is not None and value > maximum): + raise NissanError(INVALID_BATTERY_VALUE_MESSAGE) + return float(value) + + +@dataclass(frozen=True) +class BatteryStatus: + """SoC and its Unix measurement time; optional range/odometer in km. + + The battery timestamp does not describe the separate cockpit odometer. + """ + + soc_percent: float + measurement_unix_seconds: float + range_km: Optional[float] = None + odometer_km: Optional[float] = None + + +def parse_battery(body, now_unix_seconds=None): + """Require an actual, timezone-aware measurement time; never substitute now.""" + try: + attributes = body["data"]["attributes"] + soc_percent = _number(attributes["batteryLevel"], MAX_SOC_PERCENT) + raw_time = attributes["lastUpdateTime"] + if not isinstance(raw_time, str): + raise ValueError + measured = datetime.fromisoformat(raw_time.replace("Z", "+00:00")) + if measured.tzinfo is None or measured.utcoffset() is None: + raise ValueError + measurement_unix_seconds = measured.timestamp() + if not math.isfinite(measurement_unix_seconds) or measurement_unix_seconds <= 0 or measurement_unix_seconds > ( + time.time() if now_unix_seconds is None else now_unix_seconds) + MAX_MEASUREMENT_FUTURE_SKEW_SECONDS: + raise ValueError + range_km = attributes.get("rangeHvacOn") + if range_km is None: + range_km = attributes.get("rangeHvacOff") + # An unavailable optional range must not discard a valid SoC measurement. + try: + range_km = None if range_km is None else _number(range_km) + except NissanError: + range_km = None + return BatteryStatus(soc_percent, measurement_unix_seconds, range_km) + except (KeyError, TypeError, ValueError, AttributeError, OverflowError): + raise NissanError("Nissan did not return a valid SoC with a measurement timestamp.") from None + + +def parse_odometer(body): + """Read totalMileage in km; never substitute mileage, range or zero. + + The reference OdometerSensor maps cockpit.totalMileage to kilometres. It + supplies no odometer measurement timestamp. Missing or invalid optional + data must not invalidate a successful battery measurement. + """ + try: + return _number(body["data"]["attributes"]["totalMileage"]) + except (KeyError, TypeError, NissanError): + return None + + +class NissanClient: + """Stateful client for one account/vehicle; callers must serialize its use. + + Keeps tokens, vehicle selection and backoff in memory between queries. + Create a new instance when credentials/VIN change. The injected transport + and clocks support synthetic tests; production requests use PrivateTransport. + """ + + def __init__(self, user_id, password, vin=None, transport=None, + monotonic_seconds=time.monotonic, unix_seconds=time.time, request_counter=None): + self._user = user_id + self._password = password + self._configured_vin = (vin or "").strip().upper() + self._transport = transport if transport is not None else PrivateTransport() + self._monotonic_seconds = monotonic_seconds + self._unix_seconds = unix_seconds + self._request_counter = request_counter + self._access_token = None + self._refresh_token = None + self._expires_at_monotonic_seconds = 0 + self._user_id = None + self._vin = None + self._retry_at_monotonic_seconds = 0 + self._provider_retry_at_monotonic_seconds = 0 + self._failed_queries = 0 + self._retry_error_type = NissanError + self._retry_reason = "" + self._deadline_monotonic_seconds = None + self.last_query = None + self._last_contact_unix_seconds = None + self._last_success_unix_seconds = None + self._last_measurement_unix_seconds = None + self._request_started_unix_seconds = None + self._request_count = 0 + self._http_status = None + self._phase = "idle" + self._odometer_retry_at_monotonic_seconds = 0 + self._odometer_outcome = "not_requested" + self._odometer_http_status = None + self._odometer_received_unix_seconds = None + + def _request(self, method, url, auth_request=False, **kwargs): + if _origin(url) not in {_origin(AUTH_BASE_URL), _origin(BFF_BASE_URL), + _origin(USERS_BASE_URL), _origin(CARS_BASE_URL)}: + raise NissanError("Unexpected Nissan request destination; request aborted.") + remaining_seconds = self._deadline_monotonic_seconds - self._monotonic_seconds() + if remaining_seconds <= 0: + raise NissanError("Nissan query time limit exceeded.") + self._last_contact_unix_seconds = self._unix_seconds() + if self._request_started_unix_seconds is None: + self._request_started_unix_seconds = self._last_contact_unix_seconds + self._request_count += 1 + if self._request_counter is not None: + self._request_counter.record(self._monotonic_seconds(), self._last_contact_unix_seconds) + self._http_status = None + response = self._transport.request(method, url, timeout_seconds=min( + SOCKET_TIMEOUT_SECONDS, remaining_seconds), **kwargs) + self._http_status = response.status + if response.status in (HTTPStatus.TOO_MANY_REQUESTS, HTTPStatus.SERVICE_UNAVAILABLE): + self._retry_at_monotonic_seconds = self._monotonic_seconds() + self._retry_after(response) + # Separate provider advice from the scheduled client's own error backoff. + provider_delay_seconds = self._retry_after( + response, minimum_seconds=0, + default_seconds=(DEFAULT_RETRY_AFTER_SECONDS + if response.status == HTTPStatus.TOO_MANY_REQUESTS else 0)) + self._provider_retry_at_monotonic_seconds = self._monotonic_seconds() + provider_delay_seconds + if response.status == HTTPStatus.TOO_MANY_REQUESTS: + raise RateLimitError("Nissan is rate limiting requests. Please try again later.") + if response.status in (HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN) or ( + auth_request and response.status == HTTPStatus.BAD_REQUEST): + raise AuthenticationError("Nissan rejected the login or access authorization.") + if response.status >= HTTPStatus.BAD_REQUEST: + raise NissanError("Nissan service returned an HTTP error ({}).".format(response.status)) + return response + + def _retry_after(self, response, minimum_seconds=MIN_RETRY_AFTER_SECONDS, + default_seconds=DEFAULT_RETRY_AFTER_SECONDS): + """Accept delay seconds or an HTTP date; bound server-directed pauses.""" + value = next((v for k, v in response.headers.items() if k.lower() == "retry-after"), None) + try: + delay_seconds = int(value) + except (ValueError, TypeError, OverflowError): + try: + retry_at_datetime = parsedate_to_datetime(value) + if retry_at_datetime.tzinfo is None: + raise ValueError + delay_seconds = retry_at_datetime.timestamp() - self._unix_seconds() + except (ValueError, TypeError, AttributeError, OverflowError): + delay_seconds = default_seconds + return max(minimum_seconds, min(delay_seconds, MAX_RETRY_AFTER_SECONDS)) + + def _json(self, method, url, **kwargs): + response = self._request(method, url, **kwargs) + if not HTTPStatus.OK <= response.status < HTTPStatus.MULTIPLE_CHOICES: + raise NissanError("Unexpected redirect from the Nissan API.") + return _object(response) + + def _redirects(self, response, url, callback=False): + for _ in range(MAX_LOGIN_REDIRECTS): + if response.status not in REDIRECT_STATUSES: + return response, url + location = next((v for k, v in response.headers.items() if k.lower() == "location"), None) + if not isinstance(location, str) or not location: + break + try: + target = urljoin(url, location) + parsed = urlsplit(target) + except ValueError: + raise NissanError(INVALID_LOGIN_REDIRECT_MESSAGE) from None + expected = urlsplit(OAUTH_CALLBACK_URI) + if callback and (parsed.scheme, parsed.netloc, parsed.path) == ( + expected.scheme, expected.netloc, expected.path) and not parsed.fragment: + return response, target + if _origin(target) != _origin(AUTH_BASE_URL): + raise NissanError(INVALID_LOGIN_REDIRECT_MESSAGE) + response = self._request("GET", target) + url = target + raise NissanError("Too many or invalid Nissan redirects.") + + def _login(self): + if (not isinstance(self._user, str) or not self._user.strip() + or not isinstance(self._password, str) or not self._password): + raise AuthenticationError("MyNISSAN username and password are required.") + # A new login must not reuse an expired/partially authenticated web session. + if hasattr(self._transport, "clear_cookies"): + self._transport.clear_cookies() + self._refresh_token = None + verifier = secrets.token_urlsafe(PKCE_VERIFIER_RANDOM_BYTES) + digest = hashlib.sha256(verifier.encode("ascii")).digest() + challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii") + state = secrets.token_urlsafe(OAUTH_STATE_RANDOM_BYTES) + url = AUTH_BASE_URL + "oauth2/authorize?" + urlencode({ + "response_type": "code", "redirect_uri": OAUTH_CALLBACK_URI, "client_id": OAUTH_CLIENT_ID, + "state": state, "scope": OAUTH_LOGIN_SCOPE, "code_challenge": challenge, "code_challenge_method": "S256", + "locale": "en_GB", "brand": "Nissan", "client": "mynissanapp"}) + response, url = self._redirects(self._request("GET", url), url) + form = _LoginForm.parse(response.body) + if not form or not form["action"]: + raise NissanError("Nissan login form is unavailable; you may need to sign in through the app.") + try: + target = urljoin(url, form["action"]) + except ValueError: + raise NissanError(INVALID_LOGIN_FORM_DESTINATION_MESSAGE) from None + if _origin(target) != _origin(AUTH_BASE_URL): + raise NissanError(INVALID_LOGIN_FORM_DESTINATION_MESSAGE) + data = dict(form["inputs"]) + region = data.get("regionCode", "") + data.update(userName=self._user, username=region + "/" + self._user if region else self._user, + password=self._password) + response = self._request("POST", target, form=data, headers={ + "Origin": AUTH_BASE_URL.rstrip("/"), "Referer": url}) + response, callback = self._redirects(response, target, callback=True) + parsed = urlsplit(callback) + expected = urlsplit(OAUTH_CALLBACK_URI) + if (parsed.scheme, parsed.netloc, parsed.path) != (expected.scheme, expected.netloc, expected.path): + raise AuthenticationError( + "Nissan login did not complete. Check your credentials and any notices in MyNISSAN.") + values = parse_qs(parsed.query) + if values.get("state") != [state] or len(values.get("code", [])) != 1 or values.get("error"): + raise AuthenticationError("Nissan returned an invalid login confirmation.") + token = self._json("POST", AUTH_BASE_URL + "oauth2/token", auth_request=True, form={ + "grant_type": "authorization_code", "redirect_uri": OAUTH_CALLBACK_URI, "client_id": OAUTH_CLIENT_ID, + "code": values["code"][0], "code_verifier": verifier, "scope": OAUTH_LOGIN_SCOPE}) + identity_token = token.get("id_token") + if not isinstance(identity_token, str) or not identity_token: + raise AuthenticationError("Nissan did not return an identity token.") + self._install(self._json("POST", BFF_BASE_URL + "v1/oauth2/access_token?platform=Android", auth_request=True, + headers={"Authorization": identity_token, "Content-Type": JSON_API_CONTENT_TYPE})) + + def _install(self, token): + access = token.get("access_token") + refresh = token.get("refresh_token") + if not isinstance(access, str) or not access or (refresh is not None and not isinstance(refresh, str)): + raise AuthenticationError("Nissan did not return a valid access token.") + try: + expires_in_seconds = float(token.get("expires_in", DEFAULT_TOKEN_LIFETIME_SECONDS)) + if not math.isfinite(expires_in_seconds) or expires_in_seconds <= 0: + raise ValueError + except (ValueError, TypeError, OverflowError): + raise AuthenticationError("Nissan returned an invalid token lifetime.") from None + self._access_token = access + self._refresh_token = refresh or self._refresh_token + self._expires_at_monotonic_seconds = ( + self._monotonic_seconds() + max(0, expires_in_seconds - TOKEN_REFRESH_MARGIN_SECONDS)) + + def _authenticate(self, force=False): + if self._access_token and not force and self._monotonic_seconds() < self._expires_at_monotonic_seconds: + return + if self._refresh_token: + try: + self._install(self._json("POST", BFF_BASE_URL + "v1/oauth2/refresh-token?platform=Android", + auth_request=True, + headers={"Authorization": self._refresh_token}, + json_body={"scope": OAUTH_REFRESH_SCOPE})) + return + except AuthenticationError: + self._access_token = self._refresh_token = None + self._login() + + def _get(self, url): + for attempt in range(MAX_AUTHENTICATED_REQUEST_ATTEMPTS): + try: + return self._json("GET", url, headers={ + "Authorization": "Bearer " + self._access_token, "Content-Type": JSON_API_CONTENT_TYPE}) + except AuthenticationError: + if attempt: + raise + self._authenticate(force=True) + + def _select_vehicle(self): + if self._configured_vin and not re.fullmatch(VIN_PATTERN, self._configured_vin): + raise NissanError("The configured VIN is invalid.") + if not self._user_id: + user = self._get(USERS_BASE_URL + "v1/users/current").get("userId") + if not isinstance(user, str) or not user: + raise NissanError("Nissan did not return a user identifier.") + self._user_id = user + vehicles = self._get(BFF_BASE_URL + "v5/users/" + quote(self._user_id, safe="") + "/cars").get("data") + if not isinstance(vehicles, list): + raise NissanError("Nissan did not return a vehicle list.") + candidates = [v.get("vin", "").upper() for v in vehicles + if isinstance(v, dict) and isinstance(v.get("vin"), str) + and re.fullmatch(VIN_PATTERN, v["vin"].upper())] + if self._configured_vin: + if self._configured_vin not in candidates: + raise NissanError("The configured VIN was not found in the Nissan account.") + self._vin = self._configured_vin + elif len(set(candidates)) == 1: + self._vin = candidates[0] + else: + raise NissanError("Could not select a unique vehicle. Please configure the VIN.") + + def _optional_odometer(self): + """Try one passive GET after battery success, within the same budget. + + Endpoint/auth/transport failures pause this optional endpoint for five + minutes without discarding SoC or retrying login. Explicit provider + pauses still govern subsequent queries, including battery queries. + Never reuse an old odometer as if it came from this response. + """ + if self._monotonic_seconds() < self._odometer_retry_at_monotonic_seconds: + self._odometer_outcome = "deferred" + return None + self._phase = "odometer" + self._http_status = None + try: + body = self._json("GET", CARS_BASE_URL + "v1/cars/" + self._vin + "/cockpit", headers={ + "Authorization": "Bearer " + self._access_token, "Content-Type": JSON_API_CONTENT_TYPE}) + odometer_km = parse_odometer(body) + self._odometer_outcome = "success" if odometer_km is not None else "unavailable" + if odometer_km is not None: + self._odometer_received_unix_seconds = self._unix_seconds() + return odometer_km + except NissanError as error: + self._odometer_outcome = "failed" + self._odometer_retry_at_monotonic_seconds = self._monotonic_seconds() + ODOMETER_FAILURE_BACKOFF_SECONDS + if (isinstance(error, RateLimitError) + or self._provider_retry_at_monotonic_seconds > self._monotonic_seconds()): + self._retry_error_type = type(error) + self._retry_reason = "Nissan requested a pause after the optional odometer query." + else: + # A cockpit-only outage must not suppress the next battery query. + self._retry_at_monotonic_seconds = 0 + return None + finally: + self._odometer_http_status = self._http_status + + def _finish_query(self, outcome, started_monotonic_seconds, result=None): + """Keep an allowlisted diagnostic summary, never raw transport data.""" + completed_unix_seconds = self._unix_seconds() + if result is not None: + self._last_success_unix_seconds = completed_unix_seconds + self._last_measurement_unix_seconds = result.measurement_unix_seconds + + def iso(unix_seconds): + return None if unix_seconds is None else datetime.fromtimestamp(unix_seconds, timezone.utc).isoformat() + + self.last_query = { + "outcome": outcome, + "phase": self._phase, + "request_started_at": iso(self._request_started_unix_seconds), + "completed_at": iso(completed_unix_seconds), + "duration_seconds": ( + round(max(0, self._monotonic_seconds() - started_monotonic_seconds), DURATION_DECIMAL_PLACES) + ), + "http_requests": self._request_count, + "last_http_status": self._http_status, + "last_contact_at": iso(self._last_contact_unix_seconds), + "last_success_at": iso(self._last_success_unix_seconds), + "last_measurement_at": iso(self._last_measurement_unix_seconds), + "measurement_at": iso(result.measurement_unix_seconds) if result else None, + "data_age_seconds": ( + round(completed_unix_seconds - result.measurement_unix_seconds, + DATA_AGE_DECIMAL_PLACES) if result else None + ), + "soc": result.soc_percent if result else None, + "range_km": result.range_km if result else None, + "odometer_km": result.odometer_km if result else None, + "odometer_outcome": self._odometer_outcome, + "odometer_http_status": self._odometer_http_status, + "odometer_retry_after_seconds": ( + max(0, math.ceil(self._odometer_retry_at_monotonic_seconds - self._monotonic_seconds())) + ), + "odometer_received_at": iso(self._odometer_received_unix_seconds), + "retry_after_seconds": max(0, math.ceil(self._retry_at_monotonic_seconds - self._monotonic_seconds())), + "provider_retry_after_seconds": ( + max(0, math.ceil(self._provider_retry_at_monotonic_seconds - self._monotonic_seconds())) + ), + "source": DIAGNOSTIC_SOURCE, + } + if self._request_counter is not None: + self.last_query.update(self._request_counter.snapshot(self._monotonic_seconds(), completed_unix_seconds)) + + def fetch_battery(self, include_odometer=False): + """Return cached battery data or raise NissanError; never issue wake commands. + + Updates last_query with allowlisted diagnostics on success/failure and + deferred calls. Failure pauses subsequent calls without sleeping. The + 60-second budget limits new request starts, not total socket read time. + Core, not this client, decides whether to accept a measurement timestamp. + include_odometer adds one best-effort cockpit GET after battery success; + callers serving openWB and private tests enable it explicitly. + """ + started_monotonic_seconds = self._monotonic_seconds() + if self._request_counter is not None: + self._request_counter.observe(started_monotonic_seconds, self._unix_seconds()) + self._request_started_unix_seconds = None + self._request_count = 0 + self._http_status = None + self._phase = "idle" + self._odometer_outcome = "not_requested" + self._odometer_http_status = None + self._odometer_received_unix_seconds = None + if started_monotonic_seconds < self._retry_at_monotonic_seconds: + remaining_seconds = math.ceil(self._retry_at_monotonic_seconds - started_monotonic_seconds) + self._finish_query("deferred", started_monotonic_seconds) + raise self._retry_error_type( + "Nissan requests are paused for {} more seconds. {}".format(remaining_seconds, self._retry_reason)) + self._deadline_monotonic_seconds = started_monotonic_seconds + QUERY_BUDGET_SECONDS + result = None + outcome = "failed" + try: + self._phase = "authentication" + self._authenticate() + if not self._vin: + self._phase = "vehicle_selection" + self._select_vehicle() + self._phase = "battery" + body = self._get(CARS_BASE_URL + "v1/cars/" + self._vin + "/battery-status") + result = parse_battery(body, now_unix_seconds=self._unix_seconds()) + self._failed_queries = 0 + self._retry_at_monotonic_seconds = 0 + self._provider_retry_at_monotonic_seconds = 0 + self._retry_error_type = NissanError + self._retry_reason = "" + if include_odometer: + result = replace(result, odometer_km=self._optional_odometer()) + outcome = "success" + return result + except NissanError as error: + if isinstance(error, AuthenticationError): + self._access_token = self._refresh_token = None + self._user_id = self._vin = None + # Do not sleep in an openWB worker or retain exception tracebacks. + # A successful query resets this backoff; deferred calls do not extend it. + self._failed_queries = min(self._failed_queries + 1, MAX_FAILURE_BACKOFF_STEPS) + delay_seconds = min( + INITIAL_FAILURE_BACKOFF_SECONDS * FAILURE_BACKOFF_MULTIPLIER ** (self._failed_queries - 1), + MAX_FAILURE_BACKOFF_SECONDS) + self._retry_at_monotonic_seconds = max( + self._retry_at_monotonic_seconds, self._monotonic_seconds() + delay_seconds) + self._retry_error_type = type(error) + self._retry_reason = str(error) + raise + finally: + self._deadline_monotonic_seconds = None + self._finish_query(outcome, started_monotonic_seconds, result) diff --git a/packages/modules/vehicles/nissanconnect/tests/__init__.py b/packages/modules/vehicles/nissanconnect/tests/__init__.py new file mode 100644 index 0000000000..3bae1c5b67 --- /dev/null +++ b/packages/modules/vehicles/nissanconnect/tests/__init__.py @@ -0,0 +1 @@ +"""Synthetic offline tests for the Nissan module.""" diff --git a/packages/modules/vehicles/nissanconnect/tests/api_test.py b/packages/modules/vehicles/nissanconnect/tests/api_test.py new file mode 100644 index 0000000000..f3cb2f513c --- /dev/null +++ b/packages/modules/vehicles/nissanconnect/tests/api_test.py @@ -0,0 +1,387 @@ +"""Synthetic fixtures only. No captured account responses or real identifiers.""" +import base64 +import hashlib +import io +import logging +import traceback +from urllib.error import HTTPError, URLError +from urllib.parse import parse_qs, urlsplit + +import pytest + +from modules.vehicles.nissanconnect.src.api import ( + AUTH_BASE_URL, CARS_BASE_URL, AuthenticationError, MAX_RESPONSE_BYTES, + NissanError, PrivateTransport, RateLimitError, parse_battery) +from modules.vehicles.nissanconnect.tests.helpers import ( + FORM, MEASURED_UNIX_SECONDS, SECOND_VIN, VIN, battery, full_script, login_script, make_client, reply) + + +@pytest.mark.parametrize("soc", [0, 47.5, 100]) +def test_valid_soc_preserves_old_measurement(soc): + result = parse_battery(battery(soc, rangeHvacOn=0, rangeHvacOff=150), + now_unix_seconds=MEASURED_UNIX_SECONDS + 86400) + assert (result.soc_percent, result.measurement_unix_seconds, result.range_km) == (soc, MEASURED_UNIX_SECONDS, 0) + + +@pytest.mark.parametrize("soc", [None, True, False, -1, 101, "47", float("nan"), float("inf"), [], {}]) +def test_invalid_soc_never_becomes_zero(soc): + with pytest.raises(NissanError): + parse_battery(battery(soc), now_unix_seconds=MEASURED_UNIX_SECONDS) + + +@pytest.mark.parametrize("timestamp", [None, 0, "", "bad", "2022-01-01T00:00:00", "2099-01-01T00:00:00Z"]) +def test_missing_ambiguous_or_future_time_rejected(timestamp): + with pytest.raises(NissanError): + parse_battery(battery(timestamp=timestamp), now_unix_seconds=MEASURED_UNIX_SECONDS) + + +def test_offset_time_and_optional_range(): + result = parse_battery(battery(timestamp="2022-01-01T01:00:00+01:00", rangeHvacOff=150), + now_unix_seconds=MEASURED_UNIX_SECONDS) + assert result.measurement_unix_seconds == MEASURED_UNIX_SECONDS + assert result.range_km == 150 + assert parse_battery(battery(rangeHvacOn=-1), now_unix_seconds=MEASURED_UNIX_SECONDS).range_km is None + + +@pytest.mark.parametrize("body", [{}, {"data": None}, {"data": {"attributes": {}}}, [], None]) +def test_invalid_shape_has_safe_error(body): + with pytest.raises(NissanError): + parse_battery(body) + + +def test_login_pkce_selection_and_cached_battery_only(caplog): + caplog.set_level(logging.DEBUG) + client, transport, _ = make_client(full_script()) + status = client.fetch_battery() + assert (status.soc_percent == 47 and status.measurement_unix_seconds == MEASURED_UNIX_SECONDS + and status.range_km == 123.4) + calls = transport.calls + authorize = parse_qs(urlsplit(calls[0][1]).query) + verifier = calls[2][2]["form"]["code_verifier"] + expected = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode() + assert authorize["code_challenge"] == [expected] + assert authorize["code_challenge_method"] == ["S256"] + assert calls[1][2]["form"]["username"] == "EU/synthetic@example.invalid" + assert calls[3][2]["headers"]["Authorization"] == "synthetic-id" + assert calls[-1][2]["headers"]["Authorization"] == "Bearer synthetic-access" + assert calls[-1][:2] == ("GET", CARS_BASE_URL + "v1/cars/" + VIN + "/battery-status") + assert all("/actions/" not in url for _, url, _ in calls) + assert all(0 < kwargs["timeout_seconds"] <= 10 for _, _, kwargs in calls) + assert not caplog.records + assert not transport.responses + + +def test_subsequent_update_reuses_tokens_and_vehicle(): + client, transport, _ = make_client(full_script() + [reply(battery(48))]) + client.fetch_battery() + count = len(transport.calls) + assert client.fetch_battery().soc_percent == 48 + assert len(transport.calls) == count + 1 + + +def test_expired_token_is_refreshed_in_memory(): + script = full_script() + [reply({"access_token": "synthetic-renewed"}), reply(battery())] + client, transport, ticks_monotonic_seconds = make_client(script) + client.fetch_battery() + ticks_monotonic_seconds[0] = 3600 + client.fetch_battery() + assert "refresh-token" in transport.calls[-2][1] + assert transport.calls[-2][2]["headers"]["Authorization"] == "synthetic-refresh" + assert transport.calls[-1][2]["headers"]["Authorization"] == "Bearer synthetic-renewed" + + +def test_rejected_refresh_logs_in_once(): + script = full_script() + [reply({"error": "invalid_grant"}, 400)] + login_script() + [reply(battery())] + client, transport, ticks_monotonic_seconds = make_client(script) + client.fetch_battery() + ticks_monotonic_seconds[0] = 3600 + client.fetch_battery() + assert sum("oauth2/authorize?" in url for _, url, _ in transport.calls) == 2 + assert transport.cookies_cleared == 2 + + +def test_401_retries_once_then_stops_and_cools_down(): + script = full_script() + [reply("synthetic-private-body", 401), reply({"access_token": "synthetic-new"}), + reply("synthetic-private-body", 401)] + client, transport, _ = make_client(script) + client.fetch_battery() + with pytest.raises(AuthenticationError, match="Nissan"): + client.fetch_battery() + count = len(transport.calls) + with pytest.raises(AuthenticationError, match="paused"): + client.fetch_battery() + assert len(transport.calls) == count + + +def test_state_mismatch_never_exchanges_tokens(): + client, transport, _ = make_client([reply(FORM), reply("", 302, { + "Location": "com://wso2.service.nci?code=synthetic-code&state=wrong"})]) + with pytest.raises(AuthenticationError): + client.fetch_battery() + assert len(transport.calls) == 2 + + +@pytest.mark.parametrize("target", [ + "https://example.invalid/steal", "http://login.mynissan-account.com/", + "https://login.mynissan-account.com:444/", "https://user@login.mynissan-account.com/", +]) +def test_form_target_validated_before_sending_credentials(target): + client, transport, _ = make_client([reply(FORM.replace("/commonauth", target))]) + with pytest.raises(NissanError): + client.fetch_battery() + assert len(transport.calls) == 1 + + +def test_cross_origin_redirect_is_never_followed(): + client, transport, _ = make_client([reply("", 302, {"Location": "https://example.invalid/steal"})]) + with pytest.raises(NissanError): + client.fetch_battery() + assert len(transport.calls) == 1 + + +def test_redirect_loop_is_bounded(): + client, transport, _ = make_client([reply("", 302, {"Location": "/loop"})] * 11) + with pytest.raises(NissanError): + client.fetch_battery() + assert len(transport.calls) == 11 + + +@pytest.mark.parametrize("vehicles,vin", [ + ([], None), ([{"vin": VIN}, {"vin": SECOND_VIN}], None), ([{"vin": VIN}], SECOND_VIN), +]) +def test_missing_or_ambiguous_vehicle_never_guessed(vehicles, vin): + client, transport, _ = make_client(full_script(vehicles), vin) + with pytest.raises(NissanError): + client.fetch_battery() + assert not any("battery-status" in url for _, url, _ in transport.calls) + + +def test_configured_vehicle_is_selected(): + client, transport, _ = make_client(full_script([{"vin": VIN}, {"vin": SECOND_VIN}]), SECOND_VIN.lower()) + client.fetch_battery() + assert SECOND_VIN in transport.calls[-1][1] + + +def test_rate_limit_pauses_later_updates_without_sleeping(): + client, transport, ticks_monotonic_seconds = make_client( + full_script() + [reply("private", 429, {"Retry-After": "600"})]) + client.fetch_battery() + with pytest.raises(RateLimitError): + client.fetch_battery() + ticks_monotonic_seconds[0] = 599 + count = len(transport.calls) + with pytest.raises(RateLimitError): + client.fetch_battery() + assert len(transport.calls) == count + ticks_monotonic_seconds[0] = 600 + transport.responses.append(reply(battery())) + assert client.fetch_battery().soc_percent == 47 + + +@pytest.mark.parametrize("response", [ + reply("private", 500), reply("private", 503), reply("not-json"), + reply({"errors": [{"detail": "private"}]}), reply({"id_token": None}), +]) +def test_bad_response_not_logged_or_in_error(response, caplog): + caplog.set_level(logging.DEBUG) + script = login_script() + script[2] = response + client, _, _ = make_client(script) + with pytest.raises(NissanError) as error: + client.fetch_battery() + assert "private" not in str(error.value) + assert "synthetic-password" not in str(error.value) + assert not caplog.records + + +def test_transport_hides_exception_details_and_response_repr(monkeypatch, caplog): + caplog.set_level(logging.DEBUG) + transport = PrivateTransport() + private_value = "synthetic-secret-value" + + def fail(*args, **kwargs): + raise URLError(private_value) + + monkeypatch.setattr(transport._opener, "open", fail) + with pytest.raises(NissanError) as error: + transport.request("GET", AUTH_BASE_URL) + formatted = "".join(traceback.format_exception(type(error.value), error.value, error.value.__traceback__)) + assert private_value not in formatted + assert private_value not in repr(reply(private_value, headers={"Authorization": private_value})) + assert not caplog.records + + +def test_transport_returns_redirect_without_following(monkeypatch): + transport = PrivateTransport() + + def respond(*args, **kwargs): + raise HTTPError(AUTH_BASE_URL, 302, "redirect", {"Location": "/next"}, io.BytesIO(b"")) + + monkeypatch.setattr(transport._opener, "open", respond) + result = transport.request("GET", AUTH_BASE_URL) + assert result.status == 302 and result.headers["Location"] == "/next" + + +def test_transport_limits_response_size(monkeypatch): + transport = PrivateTransport() + + def respond(*args, **kwargs): + raise HTTPError(AUTH_BASE_URL, 500, "error", {}, io.BytesIO(b"x" * (MAX_RESPONSE_BYTES + 1))) + + monkeypatch.setattr(transport._opener, "open", respond) + with pytest.raises(NissanError, match="too large"): + transport.request("GET", AUTH_BASE_URL) + + +def test_total_request_budget_stops_redirect_chain(): + client, transport, ticks_monotonic_seconds = make_client([]) + + def slow_response(calls): + ticks_monotonic_seconds[0] += 61 + return reply("", 302, {"Location": "/next"}) + + transport.responses.append(slow_response) + with pytest.raises(NissanError, match="time limit"): + client.fetch_battery() + assert len(transport.calls) == 1 + + +@pytest.mark.parametrize("response", [ + reply("synthetic-private-body", 500), reply("synthetic-private-body", 503), + reply("not-json"), reply(battery(soc=None)), +]) +def test_failed_query_pauses_without_reauthentication_and_recovers(response, caplog): + client, transport, ticks_monotonic_seconds = make_client(full_script() + [response, reply(battery(48))]) + client.fetch_battery() + with pytest.raises(NissanError): + client.fetch_battery() + count = len(transport.calls) + ticks_monotonic_seconds[0] = 299 + with pytest.raises(NissanError, match="paused") as error: + client.fetch_battery() + assert len(transport.calls) == count + assert "synthetic-private-body" not in str(error.value) + ticks_monotonic_seconds[0] = 300 + assert client.fetch_battery().soc_percent == 48 + assert len(transport.calls) == count + 1 + assert not caplog.records + + +def test_repeated_outages_back_off_to_one_hour_and_success_resets_delay(): + client, transport, ticks_monotonic_seconds = make_client(full_script()) + client.fetch_battery() + for delay_seconds in (300, 600, 1200, 2400, 3600, 3600): + transport.responses.append(reply("synthetic-private-body", 503)) + with pytest.raises(NissanError): + client.fetch_battery() + count = len(transport.calls) + ticks_monotonic_seconds[0] += delay_seconds - 1 + with pytest.raises(NissanError, match="paused"): + client.fetch_battery() + assert len(transport.calls) == count + ticks_monotonic_seconds[0] += 1 + # Keep the synthetic token alive; expiry/renewal is tested separately. + client._expires_at_monotonic_seconds = ticks_monotonic_seconds[0] + 7200 + transport.responses.extend([reply(battery(49)), reply("private", 503)]) + assert client.fetch_battery().soc_percent == 49 + with pytest.raises(NissanError): + client.fetch_battery() + ticks_monotonic_seconds[0] += 300 + transport.responses.append(reply(battery(50))) + assert client.fetch_battery().soc_percent == 50 + + +@pytest.mark.parametrize("status", [429, 503]) +@pytest.mark.parametrize("header,expected_delay", [ + ("1200", 1200), ("Sun, 02 Jan 2022 00:20:00 GMT", 1200), + ("Sun, 02 Jan 2022 00:01:00 GMT", 300), ("-1", 300), ("bad-date", 300), +]) +def test_retry_after_seconds_and_http_date_are_respected(status, header, expected_delay): + client, transport, ticks_monotonic_seconds = make_client(full_script() + [ + reply("private", status, {"rEtRy-AfTeR": header}), reply(battery())]) + client.fetch_battery() + with pytest.raises(NissanError): + client.fetch_battery() + count = len(transport.calls) + ticks_monotonic_seconds[0] = expected_delay - 1 + with pytest.raises(NissanError, match="paused"): + client.fetch_battery() + assert len(transport.calls) == count + ticks_monotonic_seconds[0] = expected_delay + assert client.fetch_battery().soc_percent == 47 + + +def test_timeout_pauses_requests_without_retaining_exception_traceback(): + def timeout(calls): + raise NissanError("Nissan is unreachable or returned an invalid response.") + client, transport, ticks_monotonic_seconds = make_client(full_script() + [timeout, reply(battery())]) + client.fetch_battery() + with pytest.raises(NissanError): + client.fetch_battery() + count = len(transport.calls) + for _ in range(5): + with pytest.raises(NissanError, match="paused"): + client.fetch_battery() + assert len(transport.calls) == count + assert not any(isinstance(value, BaseException) for value in vars(client).values()) + ticks_monotonic_seconds[0] = 300 + assert client.fetch_battery().soc_percent == 47 + + +def test_unrepresentable_optional_range_preserves_valid_soc(): + result = parse_battery(battery(rangeHvacOn=10 ** 400), now_unix_seconds=MEASURED_UNIX_SECONDS) + assert (result.soc_percent, result.measurement_unix_seconds, result.range_km) == (47, MEASURED_UNIX_SECONDS, None) + + +def test_unrepresentable_token_lifetime_is_safe_and_pauses_retry(): + script = login_script() + script[-1] = reply({"access_token": "synthetic-private-access", "expires_in": 10 ** 400}) + client, transport, _ = make_client(script) + with pytest.raises(AuthenticationError, match="token lifetime") as error: + client.fetch_battery() + assert "synthetic-private-access" not in str(error.value) + assert client._access_token is None and client._refresh_token is None + count = len(transport.calls) + with pytest.raises(AuthenticationError, match="paused"): + client.fetch_battery() + assert len(transport.calls) == count + + +@pytest.mark.parametrize("during_form", [False, True]) +def test_malformed_login_destination_is_safe_and_pauses_retry(during_form): + private_url = "https://[synthetic-private-host" + response = reply(FORM.replace("/commonauth", private_url)) if during_form else reply( + "", 302, {"Location": private_url}) + client, transport, _ = make_client([response]) + with pytest.raises(NissanError) as error: + client.fetch_battery() + assert "synthetic-private-host" not in str(error.value) + with pytest.raises(NissanError, match="paused"): + client.fetch_battery() + assert len(transport.calls) == 1 + + +def test_rotated_refresh_token_is_used_on_next_expiry(): + client, transport, ticks_monotonic_seconds = make_client(full_script() + [ + reply({"access_token": "synthetic-next-access", "refresh_token": "synthetic-next-refresh"}), + reply(battery()), reply({"access_token": "synthetic-final-access"}), reply(battery())]) + client.fetch_battery() + ticks_monotonic_seconds[0] = 3600 + client.fetch_battery() + ticks_monotonic_seconds[0] = 7200 + client.fetch_battery() + assert transport.calls[-2][2]["headers"]["Authorization"] == "synthetic-next-refresh" + assert transport.calls[-1][2]["headers"]["Authorization"] == "Bearer synthetic-final-access" + + +def test_refresh_service_failure_does_not_fall_back_to_password_login(): + client, transport, ticks_monotonic_seconds = make_client(full_script() + [reply("synthetic-private-body", 503)]) + client.fetch_battery() + ticks_monotonic_seconds[0] = 3600 + with pytest.raises(NissanError): + client.fetch_battery() + assert len(transport.calls) == 8 + assert "refresh-token" in transport.calls[-1][1] + with pytest.raises(NissanError, match="paused"): + client.fetch_battery() + assert len(transport.calls) == 8 diff --git a/packages/modules/vehicles/nissanconnect/tests/conftest.py b/packages/modules/vehicles/nissanconnect/tests/conftest.py new file mode 100644 index 0000000000..9fabfcf74a --- /dev/null +++ b/packages/modules/vehicles/nissanconnect/tests/conftest.py @@ -0,0 +1,13 @@ +"""Tests in this module must never contact a vehicle, cloud API or MQTT broker.""" +import socket + +import pytest + + +@pytest.fixture(autouse=True) +def no_network(monkeypatch): + def denied(*args, **kwargs): + raise AssertionError("Network access is forbidden in NissanConnect offline tests") + monkeypatch.setattr(socket.socket, "connect", denied) + monkeypatch.setattr(socket, "create_connection", denied) + monkeypatch.setattr(socket, "getaddrinfo", denied) diff --git a/packages/modules/vehicles/nissanconnect/tests/diagnostics_test.py b/packages/modules/vehicles/nissanconnect/tests/diagnostics_test.py new file mode 100644 index 0000000000..a96ee74ec8 --- /dev/null +++ b/packages/modules/vehicles/nissanconnect/tests/diagnostics_test.py @@ -0,0 +1,261 @@ +"""Synthetic query diagnostics and isolated settings-worker contracts.""" +from datetime import datetime +import json +import time +from unittest.mock import Mock + +import pytest + +from modules.vehicles.nissanconnect.src.api import AuthenticationError, NissanError, RequestCounter +from modules.vehicles.nissanconnect.tests.helpers import MEASURED_UNIX_SECONDS, VIN, full_script, make_client, reply +from modules.vehicles.nissanconnect.connection_test import run_test + + +def test_diagnostics_distinguish_contact_measurement_failure_and_deferred_update(): + client, transport, ticks_monotonic_seconds = make_client(full_script() + [reply('synthetic-private-body', 503)]) + client._unix_seconds = lambda: MEASURED_UNIX_SECONDS + 86400 + ticks_monotonic_seconds[0] + client.fetch_battery() + success = client.last_query.copy() + assert success['outcome'] == 'success' + assert success['http_requests'] == 7 + assert success['measurement_at'] == '2022-01-01T00:00:00+00:00' + assert success['request_started_at'] == '2022-01-02T00:00:00+00:00' + assert success['data_age_seconds'] == 86400 + assert (success['soc'], success['range_km']) == (47, 123.4) + ticks_monotonic_seconds[0] = 10 + with pytest.raises(NissanError): + client.fetch_battery() + failure = client.last_query.copy() + assert failure['outcome'] == 'failed' + assert failure['last_http_status'] == 503 + assert failure['soc'] is None and failure['measurement_at'] is None + assert failure['last_success_at'] == success['completed_at'] + assert failure['last_measurement_at'] == success['measurement_at'] + ticks_monotonic_seconds[0] = 20 + requests = len(transport.calls) + with pytest.raises(NissanError): + client.fetch_battery() + deferred = client.last_query + assert deferred['outcome'] == 'deferred' + assert deferred['http_requests'] == 0 and deferred['request_started_at'] is None + assert deferred['last_contact_at'] == failure['last_contact_at'] + assert deferred['last_success_at'] == success['completed_at'] + assert len(transport.calls) == requests + for value in ('synthetic-private-body', 'synthetic-password', VIN, 'synthetic-access', 'example.invalid'): + assert value not in json.dumps([success, failure, deferred]) + + +def test_worker_returns_only_query_result_and_does_not_log(caplog): + client, transport, _ = make_client(full_script() + [reply({"data": {"attributes": {"totalMileage": 12345.6}}})]) + result = run_test({'user_id': 'synthetic@example.invalid', 'password': 'synthetic-password'}, + client_factory=lambda *args: client) + assert result['success'] is True + assert result['query']['soc'] == 47 + assert result['query']['data_age_seconds'] == 86400 + assert len(transport.calls) == 8 + assert result["query"]["odometer_km"] == 12345.6 + assert not caplog.records + for value in ('synthetic-password', 'example.invalid', VIN, 'synthetic-access'): + assert value not in json.dumps(result) + + +@pytest.mark.parametrize('payload', [ + None, [], {}, {'user_id': 'a', 'password': 'p', 'url': 'https://invalid/'}, + {'user_id': 'a', 'password': 'p', 'vin': []}, + {'user_id': 'a', 'password': 'p', 'vin': 'wrong'}, + {'user_id': 'a', 'password': 'p' * 4097}]) +def test_worker_rejects_invalid_input_without_creating_client(payload): + factory = Mock() + assert run_test(payload, factory) == {'success': False, 'code': 'invalid_input'} + factory.assert_not_called() + + +@pytest.mark.parametrize('error,code', [ + (AuthenticationError('synthetic-private'), 'authentication_failed'), + (NissanError('synthetic-private'), 'query_failed'), + (RuntimeError('synthetic-private'), 'internal_error')]) +def test_worker_never_echoes_error_details(error, code): + client = Mock(last_query=None) + client.fetch_battery.side_effect = error + result = run_test({'user_id': 'a', 'password': 'p'}, lambda *args: client) + assert result['success'] is False and result['code'] == code + assert 'synthetic-private' not in json.dumps(result) + + +@pytest.mark.parametrize('status,headers,expected', [ + (401, {}, 0), (503, {}, 0), (503, {'Retry-After': 'bad'}, 0), + (503, {'Retry-After': '60'}, 60), (429, {}, 300), (429, {'Retry-After': '900'}, 900)]) +def test_provider_advice_is_separate_from_scheduled_error_backoff(status, headers, expected): + client, _, _ = make_client([reply({}, status, headers)]) + with pytest.raises(NissanError): + client.fetch_battery() + assert client.last_query['provider_retry_after_seconds'] == expected + assert client.last_query['retry_after_seconds'] == max(300, expected) + + +def test_login_success_followed_by_battery_failure_is_not_a_successful_test(): + client, _, _ = make_client(full_script()[:-1] + [reply({}, 503)]) + result = run_test({'user_id': 'synthetic@example.invalid', 'password': 'synthetic-password'}, + client_factory=lambda *args: client) + assert result['success'] is False + assert result['query']['phase'] == 'battery' + assert result['query']['provider_retry_after_seconds'] == 0 + + +def test_request_totals_separate_rolling_window_from_local_day(): + start_unix_seconds = datetime(2022, 1, 1, 23, 59, 50).timestamp() + counter = RequestCounter() + counter.record(0, start_unix_seconds) + counter.record(20, start_unix_seconds + 20) + after_midnight = counter.snapshot(20, start_unix_seconds + 20) + assert after_midnight["http_requests_last_24h"] == 2 + assert after_midnight["http_requests_since_start"] == 2 + assert after_midnight["http_requests_since_midnight"] == 1 + assert after_midnight["http_requests_last_24h_complete"] is False + assert after_midnight["http_requests_since_midnight_complete"] is True + + # Advance across another midnight without sending a request. The second + # event remains inside the rolling window but belongs to the previous day. + next_day = counter.snapshot(86410, start_unix_seconds + 86410) + assert next_day["http_requests_last_24h"] == 1 + assert next_day["http_requests_since_start"] == 2 + assert next_day["http_requests_since_midnight"] == 0 + assert next_day["http_requests_last_24h_complete"] is True + expired = counter.snapshot(86420, start_unix_seconds + 86420) + assert expired["http_requests_last_24h"] == 0 + assert len(counter._events) == 0 + assert expired["http_requests_since_start"] == 2 + assert expired["http_requests_counting_since"] == after_midnight["http_requests_counting_since"] + + # Expiring all timestamp events must not reset the cumulative count. + counter.record(86421, start_unix_seconds + 86421) + resumed = counter.snapshot(86421, start_unix_seconds + 86421) + assert resumed["http_requests_last_24h"] == 1 + assert resumed["http_requests_since_start"] == 3 + assert resumed["http_requests_counting_since"] == after_midnight["http_requests_counting_since"] + + +def test_request_totals_report_partial_coverage_and_reset_on_recreation(): + now_unix_seconds = datetime(2022, 1, 2, 12).timestamp() + counter = RequestCounter() + counter.record(0, now_unix_seconds) + current = counter.snapshot(1, now_unix_seconds + 1) + assert current["http_requests_last_24h"] == 1 + assert current["http_requests_since_start"] == 1 + assert current["http_requests_since_midnight"] == 1 + assert current["http_requests_last_24h_complete"] is False + assert current["http_requests_since_midnight_complete"] is False + assert datetime.fromisoformat(current["http_requests_counting_since"]).timestamp() == now_unix_seconds + recreated = RequestCounter().snapshot(2, now_unix_seconds + 2) + assert recreated["http_requests_last_24h"] == 0 + assert recreated["http_requests_since_start"] == 0 + assert recreated["http_requests_since_midnight"] == 0 + assert recreated["http_requests_counter_reset_reason"] == "module_started" + assert recreated["http_requests_counting_since"] != current["http_requests_counting_since"] + + +@pytest.mark.parametrize("jump_seconds", [-3600, 3600]) +def test_request_totals_restart_coverage_after_clock_correction(jump_seconds): + start_unix_seconds = datetime(2022, 1, 2, 12).timestamp() + counter = RequestCounter() + counter.record(0, start_unix_seconds) + current = counter.snapshot(10, start_unix_seconds + 10 + jump_seconds) + assert current["http_requests_last_24h"] == 0 + assert current["http_requests_since_start"] == 0 + assert datetime.fromisoformat(current["http_requests_counting_since"]).timestamp() == ( + start_unix_seconds + 10 + jump_seconds) + assert current["http_requests_since_midnight"] == 0 + assert current["http_requests_last_24h_complete"] is False + assert current["http_requests_since_midnight_complete"] is False + assert current["http_requests_counter_reset_reason"] == "clock_changed" + + +def test_request_totals_bound_memory_without_claiming_complete_history(monkeypatch): + from modules.vehicles.nissanconnect.src import api + + monkeypatch.setattr(api, "MAX_REQUEST_COUNTER_EVENTS", 2) + start_unix_seconds = datetime(2022, 1, 2, 12).timestamp() + counter = RequestCounter() + for offset_seconds in range(3): + counter.record(offset_seconds, start_unix_seconds + offset_seconds) + current = counter.snapshot(2, start_unix_seconds + 2) + assert current["http_requests_last_24h"] == 1 + assert current["http_requests_since_start"] == 1 + assert current["http_requests_counter_reset_reason"] == "capacity_limit" + assert datetime.fromisoformat(current["http_requests_counting_since"]).timestamp() == start_unix_seconds + 2 + assert current["http_requests_last_24h_complete"] is False + assert current["http_requests_since_midnight_complete"] is False + + +@pytest.mark.skipif(not hasattr(time, "tzset"), reason="System timezone switching requires POSIX") +@pytest.mark.parametrize("midnight_iso,event_iso,now_iso,rolling_count,day_count,midnight_offset", [ + ("2026-10-24T22:00:00+00:00", "2026-10-24T22:15:00+00:00", "2026-10-25T22:45:00+00:00", 0, 1, "+02:00"), + ("2026-03-28T23:00:00+00:00", "2026-03-28T23:15:00+00:00", "2026-03-29T22:00:00+00:00", 1, 0, "+02:00"), +]) +def test_request_totals_respect_short_and_long_local_days( + monkeypatch, midnight_iso, event_iso, now_iso, rolling_count, day_count, midnight_offset): + try: + with monkeypatch.context() as context: + context.setenv("TZ", "Europe/Berlin") + time.tzset() + start_unix_seconds = datetime.fromisoformat(midnight_iso).timestamp() - 3600 + event_unix_seconds = datetime.fromisoformat(event_iso).timestamp() + now_unix_seconds = datetime.fromisoformat(now_iso).timestamp() + counter = RequestCounter() + counter.observe(0, start_unix_seconds) + counter.record(event_unix_seconds - start_unix_seconds, event_unix_seconds) + current = counter.snapshot(now_unix_seconds - start_unix_seconds, now_unix_seconds) + assert current["http_requests_last_24h"] == rolling_count + assert current["http_requests_since_start"] == 1 + assert current["http_requests_since_midnight"] == day_count + assert current["http_requests_since_midnight_complete"] is True + assert current["http_requests_local_midnight"].endswith("T00:00:00" + midnight_offset) + finally: + time.tzset() + + +def test_request_totals_count_login_odometer_failures_and_zero_request_backoff(): + client, transport, ticks = make_client(full_script() + [ + reply({"data": {"attributes": {"totalMileage": 12345.6}}}), reply({}, 503)]) + client._unix_seconds = lambda: MEASURED_UNIX_SECONDS + 86400 + ticks[0] + client._request_counter = RequestCounter() + client.fetch_battery(include_odometer=True) + assert client.last_query["http_requests"] == 8 + assert client.last_query["http_requests_last_24h"] == 8 + assert client.last_query["http_requests_since_start"] == 8 + ticks[0] = 10 + with pytest.raises(NissanError): + client.fetch_battery(include_odometer=True) + assert client.last_query["http_requests"] == 1 + assert client.last_query["http_requests_last_24h"] == 9 + assert client.last_query["http_requests_since_start"] == 9 + ticks[0] = 20 + with pytest.raises(NissanError): + client.fetch_battery(include_odometer=True) + assert client.last_query["http_requests"] == 0 + assert client.last_query["http_requests_last_24h"] == 9 + assert client.last_query["http_requests_since_start"] == 9 + assert len(transport.calls) == 9 + + +def test_request_totals_include_transport_failure_without_a_response(): + def fail_transport(calls): + raise NissanError("Synthetic transport failure") + + client, _, _ = make_client([fail_transport]) + client._request_counter = RequestCounter() + with pytest.raises(NissanError): + client.fetch_battery() + assert client.last_query["last_http_status"] is None + assert client.last_query["http_requests_last_24h"] == 1 + assert client.last_query["http_requests_since_start"] == 1 + + +def test_separate_connection_test_has_no_native_vehicle_history(): + client, _, _ = make_client(full_script() + [reply({"data": {"attributes": {"totalMileage": 0}}})]) + result = run_test({'user_id': 'synthetic@example.invalid', 'password': 'synthetic-password'}, + client_factory=lambda *args: client) + assert result["success"] is True + assert result["query"]["http_requests"] == 8 + assert "http_requests_last_24h" not in result["query"] + assert "http_requests_since_start" not in result["query"] diff --git a/packages/modules/vehicles/nissanconnect/tests/helpers.py b/packages/modules/vehicles/nissanconnect/tests/helpers.py new file mode 100644 index 0000000000..8f11ab5ff5 --- /dev/null +++ b/packages/modules/vehicles/nissanconnect/tests/helpers.py @@ -0,0 +1,60 @@ +"""Shared synthetic responses, transport and clocks; no account captures.""" +import json +from urllib.parse import parse_qs, urlsplit + +from modules.vehicles.nissanconnect.src.api import HttpResponse, NissanClient + + +# Deliberately invented test values, unrelated to any account or vehicle. +VIN = "SJNFAAZE1U0000001" +SECOND_VIN = "SJNFAAZE1U0000002" +MEASURED_UNIX_SECONDS = 1640995200.0 +FORM = ('
' + '
') + + +def reply(body, status=200, headers=None): + return HttpResponse(status, headers or {}, body if isinstance(body, str) else json.dumps(body)) + + +def battery(soc=47, timestamp="2022-01-01T00:00:00Z", **extra): + return {"data": {"attributes": dict(batteryLevel=soc, lastUpdateTime=timestamp, **extra)}} + + +class ScriptedTransport: + def __init__(self, responses): + self.responses = list(responses) + self.calls = [] + self.cookies_cleared = 0 + + def clear_cookies(self): + self.cookies_cleared += 1 + + def request(self, method, url, **kwargs): + self.calls.append((method, url, kwargs)) + assert self.responses, "Unexpected extra HTTP request" + response = self.responses.pop(0) + return response(self.calls) if callable(response) else response + + +def login_script(): + def authorize(calls): + state = parse_qs(urlsplit(calls[-2][1]).query)["state"][0] + return reply("", 302, {"Location": "com://wso2.service.nci?code=synthetic-code&state=" + state}) + return [reply(FORM), authorize, reply({"id_token": "synthetic-id"}), + reply({"access_token": "synthetic-access", "refresh_token": "synthetic-refresh", "expires_in": 3600})] + + +def make_client(responses, vin=None): + transport = ScriptedTransport(responses) + ticks_monotonic_seconds = [0.0] + client = NissanClient("synthetic@example.invalid", "synthetic-password", vin, transport, + monotonic_seconds=lambda: ticks_monotonic_seconds[0], + unix_seconds=lambda: MEASURED_UNIX_SECONDS + 86400) + return client, transport, ticks_monotonic_seconds + + +def full_script(vehicles=None): + return login_script() + [reply({"userId": "synthetic-user"}), + reply({"data": [{"vin": VIN}] if vehicles is None else vehicles}), + reply(battery(rangeHvacOn=123.4))] diff --git a/packages/modules/vehicles/nissanconnect/tests/odometer_test.py b/packages/modules/vehicles/nissanconnect/tests/odometer_test.py new file mode 100644 index 0000000000..5b470129a4 --- /dev/null +++ b/packages/modules/vehicles/nissanconnect/tests/odometer_test.py @@ -0,0 +1,146 @@ +"""Optional odometer contracts with invented values and a blocked network.""" +import json + +import pytest + +from modules.vehicles.nissanconnect.src.api import CARS_BASE_URL, NissanError, RateLimitError, parse_odometer +from modules.vehicles.nissanconnect.tests.helpers import VIN, battery, full_script, make_client, reply +from modules.vehicles.nissanconnect.connection_test import run_test + + +def cockpit(value=12345.6, **extra): + return {"data": {"attributes": dict(totalMileage=value, **extra)}} + + +@pytest.mark.parametrize("value", [0, 12345, 12345.6]) +def test_only_total_mileage_is_used_in_kilometres(value): + assert parse_odometer(cockpit(value, mileage=99, rangeHvacOn=321)) == value + + +@pytest.mark.parametrize("value", [None, True, False, -1, "12345", [], {}, float("nan"), float("inf"), 10 ** 400]) +def test_invalid_odometer_is_absent_instead_of_zero(value): + assert parse_odometer(cockpit(value)) is None + + +@pytest.mark.parametrize("body", [None, [], {}, {"data": None}, {"data": {"attributes": []}}, + {"data": {"attributes": {"mileage": 12345}}}]) +def test_missing_odometer_and_malformed_shapes_are_optional(body): + assert parse_odometer(body) is None + + +def test_passive_query_preserves_battery_time_and_does_not_reuse_old_odometer(): + client, transport, ticks_monotonic_seconds = make_client( + full_script() + [reply(cockpit()), reply(battery()), reply(cockpit(None))]) + first = client.fetch_battery(include_odometer=True) + assert first.odometer_km == 12345.6 + assert first.measurement_unix_seconds == 1640995200 + assert transport.calls[-1][:2] == ("GET", CARS_BASE_URL + "v1/cars/" + VIN + "/cockpit") + assert transport.calls[-1][2]["headers"]["Authorization"] == "Bearer synthetic-access" + assert client.last_query["odometer_outcome"] == "success" + assert client.last_query["odometer_http_status"] == 200 + assert client.last_query["odometer_received_at"] == "2022-01-02T00:00:00+00:00" + assert client.last_query["measurement_at"] == "2022-01-01T00:00:00+00:00" + ticks_monotonic_seconds[0] = 10 + second = client.fetch_battery(include_odometer=True) + assert second.soc_percent == 47 and second.odometer_km is None + assert client.last_query["odometer_outcome"] == "unavailable" + assert client.last_query["odometer_received_at"] is None + assert all("/actions/" not in url for _, url, _ in transport.calls) + assert not transport.responses + + +@pytest.mark.parametrize("status", [401, 403, 404, 500, 503]) +def test_optional_http_error_keeps_soc_and_does_not_repeat_login_or_block_battery(status): + script = full_script() + [reply("synthetic-private-body", status), reply(battery(48)), + reply(battery(49)), reply(cockpit(12346))] + client, transport, ticks_monotonic_seconds = make_client(script) + result = client.fetch_battery(include_odometer=True) + assert result.soc_percent == 47 and result.odometer_km is None + assert len(transport.calls) == 8 + assert client.last_query["outcome"] == "success" + assert client.last_query["odometer_outcome"] == "failed" + assert client.last_query["odometer_http_status"] == status + assert client.last_query["retry_after_seconds"] == 0 + assert "synthetic-private-body" not in json.dumps(client.last_query) + ticks_monotonic_seconds[0] = 10 + assert client.fetch_battery(include_odometer=True).soc_percent == 48 + assert len(transport.calls) == 9 + assert client.last_query["odometer_outcome"] == "deferred" + assert client.last_query["odometer_http_status"] is None + assert client.last_query["odometer_retry_after_seconds"] == 290 + ticks_monotonic_seconds[0] = 300 + assert client.fetch_battery(include_odometer=True).odometer_km == 12346 + assert len(transport.calls) == 11 + assert not transport.responses + + +@pytest.mark.parametrize("status,headers,pause,error_type", [ + (429, {}, 300, RateLimitError), (429, {"Retry-After": "900"}, 900, RateLimitError), + (503, {"Retry-After": "600"}, 600, NissanError)]) +def test_provider_pause_from_optional_query_survives_battery_success(status, headers, pause, error_type): + script = full_script() + [reply({}, status, headers), reply(battery()), reply(cockpit())] + client, transport, ticks_monotonic_seconds = make_client(script) + assert client.fetch_battery(include_odometer=True).soc_percent == 47 + assert client.last_query["provider_retry_after_seconds"] == pause + count = len(transport.calls) + with pytest.raises(error_type, match="paused"): + client.fetch_battery(include_odometer=True) + assert len(transport.calls) == count + assert client.last_query["odometer_km"] is None + assert client.last_query["odometer_outcome"] == "not_requested" + ticks_monotonic_seconds[0] = pause + assert client.fetch_battery(include_odometer=True).odometer_km == 12345.6 + assert client.last_query["provider_retry_after_seconds"] == 0 + + +@pytest.mark.parametrize("body", ["invalid synthetic JSON", {"errors": ["synthetic-private-body"]}]) +def test_bad_optional_response_keeps_successful_battery(body): + client, _, _ = make_client(full_script() + [reply(body)]) + assert client.fetch_battery(include_odometer=True).soc_percent == 47 + assert client.last_query["odometer_outcome"] == "failed" + assert "synthetic-private-body" not in json.dumps(client.last_query) + + +def test_optional_transport_error_keeps_successful_battery(): + def timeout(calls): + raise NissanError("Synthetic transport timeout.") + client, _, _ = make_client(full_script() + [timeout]) + assert client.fetch_battery(include_odometer=True).soc_percent == 47 + assert client.last_query["odometer_http_status"] is None + assert client.last_query["outcome"] == "success" + + +def test_no_cockpit_request_after_battery_failure_or_exhausted_budget(): + client, transport, _ = make_client(full_script()[:-1] + [reply(battery(None))]) + with pytest.raises(NissanError): + client.fetch_battery(include_odometer=True) + assert not any(url.endswith("/cockpit") for _, url, _ in transport.calls) + client, transport, ticks_monotonic_seconds = make_client(full_script()) + + def slow_battery(calls): + ticks_monotonic_seconds[0] = 61 + return reply(battery()) + transport.responses[-1] = slow_battery + assert client.fetch_battery(include_odometer=True).soc_percent == 47 + assert len(transport.calls) == 7 + assert client.last_query["odometer_outcome"] == "failed" + + +def test_unsaved_test_accepts_partial_success_and_preserves_provider_pause(): + client, _, _ = make_client(full_script() + [reply({}, 429, {"Retry-After": "900"})]) + result = run_test({"user_id": "synthetic@example.invalid", "password": "synthetic-password"}, + client_factory=lambda *args: client) + assert result["success"] is True and result["query"]["soc"] == 47 + assert result["query"]["odometer_km"] is None + assert result["query"]["provider_retry_after_seconds"] == 900 + for secret in (VIN, "synthetic-password", "synthetic-access", "example.invalid"): + assert secret not in json.dumps(result) + + +def test_zero_retry_after_does_not_remove_rate_limit_backoff(): + client, transport, _ = make_client(full_script() + [reply({}, 429, {"Retry-After": "0"})]) + assert client.fetch_battery(include_odometer=True).soc_percent == 47 + assert client.last_query["retry_after_seconds"] == 300 + with pytest.raises(RateLimitError, match="paused"): + client.fetch_battery(include_odometer=True) + assert len(transport.calls) == 8 diff --git a/packages/modules/vehicles/nissanconnect/tests/probe_test.py b/packages/modules/vehicles/nissanconnect/tests/probe_test.py new file mode 100644 index 0000000000..8a256338b7 --- /dev/null +++ b/packages/modules/vehicles/nissanconnect/tests/probe_test.py @@ -0,0 +1,99 @@ +import os +from pathlib import Path +import subprocess +from unittest.mock import Mock + +import pytest + +from modules.vehicles.nissanconnect import connection_test +from modules.vehicles.nissanconnect.tools import probe +from modules.vehicles.nissanconnect.src.api import BatteryStatus + + +@pytest.fixture +def local_terminal(monkeypatch): + for name in ("CODEX_THREAD_ID", "CODEX_SANDBOX", "CODEX_CI"): + monkeypatch.delenv(name, raising=False) + monkeypatch.setattr(probe.sys.stdin, "isatty", lambda: True) + monkeypatch.setattr(probe.sys.stdout, "isatty", lambda: True) + # Do not change the test process's global logging state. + monkeypatch.setattr(probe.logging, "disable", Mock()) + + +def test_probe_refuses_agent_context(monkeypatch, local_terminal): + monkeypatch.setenv("CODEX_THREAD_ID", "synthetic-agent-session") + credentials = Mock(side_effect=AssertionError("Credentials must not be requested")) + monkeypatch.setattr(probe, "getpass", credentials) + assert probe.main() == 2 + credentials.assert_not_called() + + +def test_probe_refuses_redirected_output(monkeypatch, local_terminal): + monkeypatch.setattr(probe.sys.stdout, "isatty", lambda: False) + assert probe.main() == 2 + + +def test_probe_aborts_before_requesting_credentials(monkeypatch, local_terminal): + monkeypatch.setattr("builtins.input", lambda prompt: "n") + credentials = Mock() + monkeypatch.setattr(probe, "getpass", credentials) + assert probe.main() == 0 + credentials.assert_not_called() + + +def test_probe_uses_hidden_inputs_without_echo(monkeypatch, local_terminal, capsys): + monkeypatch.setattr(probe.sys.stdout, "isatty", lambda: True) + monkeypatch.setattr("builtins.input", lambda prompt: "y") + monkeypatch.setattr(probe, "getpass", Mock(side_effect=["synthetic@example.invalid", "synthetic-password", ""])) + client = Mock() + client.fetch_battery.return_value = BatteryStatus(47, 1640995200, 123, 12345.6) + factory = Mock(return_value=client) + monkeypatch.setattr(probe, "NissanClient", factory) + assert probe.main() == 0 + output = capsys.readouterr().out + assert "synthetic@example.invalid" not in output + assert "synthetic-password" not in output + assert "SoC: 47 %" in output + assert "Odometer: 12345.6 km (measurement time unavailable)" in output + client.fetch_battery.assert_called_once_with(include_odometer=True) + + +def test_probe_aborts_if_hidden_input_is_unavailable(monkeypatch, local_terminal): + monkeypatch.setattr("builtins.input", lambda prompt: "y") + monkeypatch.setattr(probe, "getpass", Mock(side_effect=probe.GetPassWarning("synthetic"))) + factory = Mock() + monkeypatch.setattr(probe, "NissanClient", factory) + assert probe.main() == 2 + factory.assert_not_called() + + +@pytest.mark.parametrize("entrypoint,script_path,expected_exit_code,expected_output", [ + (probe, "tools/probe.py", 2, + "Aborted: Please run in a private terminal outside a Codex or agent session."), + (connection_test, "connection_test.py", 0, + '{"success": false, "code": "agent_environment"}'), +]) +def test_direct_entrypoint_with_isolated_python_loads_internal_api( + tmp_path, entrypoint, script_path, expected_exit_code, expected_output): + # Exercise both supported direct-start locations with a synthetic API only. + # A missing or incorrect import must fail before the agent guard can return. + (tmp_path / "src").mkdir() + (tmp_path / "src/__init__.py").write_text("", encoding="utf-8") + script = tmp_path / script_path + script.parent.mkdir(exist_ok=True) + script.write_text(Path(entrypoint.__file__).read_text(encoding="utf-8"), encoding="utf-8") + (tmp_path / "src/api.py").write_text( + 'class NissanError(Exception): pass\n' + 'class AuthenticationError(NissanError): pass\n' + 'class RateLimitError(NissanError): pass\n' + 'VIN_PATTERN = "synthetic"\n' + 'class NissanClient:\n' + ' def __init__(self, *args, **kwargs):\n' + ' raise AssertionError("No real client in the startup test")\n', + encoding="utf-8") + result = subprocess.run( + [probe.sys.executable, "-I", "-B", str(script)], cwd=str(tmp_path), + env=dict(os.environ, CODEX_CI="1"), capture_output=True, text=True, timeout=10) + assert result.returncode == expected_exit_code, result.stderr + assert result.stdout.strip() == expected_output + assert result.stderr == "" diff --git a/packages/modules/vehicles/nissanconnect/tests/soc_test.py b/packages/modules/vehicles/nissanconnect/tests/soc_test.py new file mode 100644 index 0000000000..ecc56f3261 --- /dev/null +++ b/packages/modules/vehicles/nissanconnect/tests/soc_test.py @@ -0,0 +1,116 @@ +import importlib +import sys +from types import ModuleType +from unittest.mock import Mock + +import pytest + +from modules.vehicles.nissanconnect.src.api import BatteryStatus +from modules.vehicles.nissanconnect.config import NissanConnect, NissanConnectConfiguration + + +@pytest.fixture +def soc_module(monkeypatch): + # The standalone suite must not bootstrap openWB services on the test PC. + # In the normal core suite this dependency is already imported by conftest. + dependency = "modules.common.configurable_vehicle" + if dependency not in sys.modules: + stub = ModuleType(dependency) + stub.ConfigurableVehicle = Mock() + monkeypatch.setitem(sys.modules, dependency, stub) + return importlib.import_module("modules.vehicles.nissanconnect.soc") + + +def test_openwb_adapter_preserves_timestamp_reuses_client_and_handles_config_change(monkeypatch, soc_module): + soc = soc_module + + # Only replace the scheduling/storage wrapper; use the real CarState and updater. + wrapper = Mock() + monkeypatch.setattr(soc, "ConfigurableVehicle", wrapper) + fake_client = Mock() + fake_client.fetch_battery.return_value = BatteryStatus(0, 1640995200, 0, 12345.6) + factory = Mock(return_value=fake_client) + monkeypatch.setattr(soc, "NissanClient", factory) + config = NissanConnect(configuration=NissanConnectConfiguration("synthetic@example.invalid", "synthetic-password")) + soc.create_vehicle(config, 7) + updater = wrapper.call_args.kwargs["component_updater"] + state = updater(None) + assert (state.soc, state.range, state.soc_timestamp, state.odometer) == (0, 0, 1640995200, 12345.6) + fake_client.fetch_battery.assert_called_once_with(include_odometer=True) + updater(None) + assert factory.call_count == 1 + config.configuration.password = "synthetic-new-password" + updater(None) + assert factory.call_count == 2 + assert factory.call_args_list[0].kwargs["request_counter"] is factory.call_args_list[1].kwargs["request_counter"] + soc.create_vehicle(config, 8) + wrapper.call_args.kwargs["component_updater"](None) + assert factory.call_args.kwargs["request_counter"] is not factory.call_args_list[0].kwargs["request_counter"] + assert wrapper.call_args.kwargs["calc_while_charging"] is False + assert wrapper.call_args_list[0].kwargs["vehicle"] == 7 + + +def test_configuration_descriptor_and_module_name_agree(soc_module): + config = soc_module.device_descriptor.configuration_factory() + assert config.type == "nissanconnect" + assert config.configuration.vin is None + + +def test_configuration_roundtrip_uses_openwb_serializer(): + from dataclass_utils import asdict, dataclass_from_dict + + config = NissanConnect(configuration=NissanConnectConfiguration( + "synthetic@example.invalid", "synthetic-password", "SJNFAAZE1U0000001")) + encoded = asdict(config) + decoded = dataclass_from_dict(NissanConnect, encoded) + assert decoded.type == "nissanconnect" + assert isinstance(decoded.configuration, NissanConnectConfiguration) + assert decoded.configuration.user_id == config.configuration.user_id + assert decoded.configuration.password == config.configuration.password + assert decoded.configuration.vin == config.configuration.vin + + +def test_module_logs_only_allowlisted_diagnostics(monkeypatch, soc_module, caplog): + import json + import logging + from modules.vehicles.nissanconnect.tests.helpers import VIN, full_script, make_client, reply + from modules.vehicles.nissanconnect.src.api import NissanError + + client, transport, _ = make_client(full_script() + [reply({"data": {"attributes": {"totalMileage": 12345.6}}})]) + wrapper = Mock() + monkeypatch.setattr(soc_module, "ConfigurableVehicle", wrapper) + + def create_client(*args, **kwargs): + client._request_counter = kwargs["request_counter"] + return client + monkeypatch.setattr(soc_module, "NissanClient", create_client) + config = NissanConnect(configuration=NissanConnectConfiguration("synthetic@example.invalid", "synthetic-password")) + soc_module.create_vehicle(config, 7) + updater = wrapper.call_args.kwargs["component_updater"] + caplog.set_level(logging.DEBUG, logger=soc_module.__name__) + updater(None) + assert len(caplog.records) == 1 + assert '"outcome": "success"' in caplog.text and '"data_age_seconds": 86400' in caplog.text + assert '"soc": 47.0' in caplog.text and 'Nissan vehicle 7' in caplog.text + summary = json.loads(caplog.records[0].args[1]) + assert summary["http_requests_last_24h"] == 8 + assert summary["http_requests_since_start"] == 8 + assert summary["http_requests_since_midnight"] == 8 + assert summary["http_requests_last_24h_complete"] is False + for value in (VIN, "example.invalid", "synthetic-password", "synthetic-access", "synthetic-refresh"): + assert value not in caplog.text + assert not any(record.exc_info for record in caplog.records) + + # A deferred call is logged as such, without a new contact or measured value. + client._retry_at_monotonic_seconds = 300 + client._retry_reason = "Synthetic wait." + calls = len(transport.calls) + with pytest.raises(NissanError): + updater(None) + assert len(transport.calls) == calls + assert '"outcome": "deferred"' in caplog.records[-1].getMessage() + deferred = json.loads(caplog.records[-1].args[1]) + assert deferred["request_started_at"] is None + assert deferred["http_requests"] == 0 + assert deferred["http_requests_last_24h"] == 8 + assert deferred["http_requests_since_start"] == 8 diff --git a/packages/modules/vehicles/nissanconnect/tools/__init__.py b/packages/modules/vehicles/nissanconnect/tools/__init__.py new file mode 100644 index 0000000000..612d5d72e6 --- /dev/null +++ b/packages/modules/vehicles/nissanconnect/tools/__init__.py @@ -0,0 +1 @@ +"""Optional private-terminal diagnostic tools.""" diff --git a/packages/modules/vehicles/nissanconnect/tools/probe.py b/packages/modules/vehicles/nissanconnect/tools/probe.py new file mode 100644 index 0000000000..406fad6295 --- /dev/null +++ b/packages/modules/vehicles/nissanconnect/tools/probe.py @@ -0,0 +1,78 @@ +"""Run manually in a private local terminal, never through a cloud agent tool. + +No credentials as command-line arguments, no files, no wallbox access. +The output is private and must not be copied into an issue or cloud chat. +""" +from datetime import datetime, timezone +from getpass import getpass, GetPassWarning +import logging +import os +import sys +import warnings + +if __package__: + from ..src.api import NissanClient, NissanError +else: + # Isolated/embedded Python needs the module root to find the internal src package. + sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + from src.api import NissanClient, NissanError + + +AGENT_ENVIRONMENT_VARIABLES = ("CODEX_THREAD_ID", "CODEX_SANDBOX", "CODEX_CI") +EXIT_SUCCESS = 0 +EXIT_QUERY_FAILED = 1 +EXIT_UNSAFE_TERMINAL = 2 +EXIT_INTERRUPTED = 130 + + +def main(): + if any(os.environ.get(name) for name in AGENT_ENVIRONMENT_VARIABLES): + print("Aborted: Please run in a private terminal outside a Codex or agent session.") + return EXIT_UNSAFE_TERMINAL + if not sys.stdin.isatty() or not sys.stdout.isatty(): + print("Aborted: An interactive local terminal is required; no redirection or pipes.") + return EXIT_UNSAFE_TERMINAL + print("Local MyNISSAN test: This request sends credentials directly to Nissan.") + print("No wallbox connection. No files are written. The result is confidential.") + print("Only use outside cloud agents, terminal recording and screen sharing.") + print("The environment check is a precaution, not a complete technical safeguard.") + if input("Query cached battery and odometer data now? [y/N] ").strip().lower() != "y": + return EXIT_SUCCESS + try: + with warnings.catch_warnings(): + # getpass must abort instead of falling back to an echoed input. + warnings.simplefilter("error", GetPassWarning) + user = getpass("MyNISSAN email (hidden): ") + password = getpass("MyNISSAN password (hidden): ") + vin = getpass("VIN (hidden; leave blank for a single vehicle): ") + # This separate process owns logging; never expose dependency debug output. + logging.disable(logging.CRITICAL) + status = NissanClient(user, password, vin).fetch_battery(include_odometer=True) + measured = datetime.fromtimestamp(status.measurement_unix_seconds, tz=timezone.utc).astimezone() + print("SoC: {:g} %".format(status.soc_percent)) + print("Measurement time: " + measured.isoformat()) + if status.range_km is not None: + print("Range: {:g} km".format(status.range_km)) + if status.odometer_km is not None: + print("Odometer: {:g} km (measurement time unavailable)".format(status.odometer_km)) + else: + print("Odometer: Not available; battery query succeeded.") + print("Compare with MyNISSAN. Do not copy values, screenshots or credentials into a cloud chat.") + return EXIT_SUCCESS + except GetPassWarning: + print("Hidden input is unavailable in this terminal. Aborted.") + return EXIT_UNSAFE_TERMINAL + except NissanError as error: + print("Query failed: " + str(error)) + return EXIT_QUERY_FAILED + except (KeyboardInterrupt, EOFError): + print("Aborted.") + return EXIT_INTERRUPTED + except Exception: + # Never print an unexpected exception/traceback with potentially private data. + print("Unexpected local error; no confidential details were displayed.") + return EXIT_QUERY_FAILED + + +if __name__ == "__main__": + raise SystemExit(main()) From ec57d8305a48a974d50f9b1ecf913fb3ab1283e5 Mon Sep 17 00:00:00 2001 From: wippofax Date: Sun, 11 Oct 2026 20:13:10 +0200 Subject: [PATCH 2/3] Move MyNISSAN API and tests into vehicle module root --- packages/modules/vehicles/nissanconnect/{src => }/api.py | 0 packages/modules/vehicles/nissanconnect/{tests => }/api_test.py | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename packages/modules/vehicles/nissanconnect/{src => }/api.py (100%) rename packages/modules/vehicles/nissanconnect/{tests => }/api_test.py (100%) diff --git a/packages/modules/vehicles/nissanconnect/src/api.py b/packages/modules/vehicles/nissanconnect/api.py similarity index 100% rename from packages/modules/vehicles/nissanconnect/src/api.py rename to packages/modules/vehicles/nissanconnect/api.py diff --git a/packages/modules/vehicles/nissanconnect/tests/api_test.py b/packages/modules/vehicles/nissanconnect/api_test.py similarity index 100% rename from packages/modules/vehicles/nissanconnect/tests/api_test.py rename to packages/modules/vehicles/nissanconnect/api_test.py From bf9c00624134dcbb67216ebd2858b1770469c4d3 Mon Sep 17 00:00:00 2001 From: wippofax Date: Sun, 11 Oct 2026 20:13:11 +0200 Subject: [PATCH 3/3] Simplify MyNISSAN EU module to native vehicle conventions Use shared HTTP sessions and dataclasses in five flat module files. Keep SoC, measurement time, range and odometer; Core owns polling. Remove custom backoff, counters, connection tests, CLI and extra docs. Retain inline attribution and scoped filtering of sensitive HTTP logs. Validate with 48 focused API tests and a successful Leaf ZE1 SoC trial. --- .../modules/vehicles/nissanconnect/NOTICE.md | 46 -- .../modules/vehicles/nissanconnect/README.md | 119 --- .../modules/vehicles/nissanconnect/api.py | 735 ++++-------------- .../vehicles/nissanconnect/api_test.py | 559 +++++-------- .../modules/vehicles/nissanconnect/config.py | 27 +- .../vehicles/nissanconnect/connection_test.py | 73 -- .../nissanconnect/docs/diagnostics.md | 197 ----- .../vehicles/nissanconnect/docs/privacy.md | 98 --- .../vehicles/nissanconnect/docs/source-map.md | 68 -- .../nissanconnect/docs/user-information.md | 34 - .../modules/vehicles/nissanconnect/soc.py | 27 +- .../vehicles/nissanconnect/src/__init__.py | 1 - .../vehicles/nissanconnect/tests/__init__.py | 1 - .../vehicles/nissanconnect/tests/conftest.py | 13 - .../nissanconnect/tests/diagnostics_test.py | 261 ------- .../vehicles/nissanconnect/tests/helpers.py | 60 -- .../nissanconnect/tests/odometer_test.py | 146 ---- .../nissanconnect/tests/probe_test.py | 99 --- .../vehicles/nissanconnect/tests/soc_test.py | 116 --- .../vehicles/nissanconnect/tools/__init__.py | 1 - .../vehicles/nissanconnect/tools/probe.py | 78 -- 21 files changed, 395 insertions(+), 2364 deletions(-) delete mode 100644 packages/modules/vehicles/nissanconnect/NOTICE.md delete mode 100644 packages/modules/vehicles/nissanconnect/README.md delete mode 100644 packages/modules/vehicles/nissanconnect/connection_test.py delete mode 100644 packages/modules/vehicles/nissanconnect/docs/diagnostics.md delete mode 100644 packages/modules/vehicles/nissanconnect/docs/privacy.md delete mode 100644 packages/modules/vehicles/nissanconnect/docs/source-map.md delete mode 100644 packages/modules/vehicles/nissanconnect/docs/user-information.md delete mode 100644 packages/modules/vehicles/nissanconnect/src/__init__.py delete mode 100644 packages/modules/vehicles/nissanconnect/tests/__init__.py delete mode 100644 packages/modules/vehicles/nissanconnect/tests/conftest.py delete mode 100644 packages/modules/vehicles/nissanconnect/tests/diagnostics_test.py delete mode 100644 packages/modules/vehicles/nissanconnect/tests/helpers.py delete mode 100644 packages/modules/vehicles/nissanconnect/tests/odometer_test.py delete mode 100644 packages/modules/vehicles/nissanconnect/tests/probe_test.py delete mode 100644 packages/modules/vehicles/nissanconnect/tests/soc_test.py delete mode 100644 packages/modules/vehicles/nissanconnect/tools/__init__.py delete mode 100644 packages/modules/vehicles/nissanconnect/tools/probe.py diff --git a/packages/modules/vehicles/nissanconnect/NOTICE.md b/packages/modules/vehicles/nissanconnect/NOTICE.md deleted file mode 100644 index 7582a94ec5..0000000000 --- a/packages/modules/vehicles/nissanconnect/NOTICE.md +++ /dev/null @@ -1,46 +0,0 @@ -# Third-party attribution - -This openWB integration follows the repository's GNU GPL version 3 license. -The adapted MIT source retains the notice below; this file does not relicense -the complete module under MIT or grant access to Nissan services. - -The MyNISSAN EU protocol and login flow in `src/api.py` are adapted from -[HomeAssistant-NissanConnect](https://github.com/dan-r/HomeAssistant-NissanConnect/tree/b0bafe9108d4574e243aa182324584dbc68369ad), -revision b0bafe9108d4574e243aa182324584dbc68369ad. -Its client credits @mitchellrj and @Tobiaswk and explicitly states that portions -were relicensed from Apache License, Version 2.0 with permission. We preserve -that upstream provenance statement and rely on the reference's published MIT -grant. The earlier kamereon-python client credits Richard Mitchell (2020); -dartnissanconnect credits Tobias Westergaard Kjeldsen (2021). - -Adapted for openWB by wippofax, 2026-09-11 through 2026-10-10: standard-library -transport, bounded authentication and battery validation, passive queries, -retry/error handling, native module integration and synthetic tests. The -reference project is not responsible for these modifications. - -The retained MIT notice below accompanies the adapted source. See the -[user information](docs/user-information.md) for the unresolved service-access -question; software licensing does not settle that question. Nissan and MyNISSAN are referenced -to identify compatibility; this is an independent, unofficial integration. - -MIT License - -Copyright (c) 2024 Daniel Raper - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/packages/modules/vehicles/nissanconnect/README.md b/packages/modules/vehicles/nissanconnect/README.md deleted file mode 100644 index d3c9c07438..0000000000 --- a/packages/modules/vehicles/nissanconnect/README.md +++ /dev/null @@ -1,119 +0,0 @@ -# Nissan – MyNISSAN EU (experimental) - -This module reads data stored by Nissan's European MyNISSAN service and provides -openWB with battery state of charge, the original battery measurement time, -and range and odometer values when available. Existing openWB scheduling, -display and charging control consume these values through the standard vehicle -interface. - -**Tested in practice with the Leaf ZE1; other models are unverified.** -Read [Before using MyNISSAN EU](docs/user-information.md) for service-access and -privacy information. This is an unofficial integration; specific permission for -this app-API access has not been established. - -## Compatibility - -- A European MyNISSAN account must work in the official app and have an associated - vehicle. NissanConnect EV and the existing `leaf` module use a different API. -- The client has no Leaf model-code filter. Another vehicle must provide the - same required battery fields through these EU endpoints; presence in the - MyNISSAN app alone does not establish compatibility. Other regions are unverified. -- Login changes, additional consent or MFA can interrupt access. Complete any - required account steps in the official app; the module does not automate them. -- Practical reports cover a 2019 Leaf ZE1 and openWB 2.2.3 at Core revision - `3ced7f9cef3c70980ee1fab670f0d0717d1809ca`. They do not separately establish - odometer accuracy, sustained token renewal or every charging-limit scenario. -- The Python module requires Python 3.9+. The settings connection test additionally - requires the matching settings build, Linux, PHP 7.4+ with `proc_open`, and - Python at `/usr/bin/python3`. No new Python package is required by this module. - -## Configuration - -Choose **Nissan – MyNISSAN EU (experimental)** in the vehicle's SoC settings. -The internal module type is `nissanconnect`; saved custom names are preserved. - -| Field | Meaning | -| --- | --- | -| `user_id` | MyNISSAN account email address | -| `password` | MyNISSAN account password | -| `vin` | Optional for a single vehicle; required to select among multiple vehicles | - -A single associated vehicle is selected automatically. Ambiguous selection or an -unknown VIN produces an error. Configure query intervals using the normal openWB -vehicle settings. **Test connection** queries the current form values without -saving settings or updating charging control; save separately to enable them. - -## Data freshness and errors - -Queries read cached server data. They do not wake the vehicle, request a fresh -vehicle measurement or send charging commands. A successful query can return an -old measurement; the module preserves its timestamp rather than inventing one. -The pinned Status page displays that measurement time, not the latest HTTP request. - -Invalid battery data produces an error. After a successful battery query, the -module automatically attempts to retrieve the odometer, subject to retry pauses -and the request budget. There is no separate user setting to enable it. When -Nissan supplies a valid value, it is passed to openWB in kilometres for the -existing Status page, charge-log and CSV consumers. Missing or invalid odometer -data, or a failed odometer query, does not invalidate a valid SoC result. Missing -or invalid range data likewise leaves a valid battery result usable. - -The odometer comes from `totalMileage`. Its measurement time is unavailable and -must not be inferred from the battery timestamp or the time of retrieval. - -Core decides whether to accept a measurement, calculate SoC from metered energy -or expose an error. The module does not add a charging interlock or override -Core's zero-SoC error policy. See [diagnostics and error behavior](docs/diagnostics.md) -for scheduling, timestamp rejection, retry delays and the known refresh indicator. -SoC DEBUG logs also report this vehicle module's request totals for the last -24 hours, since local midnight and since observation started, with the start -timestamp and coverage flags. -The counters reset when the module is restarted or recreated; separate settings -connection tests are excluded from these totals. - -## Development and offline tests - -The [source map](docs/source-map.md) describes `src`, `tests`, `tools` and `docs` -and the stable integration entry points. Tests use synthetic responses and -block real network access. From the Core repository root, with Python 3.9+, -pytest and Flake8 installed: - -```sh -PYTHONPATH=packages python -m pytest -q packages/modules/vehicles/nissanconnect/tests \ - --confcutdir=packages/modules/vehicles/nissanconnect/tests -python -m flake8 packages/modules/vehicles/nissanconnect -``` - -The companion `openWB/openwb-ui-settings` repository owns the Vue form, both PHP -sources under `public/modules/vehicles/nissanconnect/` and their UI/PHP tests. -Its regular build copies the PHP sources unchanged to their deployed URLs. -See that repository's `docs/nissanconnect-source-map.md`, -`tests/nissanconnect/README.md` and pinned `docs/nissanconnect-2.2.3.md` guide. -Module tests do not replace complete Core CI or live vehicle validation. - -## Optional private-terminal query - -On a development computer, run the tool yourself in a private terminal: - -```sh -python -I -B packages/modules/vehicles/nissanconnect/tools/probe.py -``` - -It asks for confirmation, reads account details using hidden input and displays -cached battery/odometer results. It refuses redirected output and recognizable -agent sessions; these checks cannot detect all recording or monitoring. Keep -credentials and results out of public issues, Git and cloud-assistant sessions. -The tool does not connect to a wallbox or save openWB settings. - -## Contribution and attribution - -This integration was initiated and contributed by [wippofax](https://github.com/wippofax). - -Core owns the Python module; settings owns the form and PHP endpoints. Review -changes together and state the tested versions and limitations. Built settings -assets do not belong in the Core source contribution. - -See [NOTICE.md](NOTICE.md) for source attribution and the retained MIT notice, -and [privacy and diagnostic data](docs/privacy.md) for data flows and the -inherited Core logging limitation. An open-source license does not grant -permission to access Nissan's service. diff --git a/packages/modules/vehicles/nissanconnect/api.py b/packages/modules/vehicles/nissanconnect/api.py index d4a74ca4ff..04ea89e78e 100644 --- a/packages/modules/vehicles/nissanconnect/api.py +++ b/packages/modules/vehicles/nissanconnect/api.py @@ -1,215 +1,81 @@ -"""MyNISSAN EU/OneID client. Cached battery/odometer data; no vehicle commands. +"""Passive MyNISSAN EU client; tested with Leaf ZE1. No vehicle commands. -Protocol adapted from HomeAssistant-NissanConnect; see NOTICE.md for attribution. -Adapted for openWB by wippofax, 2026-09-11 through 2026-10-10, under GPLv3. -Credentials, cookies and tokens stay in memory. Never log transport payloads. +Adapted by wippofax from dan-r/HomeAssistant-NissanConnect, revision +b0bafe9108d4574e243aa182324584dbc68369ad (MIT), kamereon/kamereon.py. +Upstream credits @mitchellrj and @Tobiaswk; Apache-2.0 portions relicensed with permission. +https://github.com/dan-r/HomeAssistant-NissanConnect """ +# MIT License -- Copyright (c) 2024 Daniel Raper +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. import base64 -from collections import deque -from dataclasses import dataclass, field, replace -from datetime import datetime, timezone -from email.utils import parsedate_to_datetime +from contextvars import ContextVar +from datetime import datetime import hashlib from html.parser import HTMLParser from http import HTTPStatus -from http.client import HTTPException -from http.cookiejar import CookieJar -import json +import logging import math import re import secrets -import ssl import time -from typing import Optional -from urllib.error import HTTPError, URLError +from typing import Optional, Tuple from urllib.parse import parse_qs, quote, urlencode, urljoin, urlsplit -from urllib.request import build_opener, HTTPCookieProcessor, HTTPRedirectHandler, HTTPSHandler, Request +from requests import RequestException +from modules.common.req import get_http_session +log = logging.getLogger(__name__) AUTH_BASE_URL = "https://login.mynissan-account.com/" BFF_BASE_URL = "https://nci-bff-web-prod.apps.eu2.kamereon.io/bff-web/" USERS_BASE_URL = "https://alliance-platform-usersadapter-prod.apps.eu2.kamereon.io/user-adapter/" CARS_BASE_URL = "https://alliance-platform-caradapter-prod.apps.eu2.kamereon.io/car-adapter/" OAUTH_CALLBACK_URI = "com://wso2.service.nci" -# Public application identifier from the reference implementation; not a secret. -OAUTH_CLIENT_ID = "ZM3WK7ax1OtQKYQ8Qqzcv5VgiA8a" +OAUTH_CLIENT_ID = "ZM3WK7ax1OtQKYQ8Qqzcv5VgiA8a" # Public application identifier, not a secret. OAUTH_LOGIN_SCOPE = "openid name profile email offline_access" -# Transport/query limits use seconds and bytes, not decoded character counts. -MAX_RESPONSE_BYTES = 1024 * 1024 -SOCKET_TIMEOUT_SECONDS = 10 -QUERY_BUDGET_SECONDS = 60 -MAX_MEASUREMENT_FUTURE_SKEW_SECONDS = 300 -DEFAULT_HTTPS_PORT = 443 -MAX_LOGIN_REDIRECTS = 10 -MAX_AUTHENTICATED_REQUEST_ATTEMPTS = 2 - -# Keep separate policies separate even when their current values are equal. +JSON_API_CONTENT_TYPE = "application/vnd.api+json" +REQUEST_TIMEOUT_SECONDS = 10 DEFAULT_TOKEN_LIFETIME_SECONDS = 3600 TOKEN_REFRESH_MARGIN_SECONDS = 30 -DEFAULT_RETRY_AFTER_SECONDS = 300 -MIN_RETRY_AFTER_SECONDS = 300 -MAX_RETRY_AFTER_SECONDS = 86400 -INITIAL_FAILURE_BACKOFF_SECONDS = 300 -MAX_FAILURE_BACKOFF_SECONDS = 3600 -MAX_FAILURE_BACKOFF_STEPS = 5 -FAILURE_BACKOFF_MULTIPLIER = 2 -ODOMETER_FAILURE_BACKOFF_SECONDS = 300 +MAX_MEASUREMENT_FUTURE_SKEW_SECONDS = 300 MAX_SOC_PERCENT = 100 -DURATION_DECIMAL_PLACES = 3 -DATA_AGE_DECIMAL_PLACES = 1 -REQUEST_COUNTER_WINDOW_SECONDS = 24 * 60 * 60 -REQUEST_COUNTER_CLOCK_SKEW_SECONDS = 5 -MAX_REQUEST_COUNTER_EVENTS = 100000 - +MAX_LOGIN_REDIRECTS = 10 PKCE_VERIFIER_RANDOM_BYTES = 64 OAUTH_STATE_RANDOM_BYTES = 32 VIN_PATTERN = r"[A-HJ-NPR-Z0-9]{17}" -JSON_API_CONTENT_TYPE = "application/vnd.api+json" -OAUTH_REFRESH_SCOPE = "openid profile vehicles" -DIAGNOSTIC_SOURCE = "nissan_cached_api" -INVALID_BATTERY_VALUE_MESSAGE = "Nissan returned an invalid battery value." -INVALID_LOGIN_REDIRECT_MESSAGE = "Unexpected redirect during Nissan login." -INVALID_LOGIN_FORM_DESTINATION_MESSAGE = "Unexpected Nissan login form destination." -REDIRECT_STATUSES = (HTTPStatus.MOVED_PERMANENTLY, HTTPStatus.FOUND, HTTPStatus.SEE_OTHER, - HTTPStatus.TEMPORARY_REDIRECT, HTTPStatus.PERMANENT_REDIRECT) - - -class RequestCounter: - """In-memory request history owned by one native vehicle module instance. - - Store timestamps and a cumulative count, never account or vehicle identifiers. - The cumulative count survives expiry of individual request timestamps. - Monotonic time defines the rolling window; local calendar time defines the - current day, which may last 23 or 25 hours. Keep an event while either window - needs it. Resets expose a new coverage start instead of claiming a full day. - The ordinary vehicle updater serializes access; no cross-process sharing. - """ - - def __init__(self): - self._events = deque() - self._requests_since_start_count = 0 - self._started_monotonic_seconds = None - self._started_unix_seconds = None - self._last_monotonic_seconds = None - self._reset_reason = "module_started" - - def _reset(self, monotonic_seconds, unix_seconds, reason): - self._events.clear() - self._requests_since_start_count = 0 - self._started_monotonic_seconds = monotonic_seconds - self._started_unix_seconds = unix_seconds - self._reset_reason = reason - - def observe(self, monotonic_seconds, unix_seconds): - """Advance coverage and expiry even when backoff sends no request.""" - if self._started_monotonic_seconds is None: - self._reset(monotonic_seconds, unix_seconds, "module_started") - elif (monotonic_seconds < self._last_monotonic_seconds or abs( - (unix_seconds - self._started_unix_seconds) - - (monotonic_seconds - self._started_monotonic_seconds)) > REQUEST_COUNTER_CLOCK_SKEW_SECONDS): - self._reset(monotonic_seconds, unix_seconds, "clock_changed") - self._last_monotonic_seconds = monotonic_seconds - # A naive local midnight is converted using that date's system timezone - # rules, not the current fixed UTC offset (which can differ after DST). - midnight = datetime.fromtimestamp(unix_seconds).replace(hour=0, minute=0, second=0, microsecond=0, fold=0) - midnight_unix_seconds = midnight.timestamp() - cutoff_monotonic_seconds = monotonic_seconds - REQUEST_COUNTER_WINDOW_SECONDS - while (self._events and self._events[0][0] <= cutoff_monotonic_seconds - and self._events[0][1] < midnight_unix_seconds): - self._events.popleft() - return midnight - - def record(self, monotonic_seconds, unix_seconds): - """Count immediately before transport, including requests that fail.""" - self.observe(monotonic_seconds, unix_seconds) - if len(self._events) >= MAX_REQUEST_COUNTER_EVENTS: - # Bound memory without silently truncating an allegedly complete sum. - self._reset(monotonic_seconds, unix_seconds, "capacity_limit") - self._events.append((monotonic_seconds, unix_seconds)) - self._requests_since_start_count += 1 - - def snapshot(self, monotonic_seconds, unix_seconds): - midnight = self.observe(monotonic_seconds, unix_seconds) - midnight_unix_seconds = midnight.timestamp() - cutoff_monotonic_seconds = monotonic_seconds - REQUEST_COUNTER_WINDOW_SECONDS - return { - "http_requests_last_24h": sum(event[0] > cutoff_monotonic_seconds for event in self._events), - "http_requests_since_midnight": sum(event[1] >= midnight_unix_seconds for event in self._events), - "http_requests_since_start": self._requests_since_start_count, - "http_requests_counting_since": datetime.fromtimestamp( - self._started_unix_seconds, timezone.utc).isoformat(), - "http_requests_local_midnight": midnight.astimezone().isoformat(), - "http_requests_last_24h_complete": self._started_monotonic_seconds <= cutoff_monotonic_seconds, - "http_requests_since_midnight_complete": self._started_unix_seconds < midnight_unix_seconds, - "http_requests_counter_reset_reason": self._reset_reason, - } - - -class NissanError(Exception): - """A deliberately bounded message safe for openWB's error reporting.""" - - -class AuthenticationError(NissanError): - pass - - -class RateLimitError(NissanError): - pass +_PRIVATE_HTTP_REQUEST = ContextVar("nissan_private_http_request", default=False) -@dataclass -class HttpResponse: - status: int - headers: dict = field(repr=False) - body: str = field(repr=False) +def _allow_transport_log(record): + """Suppress URL/payload diagnostics only inside this context's Nissan HTTP calls.""" + return not _PRIVATE_HTTP_REQUEST.get() -class _NoRedirect(HTTPRedirectHandler): - def redirect_request(self, req, fp, code, msg, headers, newurl): - return None +# Parent logger filters do not cover child records; register once on the emitters. +for _logger_name in ("urllib3.connection", "urllib3.connectionpool", "urllib3.poolmanager", + "urllib3.response", "urllib3.util.retry"): + logging.getLogger(_logger_name).addFilter(_allow_transport_log) -class PrivateTransport: - """TLS-verified transport without response logging, netrc or automatic redirects. +class NissanError(Exception): + """Errors omit response bodies, credentials and account identifiers.""" - openWB's standard requests hook logs complete authentication responses. urllib - avoids that hook and urllib3's debug logging of URLs containing VINs/codes. - CookieJar is memory-only; no cookie or token cache is written to disk. - urllib may inherit system/environment proxies; TLS still uses the local - trust store. This transport is not an independent network sandbox. - """ - def __init__(self): - self._cookies = CookieJar() - self._opener = build_opener( - HTTPSHandler(context=ssl.create_default_context()), - HTTPCookieProcessor(self._cookies), _NoRedirect()) - - def clear_cookies(self): - self._cookies.clear() - - def request(self, method, url, headers=None, form=None, json_body=None, timeout_seconds=SOCKET_TIMEOUT_SECONDS): - request_headers = dict(headers or {}) - data = None - if form is not None: - data = urlencode(form).encode("utf-8") - request_headers["Content-Type"] = "application/x-www-form-urlencoded" - elif json_body is not None: - data = json.dumps(json_body).encode("utf-8") - request_headers["Content-Type"] = JSON_API_CONTENT_TYPE - try: - request = Request(url, data=data, headers=request_headers, method=method) - try: - response = self._opener.open(request, timeout=timeout_seconds) - except HTTPError as error: - response = error - with response: - body = response.read(MAX_RESPONSE_BYTES + 1) - if len(body) > MAX_RESPONSE_BYTES: - raise NissanError("Nissan response is too large.") - return HttpResponse(response.code, dict(response.headers), body.decode("utf-8")) - except (URLError, OSError, HTTPException, ValueError): - raise NissanError("Nissan is unreachable or returned an invalid response.") from None +class AuthenticationError(NissanError): + pass class _LoginForm(HTMLParser): @@ -230,476 +96,213 @@ def handle_endtag(self, tag): self.forms.append(self.current) self.current = None - @classmethod - def parse(cls, body): - parser = cls() - parser.feed(body) - for form in parser.forms: - if {"sessionDataKey", "password"}.issubset(form["inputs"]): - return form - return None - - -def _origin(url): - try: - parsed = urlsplit(url) - if parsed.username or parsed.password or parsed.fragment or parsed.scheme != "https": - return None - return parsed.hostname, parsed.port or DEFAULT_HTTPS_PORT - except ValueError: - return None - -def _object(response): - try: - body = json.loads(response.body) - except (ValueError, TypeError): - raise NissanError("Nissan returned an invalid JSON response.") from None - if not isinstance(body, dict) or body.get("errors") or body.get("error"): - raise NissanError("Nissan reported an error or returned an unknown response format.") - return body +def _login_target(url): + """Only follow HTTPS login redirects and submit credentials to the login host.""" + parsed = urlsplit(url) + if (parsed.scheme != "https" or parsed.netloc != urlsplit(AUTH_BASE_URL).netloc or parsed.fragment): + raise NissanError("Unexpected Nissan login destination.") + return url -def _number(value, maximum=None): - if isinstance(value, bool) or not isinstance(value, (int, float)): - raise NissanError(INVALID_BATTERY_VALUE_MESSAGE) +def _number(value, maximum=math.inf): + if isinstance(value, bool) or not isinstance(value, (int, float)) or not 0 <= value < math.inf or value > maximum: + raise NissanError("Nissan returned an invalid vehicle measurement.") try: - value = float(value) + return float(value) except OverflowError: - raise NissanError(INVALID_BATTERY_VALUE_MESSAGE) from None - if not math.isfinite(value) or value < 0 or (maximum is not None and value > maximum): - raise NissanError(INVALID_BATTERY_VALUE_MESSAGE) - return float(value) - - -@dataclass(frozen=True) -class BatteryStatus: - """SoC and its Unix measurement time; optional range/odometer in km. - - The battery timestamp does not describe the separate cockpit odometer. - """ + raise NissanError("Nissan returned an invalid vehicle measurement.") from None - soc_percent: float - measurement_unix_seconds: float - range_km: Optional[float] = None - odometer_km: Optional[float] = None - -def parse_battery(body, now_unix_seconds=None): - """Require an actual, timezone-aware measurement time; never substitute now.""" +def parse_battery(body) -> Tuple[float, Optional[float], float]: + """Preserve the actual measurement time and valid zero values; never invent a SoC.""" try: attributes = body["data"]["attributes"] soc_percent = _number(attributes["batteryLevel"], MAX_SOC_PERCENT) - raw_time = attributes["lastUpdateTime"] - if not isinstance(raw_time, str): - raise ValueError - measured = datetime.fromisoformat(raw_time.replace("Z", "+00:00")) - if measured.tzinfo is None or measured.utcoffset() is None: - raise ValueError + measured = datetime.fromisoformat(attributes["lastUpdateTime"].replace("Z", "+00:00")) measurement_unix_seconds = measured.timestamp() - if not math.isfinite(measurement_unix_seconds) or measurement_unix_seconds <= 0 or measurement_unix_seconds > ( - time.time() if now_unix_seconds is None else now_unix_seconds) + MAX_MEASUREMENT_FUTURE_SKEW_SECONDS: + if (measured.tzinfo is None + or not 0 < measurement_unix_seconds <= time.time() + MAX_MEASUREMENT_FUTURE_SKEW_SECONDS): raise ValueError range_km = attributes.get("rangeHvacOn") if range_km is None: range_km = attributes.get("rangeHvacOff") - # An unavailable optional range must not discard a valid SoC measurement. try: - range_km = None if range_km is None else _number(range_km) + range_km = _number(range_km) except NissanError: range_km = None - return BatteryStatus(soc_percent, measurement_unix_seconds, range_km) + return soc_percent, range_km, measurement_unix_seconds except (KeyError, TypeError, ValueError, AttributeError, OverflowError): raise NissanError("Nissan did not return a valid SoC with a measurement timestamp.") from None -def parse_odometer(body): - """Read totalMileage in km; never substitute mileage, range or zero. - - The reference OdometerSensor maps cockpit.totalMileage to kilometres. It - supplies no odometer measurement timestamp. Missing or invalid optional - data must not invalidate a successful battery measurement. - """ - try: - return _number(body["data"]["attributes"]["totalMileage"]) - except (KeyError, TypeError, NissanError): - return None - - class NissanClient: - """Stateful client for one account/vehicle; callers must serialize its use. - - Keeps tokens, vehicle selection and backoff in memory between queries. - Create a new instance when credentials/VIN change. The injected transport - and clocks support synthetic tests; production requests use PrivateTransport. - """ - - def __init__(self, user_id, password, vin=None, transport=None, - monotonic_seconds=time.monotonic, unix_seconds=time.time, request_counter=None): + def __init__(self, user_id, password, vin=None): self._user = user_id self._password = password self._configured_vin = (vin or "").strip().upper() - self._transport = transport if transport is not None else PrivateTransport() - self._monotonic_seconds = monotonic_seconds - self._unix_seconds = unix_seconds - self._request_counter = request_counter - self._access_token = None - self._refresh_token = None - self._expires_at_monotonic_seconds = 0 - self._user_id = None self._vin = None - self._retry_at_monotonic_seconds = 0 - self._provider_retry_at_monotonic_seconds = 0 - self._failed_queries = 0 - self._retry_error_type = NissanError - self._retry_reason = "" - self._deadline_monotonic_seconds = None - self.last_query = None - self._last_contact_unix_seconds = None - self._last_success_unix_seconds = None - self._last_measurement_unix_seconds = None - self._request_started_unix_seconds = None - self._request_count = 0 - self._http_status = None - self._phase = "idle" - self._odometer_retry_at_monotonic_seconds = 0 - self._odometer_outcome = "not_requested" - self._odometer_http_status = None - self._odometer_received_unix_seconds = None + self._access_token = self._refresh_token = None + self._expires_at_monotonic_seconds = 0 + self._session = get_http_session() + # The shared response hook logs full tokens/payloads. Check status locally instead. + self._session.hooks["response"] = [] + self._session.trust_env = False # Do not load unrelated netrc credentials or proxies. def _request(self, method, url, auth_request=False, **kwargs): - if _origin(url) not in {_origin(AUTH_BASE_URL), _origin(BFF_BASE_URL), - _origin(USERS_BASE_URL), _origin(CARS_BASE_URL)}: - raise NissanError("Unexpected Nissan request destination; request aborted.") - remaining_seconds = self._deadline_monotonic_seconds - self._monotonic_seconds() - if remaining_seconds <= 0: - raise NissanError("Nissan query time limit exceeded.") - self._last_contact_unix_seconds = self._unix_seconds() - if self._request_started_unix_seconds is None: - self._request_started_unix_seconds = self._last_contact_unix_seconds - self._request_count += 1 - if self._request_counter is not None: - self._request_counter.record(self._monotonic_seconds(), self._last_contact_unix_seconds) - self._http_status = None - response = self._transport.request(method, url, timeout_seconds=min( - SOCKET_TIMEOUT_SECONDS, remaining_seconds), **kwargs) - self._http_status = response.status - if response.status in (HTTPStatus.TOO_MANY_REQUESTS, HTTPStatus.SERVICE_UNAVAILABLE): - self._retry_at_monotonic_seconds = self._monotonic_seconds() + self._retry_after(response) - # Separate provider advice from the scheduled client's own error backoff. - provider_delay_seconds = self._retry_after( - response, minimum_seconds=0, - default_seconds=(DEFAULT_RETRY_AFTER_SECONDS - if response.status == HTTPStatus.TOO_MANY_REQUESTS else 0)) - self._provider_retry_at_monotonic_seconds = self._monotonic_seconds() + provider_delay_seconds - if response.status == HTTPStatus.TOO_MANY_REQUESTS: - raise RateLimitError("Nissan is rate limiting requests. Please try again later.") - if response.status in (HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN) or ( - auth_request and response.status == HTTPStatus.BAD_REQUEST): + log_context = _PRIVATE_HTTP_REQUEST.set(True) + try: + response = self._session.request(method, url, timeout=REQUEST_TIMEOUT_SECONDS, + allow_redirects=False, **kwargs) + except RequestException: + raise NissanError("Nissan could not be reached.") from None + finally: + _PRIVATE_HTTP_REQUEST.reset(log_context) + if response.status_code in (HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN) or ( + auth_request and response.status_code == HTTPStatus.BAD_REQUEST): raise AuthenticationError("Nissan rejected the login or access authorization.") - if response.status >= HTTPStatus.BAD_REQUEST: - raise NissanError("Nissan service returned an HTTP error ({}).".format(response.status)) + if response.status_code >= HTTPStatus.BAD_REQUEST: + raise NissanError(f"Nissan service returned HTTP {response.status_code}.") return response - def _retry_after(self, response, minimum_seconds=MIN_RETRY_AFTER_SECONDS, - default_seconds=DEFAULT_RETRY_AFTER_SECONDS): - """Accept delay seconds or an HTTP date; bound server-directed pauses.""" - value = next((v for k, v in response.headers.items() if k.lower() == "retry-after"), None) - try: - delay_seconds = int(value) - except (ValueError, TypeError, OverflowError): - try: - retry_at_datetime = parsedate_to_datetime(value) - if retry_at_datetime.tzinfo is None: - raise ValueError - delay_seconds = retry_at_datetime.timestamp() - self._unix_seconds() - except (ValueError, TypeError, AttributeError, OverflowError): - delay_seconds = default_seconds - return max(minimum_seconds, min(delay_seconds, MAX_RETRY_AFTER_SECONDS)) - def _json(self, method, url, **kwargs): response = self._request(method, url, **kwargs) - if not HTTPStatus.OK <= response.status < HTTPStatus.MULTIPLE_CHOICES: - raise NissanError("Unexpected redirect from the Nissan API.") - return _object(response) + try: + body = response.json() + if not isinstance(body, dict) or body.get("error") or body.get("errors"): + raise ValueError + return body + except ValueError: + raise NissanError("Nissan returned an invalid response.") from None def _redirects(self, response, url, callback=False): for _ in range(MAX_LOGIN_REDIRECTS): - if response.status not in REDIRECT_STATUSES: + if not response.is_redirect: return response, url - location = next((v for k, v in response.headers.items() if k.lower() == "location"), None) - if not isinstance(location, str) or not location: - break - try: - target = urljoin(url, location) - parsed = urlsplit(target) - except ValueError: - raise NissanError(INVALID_LOGIN_REDIRECT_MESSAGE) from None - expected = urlsplit(OAUTH_CALLBACK_URI) - if callback and (parsed.scheme, parsed.netloc, parsed.path) == ( - expected.scheme, expected.netloc, expected.path) and not parsed.fragment: + target = urljoin(url, response.headers["Location"]) + if callback and target.split("?", 1)[0] == OAUTH_CALLBACK_URI: return response, target - if _origin(target) != _origin(AUTH_BASE_URL): - raise NissanError(INVALID_LOGIN_REDIRECT_MESSAGE) - response = self._request("GET", target) - url = target - raise NissanError("Too many or invalid Nissan redirects.") + url = _login_target(target) + response = self._request("GET", url) + raise NissanError("Too many Nissan login redirects.") def _login(self): - if (not isinstance(self._user, str) or not self._user.strip() - or not isinstance(self._password, str) or not self._password): + """Sign in through MyNISSAN, then exchange its identity token for Nissan API tokens.""" + if not self._user or not self._password: raise AuthenticationError("MyNISSAN username and password are required.") - # A new login must not reuse an expired/partially authenticated web session. - if hasattr(self._transport, "clear_cookies"): - self._transport.clear_cookies() - self._refresh_token = None + self._session.cookies.clear() + self._access_token = self._refresh_token = None + # Request a PKCE-bound authorization code through the hosted login form. verifier = secrets.token_urlsafe(PKCE_VERIFIER_RANDOM_BYTES) - digest = hashlib.sha256(verifier.encode("ascii")).digest() - challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii") + challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode() state = secrets.token_urlsafe(OAUTH_STATE_RANDOM_BYTES) url = AUTH_BASE_URL + "oauth2/authorize?" + urlencode({ "response_type": "code", "redirect_uri": OAUTH_CALLBACK_URI, "client_id": OAUTH_CLIENT_ID, "state": state, "scope": OAUTH_LOGIN_SCOPE, "code_challenge": challenge, "code_challenge_method": "S256", "locale": "en_GB", "brand": "Nissan", "client": "mynissanapp"}) response, url = self._redirects(self._request("GET", url), url) - form = _LoginForm.parse(response.body) + parser = _LoginForm() + parser.feed(response.text) + form = next((f for f in parser.forms if {"sessionDataKey", "password"}.issubset(f["inputs"])), None) if not form or not form["action"]: - raise NissanError("Nissan login form is unavailable; you may need to sign in through the app.") - try: - target = urljoin(url, form["action"]) - except ValueError: - raise NissanError(INVALID_LOGIN_FORM_DESTINATION_MESSAGE) from None - if _origin(target) != _origin(AUTH_BASE_URL): - raise NissanError(INVALID_LOGIN_FORM_DESTINATION_MESSAGE) - data = dict(form["inputs"]) + raise AuthenticationError("Nissan login form is unavailable. Check sign-in through MyNISSAN.") + target = _login_target(urljoin(url, form["action"])) + data = form["inputs"] region = data.get("regionCode", "") data.update(userName=self._user, username=region + "/" + self._user if region else self._user, password=self._password) - response = self._request("POST", target, form=data, headers={ - "Origin": AUTH_BASE_URL.rstrip("/"), "Referer": url}) - response, callback = self._redirects(response, target, callback=True) - parsed = urlsplit(callback) - expected = urlsplit(OAUTH_CALLBACK_URI) - if (parsed.scheme, parsed.netloc, parsed.path) != (expected.scheme, expected.netloc, expected.path): - raise AuthenticationError( - "Nissan login did not complete. Check your credentials and any notices in MyNISSAN.") - values = parse_qs(parsed.query) - if values.get("state") != [state] or len(values.get("code", [])) != 1 or values.get("error"): - raise AuthenticationError("Nissan returned an invalid login confirmation.") - token = self._json("POST", AUTH_BASE_URL + "oauth2/token", auth_request=True, form={ + response = self._request("POST", target, data=data, headers={ + "Origin": AUTH_BASE_URL.rstrip("/"), "Referer": url}) + _, callback = self._redirects(response, target, callback=True) + values = parse_qs(urlsplit(callback).query) + if (callback.split("?", 1)[0] != OAUTH_CALLBACK_URI or urlsplit(callback).fragment + or values.get("state") != [state] or len(values.get("code", [])) != 1 or values.get("error")): + raise AuthenticationError("Nissan login did not complete. Check credentials and notices in MyNISSAN.") + # Redeem the authorization code at the identity provider. + token = self._json("POST", AUTH_BASE_URL + "oauth2/token", auth_request=True, data={ "grant_type": "authorization_code", "redirect_uri": OAUTH_CALLBACK_URI, "client_id": OAUTH_CLIENT_ID, "code": values["code"][0], "code_verifier": verifier, "scope": OAUTH_LOGIN_SCOPE}) - identity_token = token.get("id_token") - if not isinstance(identity_token, str) or not identity_token: + if not isinstance(token.get("id_token"), str) or not token["id_token"]: raise AuthenticationError("Nissan did not return an identity token.") - self._install(self._json("POST", BFF_BASE_URL + "v1/oauth2/access_token?platform=Android", auth_request=True, - headers={"Authorization": identity_token, "Content-Type": JSON_API_CONTENT_TYPE})) - - def _install(self, token): - access = token.get("access_token") - refresh = token.get("refresh_token") - if not isinstance(access, str) or not access or (refresh is not None and not isinstance(refresh, str)): - raise AuthenticationError("Nissan did not return a valid access token.") + # The vehicle API requires separate access/refresh tokens from the Nissan backend. + self._install_token(self._json("POST", BFF_BASE_URL + "v1/oauth2/access_token?platform=Android", + auth_request=True, headers={"Authorization": token["id_token"], + "Content-Type": JSON_API_CONTENT_TYPE})) + + def _install_token(self, token): + if not isinstance(token.get("access_token"), str) or not token["access_token"]: + raise AuthenticationError("Nissan did not return an access token.") try: - expires_in_seconds = float(token.get("expires_in", DEFAULT_TOKEN_LIFETIME_SECONDS)) - if not math.isfinite(expires_in_seconds) or expires_in_seconds <= 0: + lifetime_seconds = float(token.get("expires_in", DEFAULT_TOKEN_LIFETIME_SECONDS)) + if not math.isfinite(lifetime_seconds) or lifetime_seconds <= 0: raise ValueError - except (ValueError, TypeError, OverflowError): + except (TypeError, ValueError, OverflowError): raise AuthenticationError("Nissan returned an invalid token lifetime.") from None - self._access_token = access - self._refresh_token = refresh or self._refresh_token - self._expires_at_monotonic_seconds = ( - self._monotonic_seconds() + max(0, expires_in_seconds - TOKEN_REFRESH_MARGIN_SECONDS)) + self._access_token = token["access_token"] + self._refresh_token = token.get("refresh_token") or self._refresh_token + self._expires_at_monotonic_seconds = time.monotonic() + max(0, lifetime_seconds - TOKEN_REFRESH_MARGIN_SECONDS) def _authenticate(self, force=False): - if self._access_token and not force and self._monotonic_seconds() < self._expires_at_monotonic_seconds: + if self._access_token and not force and time.monotonic() < self._expires_at_monotonic_seconds: return if self._refresh_token: try: - self._install(self._json("POST", BFF_BASE_URL + "v1/oauth2/refresh-token?platform=Android", - auth_request=True, - headers={"Authorization": self._refresh_token}, - json_body={"scope": OAUTH_REFRESH_SCOPE})) + self._install_token(self._json("POST", BFF_BASE_URL + "v1/oauth2/refresh-token?platform=Android", + auth_request=True, headers={"Authorization": self._refresh_token, + "Content-Type": JSON_API_CONTENT_TYPE}, + json={"scope": "openid profile vehicles"})) return except AuthenticationError: - self._access_token = self._refresh_token = None + pass # A rejected refresh token requires a new login. self._login() def _get(self, url): - for attempt in range(MAX_AUTHENTICATED_REQUEST_ATTEMPTS): + # Retry authorization once; scheduling and service-error retries belong to Core. + for force in (False, True): + self._authenticate(force=force) try: - return self._json("GET", url, headers={ - "Authorization": "Bearer " + self._access_token, "Content-Type": JSON_API_CONTENT_TYPE}) + return self._json("GET", url, headers={"Authorization": "Bearer " + self._access_token, + "Content-Type": JSON_API_CONTENT_TYPE}) except AuthenticationError: - if attempt: + if force: + self._access_token = self._refresh_token = None raise - self._authenticate(force=True) def _select_vehicle(self): - if self._configured_vin and not re.fullmatch(VIN_PATTERN, self._configured_vin): - raise NissanError("The configured VIN is invalid.") - if not self._user_id: - user = self._get(USERS_BASE_URL + "v1/users/current").get("userId") - if not isinstance(user, str) or not user: - raise NissanError("Nissan did not return a user identifier.") - self._user_id = user - vehicles = self._get(BFF_BASE_URL + "v5/users/" + quote(self._user_id, safe="") + "/cars").get("data") + user_id = self._get(USERS_BASE_URL + "v1/users/current").get("userId") + if not isinstance(user_id, str) or not user_id: + raise NissanError("Nissan did not return a user identifier.") + vehicles = self._get(BFF_BASE_URL + "v5/users/" + quote(user_id, safe="") + "/cars").get("data") if not isinstance(vehicles, list): raise NissanError("Nissan did not return a vehicle list.") - candidates = [v.get("vin", "").upper() for v in vehicles - if isinstance(v, dict) and isinstance(v.get("vin"), str) - and re.fullmatch(VIN_PATTERN, v["vin"].upper())] + candidates = {v["vin"].upper() for v in vehicles if isinstance(v, dict) and isinstance(v.get("vin"), str) + and re.fullmatch(VIN_PATTERN, v["vin"].upper())} if self._configured_vin: if self._configured_vin not in candidates: raise NissanError("The configured VIN was not found in the Nissan account.") self._vin = self._configured_vin - elif len(set(candidates)) == 1: - self._vin = candidates[0] + elif len(candidates) == 1: + self._vin = candidates.pop() else: raise NissanError("Could not select a unique vehicle. Please configure the VIN.") - def _optional_odometer(self): - """Try one passive GET after battery success, within the same budget. + def fetch_battery(self) -> Tuple[float, Optional[float], float, Optional[float]]: + """Return SoC (%), range (km), battery measurement time (Unix seconds), odometer (km). - Endpoint/auth/transport failures pause this optional endpoint for five - minutes without discarding SoC or retrying login. Explicit provider - pauses still govern subsequent queries, including battery queries. - Never reuse an old odometer as if it came from this response. + Range and odometer may be None; valid zero values are preserved. The timestamp + belongs only to the battery data, not the separately retrieved odometer. + Battery or required login failures raise NissanError. Odometer failures do not + invalidate a valid SoC and return None for mileage. """ - if self._monotonic_seconds() < self._odometer_retry_at_monotonic_seconds: - self._odometer_outcome = "deferred" - return None - self._phase = "odometer" - self._http_status = None + if not self._vin: + self._select_vehicle() + url = CARS_BASE_URL + "v1/cars/" + self._vin + soc_percent, range_km, measurement_unix_seconds = parse_battery(self._get(url + "/battery-status")) + # Cockpit availability differs by vehicle. Never discard a valid SoC or invent mileage. + odometer_km = None try: - body = self._json("GET", CARS_BASE_URL + "v1/cars/" + self._vin + "/cockpit", headers={ - "Authorization": "Bearer " + self._access_token, "Content-Type": JSON_API_CONTENT_TYPE}) - odometer_km = parse_odometer(body) - self._odometer_outcome = "success" if odometer_km is not None else "unavailable" - if odometer_km is not None: - self._odometer_received_unix_seconds = self._unix_seconds() - return odometer_km - except NissanError as error: - self._odometer_outcome = "failed" - self._odometer_retry_at_monotonic_seconds = self._monotonic_seconds() + ODOMETER_FAILURE_BACKOFF_SECONDS - if (isinstance(error, RateLimitError) - or self._provider_retry_at_monotonic_seconds > self._monotonic_seconds()): - self._retry_error_type = type(error) - self._retry_reason = "Nissan requested a pause after the optional odometer query." - else: - # A cockpit-only outage must not suppress the next battery query. - self._retry_at_monotonic_seconds = 0 - return None - finally: - self._odometer_http_status = self._http_status - - def _finish_query(self, outcome, started_monotonic_seconds, result=None): - """Keep an allowlisted diagnostic summary, never raw transport data.""" - completed_unix_seconds = self._unix_seconds() - if result is not None: - self._last_success_unix_seconds = completed_unix_seconds - self._last_measurement_unix_seconds = result.measurement_unix_seconds - - def iso(unix_seconds): - return None if unix_seconds is None else datetime.fromtimestamp(unix_seconds, timezone.utc).isoformat() - - self.last_query = { - "outcome": outcome, - "phase": self._phase, - "request_started_at": iso(self._request_started_unix_seconds), - "completed_at": iso(completed_unix_seconds), - "duration_seconds": ( - round(max(0, self._monotonic_seconds() - started_monotonic_seconds), DURATION_DECIMAL_PLACES) - ), - "http_requests": self._request_count, - "last_http_status": self._http_status, - "last_contact_at": iso(self._last_contact_unix_seconds), - "last_success_at": iso(self._last_success_unix_seconds), - "last_measurement_at": iso(self._last_measurement_unix_seconds), - "measurement_at": iso(result.measurement_unix_seconds) if result else None, - "data_age_seconds": ( - round(completed_unix_seconds - result.measurement_unix_seconds, - DATA_AGE_DECIMAL_PLACES) if result else None - ), - "soc": result.soc_percent if result else None, - "range_km": result.range_km if result else None, - "odometer_km": result.odometer_km if result else None, - "odometer_outcome": self._odometer_outcome, - "odometer_http_status": self._odometer_http_status, - "odometer_retry_after_seconds": ( - max(0, math.ceil(self._odometer_retry_at_monotonic_seconds - self._monotonic_seconds())) - ), - "odometer_received_at": iso(self._odometer_received_unix_seconds), - "retry_after_seconds": max(0, math.ceil(self._retry_at_monotonic_seconds - self._monotonic_seconds())), - "provider_retry_after_seconds": ( - max(0, math.ceil(self._provider_retry_at_monotonic_seconds - self._monotonic_seconds())) - ), - "source": DIAGNOSTIC_SOURCE, - } - if self._request_counter is not None: - self.last_query.update(self._request_counter.snapshot(self._monotonic_seconds(), completed_unix_seconds)) - - def fetch_battery(self, include_odometer=False): - """Return cached battery data or raise NissanError; never issue wake commands. - - Updates last_query with allowlisted diagnostics on success/failure and - deferred calls. Failure pauses subsequent calls without sleeping. The - 60-second budget limits new request starts, not total socket read time. - Core, not this client, decides whether to accept a measurement timestamp. - include_odometer adds one best-effort cockpit GET after battery success; - callers serving openWB and private tests enable it explicitly. - """ - started_monotonic_seconds = self._monotonic_seconds() - if self._request_counter is not None: - self._request_counter.observe(started_monotonic_seconds, self._unix_seconds()) - self._request_started_unix_seconds = None - self._request_count = 0 - self._http_status = None - self._phase = "idle" - self._odometer_outcome = "not_requested" - self._odometer_http_status = None - self._odometer_received_unix_seconds = None - if started_monotonic_seconds < self._retry_at_monotonic_seconds: - remaining_seconds = math.ceil(self._retry_at_monotonic_seconds - started_monotonic_seconds) - self._finish_query("deferred", started_monotonic_seconds) - raise self._retry_error_type( - "Nissan requests are paused for {} more seconds. {}".format(remaining_seconds, self._retry_reason)) - self._deadline_monotonic_seconds = started_monotonic_seconds + QUERY_BUDGET_SECONDS - result = None - outcome = "failed" - try: - self._phase = "authentication" - self._authenticate() - if not self._vin: - self._phase = "vehicle_selection" - self._select_vehicle() - self._phase = "battery" - body = self._get(CARS_BASE_URL + "v1/cars/" + self._vin + "/battery-status") - result = parse_battery(body, now_unix_seconds=self._unix_seconds()) - self._failed_queries = 0 - self._retry_at_monotonic_seconds = 0 - self._provider_retry_at_monotonic_seconds = 0 - self._retry_error_type = NissanError - self._retry_reason = "" - if include_odometer: - result = replace(result, odometer_km=self._optional_odometer()) - outcome = "success" - return result - except NissanError as error: - if isinstance(error, AuthenticationError): - self._access_token = self._refresh_token = None - self._user_id = self._vin = None - # Do not sleep in an openWB worker or retain exception tracebacks. - # A successful query resets this backoff; deferred calls do not extend it. - self._failed_queries = min(self._failed_queries + 1, MAX_FAILURE_BACKOFF_STEPS) - delay_seconds = min( - INITIAL_FAILURE_BACKOFF_SECONDS * FAILURE_BACKOFF_MULTIPLIER ** (self._failed_queries - 1), - MAX_FAILURE_BACKOFF_SECONDS) - self._retry_at_monotonic_seconds = max( - self._retry_at_monotonic_seconds, self._monotonic_seconds() + delay_seconds) - self._retry_error_type = type(error) - self._retry_reason = str(error) - raise - finally: - self._deadline_monotonic_seconds = None - self._finish_query(outcome, started_monotonic_seconds, result) + odometer_km = _number(self._get(url + "/cockpit")["data"]["attributes"]["totalMileage"]) + except (NissanError, KeyError, TypeError): + log.debug("Nissan odometer unavailable.") + log.debug("Nissan battery measurement_unix_seconds=%s", measurement_unix_seconds) + return soc_percent, range_km, measurement_unix_seconds, odometer_km diff --git a/packages/modules/vehicles/nissanconnect/api_test.py b/packages/modules/vehicles/nissanconnect/api_test.py index f3cb2f513c..3a21a45ee3 100644 --- a/packages/modules/vehicles/nissanconnect/api_test.py +++ b/packages/modules/vehicles/nissanconnect/api_test.py @@ -1,387 +1,244 @@ -"""Synthetic fixtures only. No captured account responses or real identifiers.""" +"""Synthetic protocol tests; unmatched requests cannot reach Nissan.""" import base64 +from concurrent.futures import ThreadPoolExecutor import hashlib -import io import logging -import traceback -from urllib.error import HTTPError, URLError +import re +from unittest.mock import Mock from urllib.parse import parse_qs, urlsplit import pytest +import requests +import requests_mock +from urllib3.connectionpool import HTTPSConnectionPool +from urllib3.response import HTTPResponse +from urllib3.util.retry import Retry -from modules.vehicles.nissanconnect.src.api import ( - AUTH_BASE_URL, CARS_BASE_URL, AuthenticationError, MAX_RESPONSE_BYTES, - NissanError, PrivateTransport, RateLimitError, parse_battery) -from modules.vehicles.nissanconnect.tests.helpers import ( - FORM, MEASURED_UNIX_SECONDS, SECOND_VIN, VIN, battery, full_script, login_script, make_client, reply) +from modules.vehicles.nissanconnect import api +from modules.vehicles.nissanconnect.config import NissanConnect, NissanConnectConfiguration +VIN = "SJNFAAZE1U0000001" +OTHER_VIN = "SJNFAAZE1U0000002" +MEASUREMENT_UNIX_SECONDS = 1640995200.0 +BATTERY_URL = api.CARS_BASE_URL + "v1/cars/" + VIN + "/battery-status" +COCKPIT_URL = api.CARS_BASE_URL + "v1/cars/" + VIN + "/cockpit" +REFRESH_URL = api.BFF_BASE_URL + "v1/oauth2/refresh-token?platform=Android" +FORM = ('
' + '
') -@pytest.mark.parametrize("soc", [0, 47.5, 100]) -def test_valid_soc_preserves_old_measurement(soc): - result = parse_battery(battery(soc, rangeHvacOn=0, rangeHvacOff=150), - now_unix_seconds=MEASURED_UNIX_SECONDS + 86400) - assert (result.soc_percent, result.measurement_unix_seconds, result.range_km) == (soc, MEASURED_UNIX_SECONDS, 0) - - -@pytest.mark.parametrize("soc", [None, True, False, -1, 101, "47", float("nan"), float("inf"), [], {}]) -def test_invalid_soc_never_becomes_zero(soc): - with pytest.raises(NissanError): - parse_battery(battery(soc), now_unix_seconds=MEASURED_UNIX_SECONDS) - - -@pytest.mark.parametrize("timestamp", [None, 0, "", "bad", "2022-01-01T00:00:00", "2099-01-01T00:00:00Z"]) -def test_missing_ambiguous_or_future_time_rejected(timestamp): - with pytest.raises(NissanError): - parse_battery(battery(timestamp=timestamp), now_unix_seconds=MEASURED_UNIX_SECONDS) - - -def test_offset_time_and_optional_range(): - result = parse_battery(battery(timestamp="2022-01-01T01:00:00+01:00", rangeHvacOff=150), - now_unix_seconds=MEASURED_UNIX_SECONDS) - assert result.measurement_unix_seconds == MEASURED_UNIX_SECONDS - assert result.range_km == 150 - assert parse_battery(battery(rangeHvacOn=-1), now_unix_seconds=MEASURED_UNIX_SECONDS).range_km is None +@pytest.fixture(autouse=True) +def http(): + with requests_mock.Mocker() as mock: + yield mock -@pytest.mark.parametrize("body", [{}, {"data": None}, {"data": {"attributes": {}}}, [], None]) -def test_invalid_shape_has_safe_error(body): - with pytest.raises(NissanError): - parse_battery(body) - -def test_login_pkce_selection_and_cached_battery_only(caplog): - caplog.set_level(logging.DEBUG) - client, transport, _ = make_client(full_script()) - status = client.fetch_battery() - assert (status.soc_percent == 47 and status.measurement_unix_seconds == MEASURED_UNIX_SECONDS - and status.range_km == 123.4) - calls = transport.calls - authorize = parse_qs(urlsplit(calls[0][1]).query) - verifier = calls[2][2]["form"]["code_verifier"] - expected = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode() - assert authorize["code_challenge"] == [expected] - assert authorize["code_challenge_method"] == ["S256"] - assert calls[1][2]["form"]["username"] == "EU/synthetic@example.invalid" - assert calls[3][2]["headers"]["Authorization"] == "synthetic-id" - assert calls[-1][2]["headers"]["Authorization"] == "Bearer synthetic-access" - assert calls[-1][:2] == ("GET", CARS_BASE_URL + "v1/cars/" + VIN + "/battery-status") - assert all("/actions/" not in url for _, url, _ in calls) - assert all(0 < kwargs["timeout_seconds"] <= 10 for _, _, kwargs in calls) - assert not caplog.records - assert not transport.responses - - -def test_subsequent_update_reuses_tokens_and_vehicle(): - client, transport, _ = make_client(full_script() + [reply(battery(48))]) - client.fetch_battery() - count = len(transport.calls) - assert client.fetch_battery().soc_percent == 48 - assert len(transport.calls) == count + 1 - - -def test_expired_token_is_refreshed_in_memory(): - script = full_script() + [reply({"access_token": "synthetic-renewed"}), reply(battery())] - client, transport, ticks_monotonic_seconds = make_client(script) - client.fetch_battery() - ticks_monotonic_seconds[0] = 3600 - client.fetch_battery() - assert "refresh-token" in transport.calls[-2][1] - assert transport.calls[-2][2]["headers"]["Authorization"] == "synthetic-refresh" - assert transport.calls[-1][2]["headers"]["Authorization"] == "Bearer synthetic-renewed" - - -def test_rejected_refresh_logs_in_once(): - script = full_script() + [reply({"error": "invalid_grant"}, 400)] + login_script() + [reply(battery())] - client, transport, ticks_monotonic_seconds = make_client(script) - client.fetch_battery() - ticks_monotonic_seconds[0] = 3600 - client.fetch_battery() - assert sum("oauth2/authorize?" in url for _, url, _ in transport.calls) == 2 - assert transport.cookies_cleared == 2 - - -def test_401_retries_once_then_stops_and_cools_down(): - script = full_script() + [reply("synthetic-private-body", 401), reply({"access_token": "synthetic-new"}), - reply("synthetic-private-body", 401)] - client, transport, _ = make_client(script) - client.fetch_battery() - with pytest.raises(AuthenticationError, match="Nissan"): - client.fetch_battery() - count = len(transport.calls) - with pytest.raises(AuthenticationError, match="paused"): - client.fetch_battery() - assert len(transport.calls) == count - - -def test_state_mismatch_never_exchanges_tokens(): - client, transport, _ = make_client([reply(FORM), reply("", 302, { - "Location": "com://wso2.service.nci?code=synthetic-code&state=wrong"})]) - with pytest.raises(AuthenticationError): - client.fetch_battery() - assert len(transport.calls) == 2 - - -@pytest.mark.parametrize("target", [ - "https://example.invalid/steal", "http://login.mynissan-account.com/", - "https://login.mynissan-account.com:444/", "https://user@login.mynissan-account.com/", -]) -def test_form_target_validated_before_sending_credentials(target): - client, transport, _ = make_client([reply(FORM.replace("/commonauth", target))]) - with pytest.raises(NissanError): - client.fetch_battery() - assert len(transport.calls) == 1 +def battery(soc=47, timestamp="2022-01-01T00:00:00Z", **extra): + return {"data": {"attributes": dict(batteryLevel=soc, lastUpdateTime=timestamp, **extra)}} -def test_cross_origin_redirect_is_never_followed(): - client, transport, _ = make_client([reply("", 302, {"Location": "https://example.invalid/steal"})]) - with pytest.raises(NissanError): - client.fetch_battery() - assert len(transport.calls) == 1 +def client(): + result = api.NissanClient("synthetic@example.invalid", "synthetic-password") + result._vin = VIN + result._install_token({"access_token": "synthetic-access", "refresh_token": "synthetic-refresh"}) + return result -def test_redirect_loop_is_bounded(): - client, transport, _ = make_client([reply("", 302, {"Location": "/loop"})] * 11) - with pytest.raises(NissanError): - client.fetch_battery() - assert len(transport.calls) == 11 +def login(http): + http.get(re.compile(re.escape(api.AUTH_BASE_URL + "oauth2/authorize?")), text=FORM) + def callback(request, context): + state = parse_qs(urlsplit(http.request_history[-2].url).query)["state"][0] + context.headers["Location"] = api.OAUTH_CALLBACK_URI + "?code=synthetic-code&state=" + state + return "" -@pytest.mark.parametrize("vehicles,vin", [ - ([], None), ([{"vin": VIN}, {"vin": SECOND_VIN}], None), ([{"vin": VIN}], SECOND_VIN), -]) -def test_missing_or_ambiguous_vehicle_never_guessed(vehicles, vin): - client, transport, _ = make_client(full_script(vehicles), vin) - with pytest.raises(NissanError): - client.fetch_battery() - assert not any("battery-status" in url for _, url, _ in transport.calls) - - -def test_configured_vehicle_is_selected(): - client, transport, _ = make_client(full_script([{"vin": VIN}, {"vin": SECOND_VIN}]), SECOND_VIN.lower()) - client.fetch_battery() - assert SECOND_VIN in transport.calls[-1][1] - - -def test_rate_limit_pauses_later_updates_without_sleeping(): - client, transport, ticks_monotonic_seconds = make_client( - full_script() + [reply("private", 429, {"Retry-After": "600"})]) - client.fetch_battery() - with pytest.raises(RateLimitError): - client.fetch_battery() - ticks_monotonic_seconds[0] = 599 - count = len(transport.calls) - with pytest.raises(RateLimitError): - client.fetch_battery() - assert len(transport.calls) == count - ticks_monotonic_seconds[0] = 600 - transport.responses.append(reply(battery())) - assert client.fetch_battery().soc_percent == 47 - - -@pytest.mark.parametrize("response", [ - reply("private", 500), reply("private", 503), reply("not-json"), - reply({"errors": [{"detail": "private"}]}), reply({"id_token": None}), -]) -def test_bad_response_not_logged_or_in_error(response, caplog): - caplog.set_level(logging.DEBUG) - script = login_script() - script[2] = response - client, _, _ = make_client(script) - with pytest.raises(NissanError) as error: - client.fetch_battery() - assert "private" not in str(error.value) - assert "synthetic-password" not in str(error.value) - assert not caplog.records + http.post(api.AUTH_BASE_URL + "commonauth", status_code=302, text=callback) + http.post(api.AUTH_BASE_URL + "oauth2/token", json={"id_token": "synthetic-id"}) + http.post(api.BFF_BASE_URL + "v1/oauth2/access_token?platform=Android", + json={"access_token": "synthetic-access", "refresh_token": "synthetic-refresh"}) -def test_transport_hides_exception_details_and_response_repr(monkeypatch, caplog): +def test_login_selection_measurements_and_session_reuse(http, caplog): caplog.set_level(logging.DEBUG) - transport = PrivateTransport() - private_value = "synthetic-secret-value" - - def fail(*args, **kwargs): - raise URLError(private_value) + login(http) + http.get(api.USERS_BASE_URL + "v1/users/current", json={"userId": "synthetic-user"}) + http.get(api.BFF_BASE_URL + "v5/users/synthetic-user/cars", json={"data": [{"vin": VIN}]}) + http.get(BATTERY_URL, json=battery(rangeHvacOn=123.4)) + http.get(COCKPIT_URL, json={"data": {"attributes": {"totalMileage": 12345.6}}}) + instance = api.NissanClient("synthetic@example.invalid", "synthetic-password") + assert instance.fetch_battery() == (47, 123.4, MEASUREMENT_UNIX_SECONDS, 12345.6) + calls = list(http.request_history) + authorize = parse_qs(urlsplit(calls[0].url).query) + verifier = parse_qs(calls[2].text)["code_verifier"][0] + challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode() + assert authorize["code_challenge"] == [challenge] + assert parse_qs(calls[1].text)["username"] == ["EU/synthetic@example.invalid"] + assert calls[3].headers["Authorization"] == "synthetic-id" + assert calls[-1].headers["Authorization"] == "Bearer synthetic-access" + assert all(call.timeout == api.REQUEST_TIMEOUT_SECONDS for call in calls) + assert all("/actions/" not in call.url for call in calls) + assert not any(record.name == "modules.common.req" for record in caplog.records) + instance.fetch_battery() + assert len(http.request_history) == len(calls) + 2 - monkeypatch.setattr(transport._opener, "open", fail) - with pytest.raises(NissanError) as error: - transport.request("GET", AUTH_BASE_URL) - formatted = "".join(traceback.format_exception(type(error.value), error.value, error.value.__traceback__)) - assert private_value not in formatted - assert private_value not in repr(reply(private_value, headers={"Authorization": private_value})) - assert not caplog.records - -def test_transport_returns_redirect_without_following(monkeypatch): - transport = PrivateTransport() - - def respond(*args, **kwargs): - raise HTTPError(AUTH_BASE_URL, 302, "redirect", {"Location": "/next"}, io.BytesIO(b"")) - - monkeypatch.setattr(transport._opener, "open", respond) - result = transport.request("GET", AUTH_BASE_URL) - assert result.status == 302 and result.headers["Location"] == "/next" +@pytest.mark.parametrize("soc", [0, 47.5, 100]) +def test_valid_zero_and_old_measurement(soc): + assert api.parse_battery(battery(soc, rangeHvacOn=0)) == (soc, 0, MEASUREMENT_UNIX_SECONDS) -def test_transport_limits_response_size(monkeypatch): - transport = PrivateTransport() +@pytest.mark.parametrize("soc", [None, True, -1, 101, "47", float("nan"), float("inf")]) +def test_invalid_soc_never_becomes_zero(soc): + with pytest.raises(api.NissanError): + api.parse_battery(battery(soc)) - def respond(*args, **kwargs): - raise HTTPError(AUTH_BASE_URL, 500, "error", {}, io.BytesIO(b"x" * (MAX_RESPONSE_BYTES + 1))) - monkeypatch.setattr(transport._opener, "open", respond) - with pytest.raises(NissanError, match="too large"): - transport.request("GET", AUTH_BASE_URL) +@pytest.mark.parametrize("timestamp", [None, "bad", "2022-01-01T00:00:00", "2099-01-01T00:00:00Z"]) +def test_invalid_timestamp_never_becomes_now(timestamp): + with pytest.raises(api.NissanError): + api.parse_battery(battery(timestamp=timestamp)) -def test_total_request_budget_stops_redirect_chain(): - client, transport, ticks_monotonic_seconds = make_client([]) +@pytest.mark.parametrize("odometer", [None, -1, True, "12345", float("inf")]) +def test_unavailable_optional_data_preserves_soc(http, odometer): + http.get(BATTERY_URL, json=battery(rangeHvacOn=-1)) + http.get(COCKPIT_URL, json={"data": {"attributes": {"totalMileage": odometer}}}) + assert client().fetch_battery() == (47, None, MEASUREMENT_UNIX_SECONDS, None) - def slow_response(calls): - ticks_monotonic_seconds[0] += 61 - return reply("", 302, {"Location": "/next"}) - transport.responses.append(slow_response) - with pytest.raises(NissanError, match="time limit"): - client.fetch_battery() - assert len(transport.calls) == 1 +def test_optional_endpoint_failure_preserves_soc(http): + http.get(BATTERY_URL, json=battery(rangeHvacOff=150)) + http.get(COCKPIT_URL, status_code=503) + assert client().fetch_battery() == (47, 150, MEASUREMENT_UNIX_SECONDS, None) -@pytest.mark.parametrize("response", [ - reply("synthetic-private-body", 500), reply("synthetic-private-body", 503), - reply("not-json"), reply(battery(soc=None)), -]) -def test_failed_query_pauses_without_reauthentication_and_recovers(response, caplog): - client, transport, ticks_monotonic_seconds = make_client(full_script() + [response, reply(battery(48))]) - client.fetch_battery() - with pytest.raises(NissanError): - client.fetch_battery() - count = len(transport.calls) - ticks_monotonic_seconds[0] = 299 - with pytest.raises(NissanError, match="paused") as error: - client.fetch_battery() - assert len(transport.calls) == count - assert "synthetic-private-body" not in str(error.value) - ticks_monotonic_seconds[0] = 300 - assert client.fetch_battery().soc_percent == 48 - assert len(transport.calls) == count + 1 - assert not caplog.records - - -def test_repeated_outages_back_off_to_one_hour_and_success_resets_delay(): - client, transport, ticks_monotonic_seconds = make_client(full_script()) - client.fetch_battery() - for delay_seconds in (300, 600, 1200, 2400, 3600, 3600): - transport.responses.append(reply("synthetic-private-body", 503)) - with pytest.raises(NissanError): - client.fetch_battery() - count = len(transport.calls) - ticks_monotonic_seconds[0] += delay_seconds - 1 - with pytest.raises(NissanError, match="paused"): - client.fetch_battery() - assert len(transport.calls) == count - ticks_monotonic_seconds[0] += 1 - # Keep the synthetic token alive; expiry/renewal is tested separately. - client._expires_at_monotonic_seconds = ticks_monotonic_seconds[0] + 7200 - transport.responses.extend([reply(battery(49)), reply("private", 503)]) - assert client.fetch_battery().soc_percent == 49 - with pytest.raises(NissanError): - client.fetch_battery() - ticks_monotonic_seconds[0] += 300 - transport.responses.append(reply(battery(50))) - assert client.fetch_battery().soc_percent == 50 +@pytest.mark.parametrize("status", [429, 503]) +def test_service_errors_raise_and_next_core_update_can_retry(http, status): + http.get(BATTERY_URL, [{"status_code": status, "text": "synthetic-private-body"}, {"json": battery()}]) + http.get(COCKPIT_URL, json={"data": {"attributes": {"totalMileage": 0}}}) + instance = client() + with pytest.raises(api.NissanError, match=f"HTTP {status}"): + instance.fetch_battery() + assert http.call_count == 1 + assert instance.fetch_battery()[-1] == 0 + + +@pytest.mark.parametrize("expired", [False, True]) +def test_authentication_refreshes_once(http, expired): + http.get(BATTERY_URL, [{"json": battery()}] if expired else [{"status_code": 401}, {"json": battery()}]) + http.get(COCKPIT_URL, json={}) + refresh = http.post(REFRESH_URL, json={"access_token": "synthetic-new"}) + instance = client() + if expired: + instance._expires_at_monotonic_seconds = 0 + assert instance.fetch_battery()[0] == 47 + assert refresh.call_count == 1 + assert http.last_request.headers["Authorization"] == "Bearer synthetic-new" + + +def test_rejected_refresh_uses_login_and_repeated_401_stops(http): + login(http) + http.get(BATTERY_URL, status_code=401) + refresh = http.post(REFRESH_URL, status_code=400) + with pytest.raises(api.AuthenticationError): + client().fetch_battery() + assert refresh.call_count == 1 + assert sum(request.url == BATTERY_URL for request in http.request_history) == 2 -@pytest.mark.parametrize("status", [429, 503]) -@pytest.mark.parametrize("header,expected_delay", [ - ("1200", 1200), ("Sun, 02 Jan 2022 00:20:00 GMT", 1200), - ("Sun, 02 Jan 2022 00:01:00 GMT", 300), ("-1", 300), ("bad-date", 300), +@pytest.mark.parametrize("target", [ + "https://example.invalid/steal", "http://login.mynissan-account.com/", + "https://user@login.mynissan-account.com/", "https://login.mynissan-account.com:444/"]) +@pytest.mark.parametrize("form", [False, True]) +def test_rejects_unsafe_login_destinations(http, target, form): + http.get(re.compile(re.escape(api.AUTH_BASE_URL)), + text=FORM.replace("/commonauth", target) if form else "", status_code=200 if form else 302, + headers={} if form else {"Location": target}) + with pytest.raises(api.NissanError, match="destination"): + api.NissanClient("synthetic@example.invalid", "synthetic-password").fetch_battery() + assert http.call_count == 1 + + +def test_oauth_state_mismatch_stops_before_token_exchange(http): + login(http) + http.post(api.AUTH_BASE_URL + "commonauth", status_code=302, + headers={"Location": api.OAUTH_CALLBACK_URI + "?code=synthetic-code&state=wrong"}) + with pytest.raises(api.AuthenticationError): + api.NissanClient("synthetic@example.invalid", "synthetic-password").fetch_battery() + assert http.call_count == 2 + + +@pytest.mark.parametrize("configured_vin,vehicles,expected", [ + ("", [VIN, OTHER_VIN], None), (OTHER_VIN, [VIN], None), ("", [], None), + (OTHER_VIN.lower(), [VIN, OTHER_VIN], OTHER_VIN), ("", [VIN], VIN)]) +def test_vehicle_selection(http, configured_vin, vehicles, expected): + http.get(api.USERS_BASE_URL + "v1/users/current", json={"userId": "synthetic-user"}) + http.get(api.BFF_BASE_URL + "v5/users/synthetic-user/cars", json={"data": [{"vin": vin} for vin in vehicles]}) + instance = client() + instance._configured_vin = configured_vin.upper() + if expected: + instance._select_vehicle() + assert instance._vin == expected + else: + with pytest.raises(api.NissanError): + instance._select_vehicle() + + +def test_transport_and_json_errors_omit_private_data(http): + http.get(BATTERY_URL, exc=requests.Timeout("synthetic-private-url")) + instance = client() + with pytest.raises(api.NissanError, match="^Nissan could not be reached.$"): + instance.fetch_battery() + http.get(BATTERY_URL, text="synthetic-private-body") + with pytest.raises(api.NissanError, match="^Nissan returned an invalid response.$"): + instance.fetch_battery() + + +def test_native_configuration_roundtrip_and_private_repr(): + from dataclass_utils import asdict, dataclass_from_dict + + config = NissanConnect(configuration=NissanConnectConfiguration("synthetic@example.invalid", "synthetic-password")) + decoded = dataclass_from_dict(NissanConnect, asdict(config)) + assert decoded == config + assert "synthetic" not in repr(config) + assert NissanConnect().configuration is not NissanConnect().configuration + + +@pytest.mark.parametrize("path", [ + "/car-adapter/v1/cars/" + VIN + "/battery-status", + "/bff-web/v5/users/synthetic-user/cars", + "/commonauth?code=synthetic-code&sessionDataKey=synthetic-session", ]) -def test_retry_after_seconds_and_http_date_are_respected(status, header, expected_delay): - client, transport, ticks_monotonic_seconds = make_client(full_script() + [ - reply("private", status, {"rEtRy-AfTeR": header}), reply(battery())]) - client.fetch_battery() - with pytest.raises(NissanError): - client.fetch_battery() - count = len(transport.calls) - ticks_monotonic_seconds[0] = expected_delay - 1 - with pytest.raises(NissanError, match="paused"): - client.fetch_battery() - assert len(transport.calls) == count - ticks_monotonic_seconds[0] = expected_delay - assert client.fetch_battery().soc_percent == 47 - - -def test_timeout_pauses_requests_without_retaining_exception_traceback(): - def timeout(calls): - raise NissanError("Nissan is unreachable or returned an invalid response.") - client, transport, ticks_monotonic_seconds = make_client(full_script() + [timeout, reply(battery())]) - client.fetch_battery() - with pytest.raises(NissanError): - client.fetch_battery() - count = len(transport.calls) - for _ in range(5): - with pytest.raises(NissanError, match="paused"): - client.fetch_battery() - assert len(transport.calls) == count - assert not any(isinstance(value, BaseException) for value in vars(client).values()) - ticks_monotonic_seconds[0] = 300 - assert client.fetch_battery().soc_percent == 47 - - -def test_unrepresentable_optional_range_preserves_valid_soc(): - result = parse_battery(battery(rangeHvacOn=10 ** 400), now_unix_seconds=MEASURED_UNIX_SECONDS) - assert (result.soc_percent, result.measurement_unix_seconds, result.range_km) == (47, MEASURED_UNIX_SECONDS, None) - - -def test_unrepresentable_token_lifetime_is_safe_and_pauses_retry(): - script = login_script() - script[-1] = reply({"access_token": "synthetic-private-access", "expires_in": 10 ** 400}) - client, transport, _ = make_client(script) - with pytest.raises(AuthenticationError, match="token lifetime") as error: - client.fetch_battery() - assert "synthetic-private-access" not in str(error.value) - assert client._access_token is None and client._refresh_token is None - count = len(transport.calls) - with pytest.raises(AuthenticationError, match="paused"): - client.fetch_battery() - assert len(transport.calls) == count - - -@pytest.mark.parametrize("during_form", [False, True]) -def test_malformed_login_destination_is_safe_and_pauses_retry(during_form): - private_url = "https://[synthetic-private-host" - response = reply(FORM.replace("/commonauth", private_url)) if during_form else reply( - "", 302, {"Location": private_url}) - client, transport, _ = make_client([response]) - with pytest.raises(NissanError) as error: - client.fetch_battery() - assert "synthetic-private-host" not in str(error.value) - with pytest.raises(NissanError, match="paused"): - client.fetch_battery() - assert len(transport.calls) == 1 - - -def test_rotated_refresh_token_is_used_on_next_expiry(): - client, transport, ticks_monotonic_seconds = make_client(full_script() + [ - reply({"access_token": "synthetic-next-access", "refresh_token": "synthetic-next-refresh"}), - reply(battery()), reply({"access_token": "synthetic-final-access"}), reply(battery())]) - client.fetch_battery() - ticks_monotonic_seconds[0] = 3600 - client.fetch_battery() - ticks_monotonic_seconds[0] = 7200 - client.fetch_battery() - assert transport.calls[-2][2]["headers"]["Authorization"] == "synthetic-next-refresh" - assert transport.calls[-1][2]["headers"]["Authorization"] == "Bearer synthetic-final-access" - - -def test_refresh_service_failure_does_not_fall_back_to_password_login(): - client, transport, ticks_monotonic_seconds = make_client(full_script() + [reply("synthetic-private-body", 503)]) - client.fetch_battery() - ticks_monotonic_seconds[0] = 3600 - with pytest.raises(NissanError): - client.fetch_battery() - assert len(transport.calls) == 8 - assert "refresh-token" in transport.calls[-1][1] - with pytest.raises(NissanError, match="paused"): - client.fetch_battery() - assert len(transport.calls) == 8 +@pytest.mark.parametrize("fail", [False, True]) +def test_transport_logs_hide_private_urls_without_muting_other_requests(monkeypatch, caplog, path, fail): + caplog.set_level(logging.DEBUG) + instance = client() + pool = HTTPSConnectionPool("example.invalid") + connection = Mock(is_closed=False, is_verified=True) + connection.getresponse.return_value = HTTPResponse(status=200, headers={}, preload_content=False) + monkeypatch.setattr(pool, "_validate_conn", lambda connection: None) + transport_log = logging.getLogger("urllib3.connectionpool") + + def request(*args, **kwargs): + # Real urllib3 log paths, but a fake connection: no socket or Nissan access. + pool._make_request(connection, "GET", path) + Retry(total=1).increment("GET", path) + with ThreadPoolExecutor(max_workers=1) as executor: + executor.submit(transport_log.debug, "Other vehicle request remains visible").result() + api.log.debug("Nissan diagnostic remains visible") + if fail: + raise requests.Timeout("synthetic-private-url") + return Mock(status_code=200) + + monkeypatch.setattr(instance._session, "request", request) + if fail: + with pytest.raises(api.NissanError, match="^Nissan could not be reached.$"): + instance._request("GET", "https://example.invalid" + path) + else: + assert instance._request("GET", "https://example.invalid" + path).status_code == 200 + transport_log.debug("Subsequent request remains visible") + assert path not in caplog.text + assert "synthetic-private-url" not in caplog.text + assert "Other vehicle request remains visible" in caplog.text + assert "Nissan diagnostic remains visible" in caplog.text + assert "Subsequent request remains visible" in caplog.text diff --git a/packages/modules/vehicles/nissanconnect/config.py b/packages/modules/vehicles/nissanconnect/config.py index ca50b25485..3c625227df 100644 --- a/packages/modules/vehicles/nissanconnect/config.py +++ b/packages/modules/vehicles/nissanconnect/config.py @@ -1,22 +1,21 @@ -"""Native openWB configuration; credentials follow its existing storage lifecycle.""" +from dataclasses import dataclass, field from typing import Optional +@dataclass class NissanConnectConfiguration: - """EU account credentials and optional VIN; never log or export this object.""" - - def __init__(self, user_id: Optional[str] = None, password: Optional[str] = None, - vin: Optional[str] = None): - self.user_id = user_id - self.password = password - self.vin = vin + user_id: Optional[str] = field(default=None, repr=False) + password: Optional[str] = field(default=None, repr=False) + vin: Optional[str] = field(default=None, repr=False) +@dataclass class NissanConnect: - """Descriptor defaults consumed by the standard module loader and settings UI.""" + name: str = "Nissan – MyNISSAN EU (experimental)" + type: str = "nissanconnect" + official: bool = False + configuration: NissanConnectConfiguration = None - def __init__(self, name: str = "Nissan – MyNISSAN EU (experimental)", - type: str = "nissanconnect", configuration: NissanConnectConfiguration = None): - self.name = name - self.type = type - self.configuration = configuration or NissanConnectConfiguration() + def __post_init__(self): + if self.configuration is None: + self.configuration = NissanConnectConfiguration() diff --git a/packages/modules/vehicles/nissanconnect/connection_test.py b/packages/modules/vehicles/nissanconnect/connection_test.py deleted file mode 100644 index 9965845aef..0000000000 --- a/packages/modules/vehicles/nissanconnect/connection_test.py +++ /dev/null @@ -1,73 +0,0 @@ -"""One passive settings test. Credentials arrive on stdin, never in argv or files.""" -import json -import logging -import os -import re -import sys - -if __package__: - from .src.api import AuthenticationError, NissanClient, NissanError, RateLimitError, VIN_PATTERN -else: - sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) - from src.api import AuthenticationError, NissanClient, NissanError, RateLimitError, VIN_PATTERN - - -# Python validates decoded strings in characters; PHP also limits HTTP bytes. -MAX_STDIN_CHARACTERS = 8192 -MAX_USER_ID_CHARACTERS = 320 -MAX_PASSWORD_CHARACTERS = 4096 -ALLOWED_INPUT_FIELDS = frozenset({"user_id", "password", "vin"}) -AGENT_ENVIRONMENT_VARIABLES = ("CODEX_THREAD_ID", "CODEX_SANDBOX", "CODEX_CI") - - -def run_test(payload, client_factory=NissanClient): - """Validate one unsaved test and return diagnostics or a fixed public error code. - - No MQTT/configuration writes. PHP owns cross-request locking and cooldowns; - this short-lived worker does not share scheduled-client tokens or backoff. - """ - if not isinstance(payload, dict) or set(payload) - ALLOWED_INPUT_FIELDS: - return {"success": False, "code": "invalid_input"} - user = payload.get("user_id") - password = payload.get("password") - vin = payload.get("vin") - if vin == "": - vin = None - if (not isinstance(user, str) or not user.strip() or len(user) > MAX_USER_ID_CHARACTERS - or not isinstance(password, str) or not password or len(password) > MAX_PASSWORD_CHARACTERS - or (vin is not None and (not isinstance(vin, str) - or not re.fullmatch(VIN_PATTERN, vin.strip().upper())))): - return {"success": False, "code": "invalid_input"} - client = None - try: - client = client_factory(user.strip(), password, vin.strip().upper() if vin else None) - client.fetch_battery(include_odometer=True) - return {"success": True, "code": "success", "query": client.last_query} - except AuthenticationError: - code = "authentication_failed" - except RateLimitError: - code = "rate_limited" - except NissanError: - code = "query_failed" - except Exception: - # Neither exception messages nor tracebacks belong in the web response. - return {"success": False, "code": "internal_error"} - return {"success": False, "code": code, "query": client.last_query if client else None} - - -def main(): - logging.disable(logging.CRITICAL) - if any(os.environ.get(name) for name in AGENT_ENVIRONMENT_VARIABLES): - result = {"success": False, "code": "agent_environment"} - else: - try: - body = sys.stdin.read(MAX_STDIN_CHARACTERS + 1) - result = run_test(json.loads(body)) if len(body) <= MAX_STDIN_CHARACTERS else { - "success": False, "code": "invalid_input"} - except Exception: - result = {"success": False, "code": "invalid_input"} - print(json.dumps(result, allow_nan=False)) - - -if __name__ == "__main__": - main() diff --git a/packages/modules/vehicles/nissanconnect/docs/diagnostics.md b/packages/modules/vehicles/nissanconnect/docs/diagnostics.md deleted file mode 100644 index 31f1961b31..0000000000 --- a/packages/modules/vehicles/nissanconnect/docs/diagnostics.md +++ /dev/null @@ -1,197 +0,0 @@ -# Query diagnostics, errors and connection tests - -## Requests, measurements and scheduling - -The module reads cached Nissan data; it sends no wakeup, refresh or charging -commands. Core calls the updater according to its ordinary vehicle settings -or a manual SoC refresh. A scheduled invocation can be deferred by client -backoff without making an HTTP request. - -In the inspected openWB 2.2.3 Core (`3ced7f9cef3c70980ee1fab670f0d0717d1809ca`): - -- Status displays `soc_timestamp`, set from Nissan's battery measurement time. - It need not change after a successful query and is independent of percentage changes. -- `soc_request_timestamp` records a Core updater attempt, not proof of an HTTP request. -- Switching modules does not reset that request timestamp or force an immediate - query. Normal scheduling applies the currently saved interval to it. -- Manual refresh bypasses the scheduler interval, but not the client's retry - delay or Core's measurement timestamp guard. -- Core rejects a measurement more than 60 seconds older than its stored value, - logging `Not updating SoC, because timestamp is older.` at DEBUG. A recent - manual value can therefore remain visible after successful Nissan retrieval. - -These source-level behaviors do not identify the cause of a particular private -trial. Later Core versions may differ; the module does not replace these policies. - -## Scheduled-query diagnostics - -Each updater invocation emits one JSON summary at DEBUG through the existing -SoC logger (`soc.log` when that context permits DEBUG). The standalone probe -and settings worker do not write these scheduled-query records. - -| Fields | Meaning | -| --- | --- | -| `outcome`, `phase` | Success, failure or deferred invocation; current query phase | -| `request_started_at` | First actual HTTP attempt in this invocation; null when deferred | -| `completed_at`, `duration_seconds` | Completion time and elapsed monotonic seconds | -| `http_requests`, `last_http_status` | HTTP attempts in this invocation (including login/token requests and failed attempts), and latest response status if available | -| `http_requests_last_24h` | Request attempts in the rolling preceding 24 hours, for this vehicle module instance | -| `http_requests_since_midnight` | Request attempts since 00:00 in the wallbox's local timezone, for this vehicle module instance | -| `http_requests_since_start` | Total request attempts since `http_requests_counting_since`, including attempts older than 24 hours | -| `http_requests_counting_since` | UTC timestamp at which the current counting period started | -| `http_requests_local_midnight` | Current day's local midnight, including its UTC offset | -| `http_requests_last_24h_complete`, `http_requests_since_midnight_complete` | Whether observation covers the entire respective window | -| `http_requests_counter_reset_reason` | Why the current observation period started: `module_started`, `clock_changed` or `capacity_limit` | -| `last_contact_at` | Latest actual HTTP attempt, including failures; retained during backoff | -| `last_success_at` | Latest successful battery query, retained after failure | -| `measurement_at`, `data_age_seconds` | Original battery measurement and its age at completion | -| `last_measurement_at` | Measurement time from the latest successful battery query | -| `soc`, `range_km` | Parsed battery percentage and optional range; null without valid battery data | -| `odometer_km`, `odometer_outcome` | Total kilometres when available and success/unavailable/failed/deferred/not_requested status | -| `odometer_http_status`, `odometer_retry_after_seconds` | Odometer endpoint status when available and remaining separate pause | -| `odometer_received_at` | Reception time of a valid odometer; its measurement time is unknown | -| `retry_after_seconds` | Remaining client pause | -| `provider_retry_after_seconds` | Remaining provider advice, excluding the client's own error backoff | -| `source` | `nissan_cached_api` | - -Times are ISO 8601 UTC, except `http_requests_local_midnight`, which includes -the wallbox-local UTC offset. A deferred invocation has zero HTTP requests and no new -request start. A small negative data age can indicate clock skew. `outcome=success` -means the client retrieved valid battery data, before Core decides whether to -accept the returned state. Odometer reception time never substitutes for battery -or odometer measurement time. - -Module summaries exclude usernames, passwords, VINs, tokens, cookies, URLs, raw -responses and transport exceptions. Vehicle values and times remain private; -see the separate [inherited logging limitation](privacy.md#logs-and-retention). - -## Rolling and daily request totals - -Search scheduled SoC DEBUG logs for `http_requests_last_24h` and -`http_requests_since_midnight`; `http_requests_since_start` gives the cumulative -total since the timestamp in `http_requests_counting_since`. This total keeps -growing across midnights and when requests expire from the rolling window. -The totals count each HTTP attempt immediately -before transport, including login, token renewal, vehicle selection, battery, -odometer and failed attempts. A paused invocation adds no request. An attempt is -not proof that the server received it. Totals are updated even while DEBUG is -disabled and are reported when the next updater invocation is logged. - -History belongs to one configured vehicle's module instance. Automatic updates -and the normal manual SoC refresh share it; a replacement API client within that -instance keeps the history. Other vehicles, the MyNISSAN app, private probes and -the separately executed settings connection tests are excluded. These are not -account-wide provider statistics; the settings test still shows only its own -`http_requests` count. - -Request timestamps and a cumulative count are retained in memory; no new file -or MQTT topic is created. Restarting or recreating the vehicle module resets -history and the cumulative count together with their start timestamp. The -`http_requests_counting_since` value identifies the observed period and each -`*_complete` flag remains false until its full window has been observed. A zero -with incomplete coverage is not a claim that no earlier requests occurred. - -The rolling window uses elapsed monotonic seconds and excludes events exactly -24 hours old. The daily window starts at local midnight, including on 23-hour -and 25-hour days. A system-clock correction exceeding five seconds or a backward -monotonic clock starts a new observation period. A 100,000-event memory bound -also resets coverage explicitly instead of silently dropping counted history. -Each reset restarts the cumulative count and its start timestamp together. -The reset reason remains visible. These diagnostics do not change polling, -backoff, charging control or Nissan rate limits. - -## Validation, retries and Core fallback - -SoC must be finite and within 0–100%; a genuine zero is valid. Battery time must -include a timezone, be positive and be no more than five minutes ahead of the -local clock. Invalid required fields fail; missing or invalid range/odometer -values remain unavailable rather than being fabricated or copied from an earlier -response. Their absence does not invalidate a valid battery result. - -The transport verifies HTTPS certificates, restricts destinations and redirects, -and limits each response to 1 MiB. Socket timeouts are at most ten seconds; -no new request starts after a 60-second query budget. This is not a hard deadline -for a socket operation already running. Tokens are reused in memory; changing -account configuration or restarting the module creates a new client. - -Failed battery queries use pauses of 5, 10, 20, 40 and then at most 60 minutes. -Success resets that sequence. HTTP 429/503 honor `Retry-After` in seconds or -HTTP-date form, bounded to 5 minutes through 24 hours for the scheduled client. -Deferred invocations make no HTTP request and do not extend the pause. Actual -retry time also depends on Core's polling interval. Authorization failure clears -rejected tokens; ordinary service/network failures retain authentication for reuse. - -After valid battery data, the module automatically attempts to read -`totalMileage` from the cockpit endpoint using the same token and request budget, -subject to retry pauses. Users do not need to enable a separate odometer option. -Only data availability is conditional: unavailable or invalid mileage and query -errors leave the successful battery result usable. Odometer endpoint errors pause -that endpoint for five minutes without forcing a second login. Provider rate-limit -advice can also defer subsequent requests. - -The existing Core wrapper may calculate SoC from metered charging energy after -failed or stale API data when its required inputs are available. This depends on -connection state, previous SoC, meter readings, battery capacity and efficiency; -the pinned fallback also treats a previous zero SoC as absent. If fallback fails, -normal Core fault handling applies. In the pinned version, three consecutive -failed updates lead to publication of zero before the next update; deferred -client calls can count as failed updates without being HTTP requests. Zero does -not mean unknown to the charge-limit logic, so an API failure does not guarantee -charging stops at the intended SoC target. The module neither suppresses errors -nor changes that policy, and exposes no separate `calculate_soc` setting. - -## Settings connection test - -**Test connection** uses current form values without saving them or publishing -results to MQTT/charging control. It displays selected battery/odometer values, -query times, original battery time and battery data age in the browser's timezone. -The result also shows **Nissan HTTP requests (this test)** from `http_requests`, -including authentication, vehicle selection, battery and odometer attempts. -Failed attempts count too; this is not proof that Nissan received every request. -The count is shown on success and failure when available. Missing or invalid -counts and interrupted responses show **Not available**, never an assumed zero. -This is a per-test count, not a persistent total across tests or scheduled queries. -Displaying it triggers no additional Nissan requests. - -Changing input clears the result. An in-flight result from the previous input -is discarded, while any resulting cooldown remains associated with its account. - -The settings build delivers both PHP files from `public/modules/vehicles/nissanconnect/`. -The endpoint URL is `/openWB/web/settings/modules/vehicles/nissanconnect/test_connection.php`; -it invokes Core's `packages/modules/vehicles/nissanconnect/connection_test.py`. -Production requires Linux, PHP 7.4+ with `proc_open` and Python 3.9+ at -`/usr/bin/python3`. UI-only installation cannot supply the Python worker. - -The endpoint accepts same-origin JSON POST and inherits web-server access control. -Origin checks are not authentication; reverse proxies must preserve the actual -scheme/host relationship. Credentials go through POST and worker stdin, never -command arguments. Responses use `no-store`, stderr is discarded and errors use -fixed codes. Run actual account tests privately; the agent-environment guard is -only a precaution. See [privacy](privacy.md) for storage and transport boundaries. - -Only one settings test runs at a time. Complete battery success, including zero -SoC, starts a five-minute account cooldown at completion. Account changes are -allowed; returning to an account retains its pause, even with changed password -or VIN. Ordinary failure or a busy response adds no account cooldown. Provider -advice is independent and applies across accounts when its scope is unknown: -429 without usable advice defaults to five minutes; valid 429/503 advice is -capped at 24 hours; 503 without usable advice adds no manual-test pause. - -The server stores account cooldown digests and expiry times in private temporary -state. It remains authoritative after a reload or across browser tabs. This -manual-test state is separate from scheduled-client authentication and backoff. -The PHP parent terminates a still-running worker after 70 seconds; the browser -times out after 75 seconds. Closing the page does not guarantee cancellation of -a server query already running. These limits are covered by synthetic tests; -they do not prove every target host's process behavior. - -## Color-theme refresh indicator - -In the inspected 2.2.3 Color theme, the pending indicator clears only when the -connected-vehicle SoC object is republished with changed contents. Cached data -or a rejected older measurement can leave it pending after the query finishes. -A change in percentage, time, range or fault data can trigger that publication; -the percentage alone is not decisive. The theme has no timeout for this flag. -Reloading resets the local indicator, not vehicle measurements. This separate -theme behavior is not worked around by inventing times or publishing chargepoint -state from the Nissan module. diff --git a/packages/modules/vehicles/nissanconnect/docs/privacy.md b/packages/modules/vehicles/nissanconnect/docs/privacy.md deleted file mode 100644 index ca08c815bf..0000000000 --- a/packages/modules/vehicles/nissanconnect/docs/privacy.md +++ /dev/null @@ -1,98 +0,0 @@ -# Privacy and diagnostic data - -See [Before using MyNISSAN EU](user-information.md) for the short user notice. -This technical description covers module behavior and source-reviewed openWB -data paths. It is not verification of an installation or a GDPR/BDSG compliance -assessment. Inherited Core observations refer to revision -`3ced7f9cef3c70980ee1fab670f0d0717d1809ca`. - -## Data paths - -| Data | Processing and storage | -| --- | --- | -| Entered username, password and optional VIN | Browser form and existing Vue configuration store; an unsaved test sends them to the same-origin PHP endpoint and then worker stdin. The component adds no local/session storage; parent state and browser autofill remain separate. | -| Saved account configuration | Ordinary Core configuration, including `openWB/vehicle//soc_module/config`, retained MQTT state and persistence, permitted subscribers and backups. No module-level encryption or expiry is added. | -| Login data, cookies and tokens | Python client memory and fixed Nissan/OneID/Kamereon HTTPS services in `src/api.py`. Changing configuration creates a new client; process exit releases references but does not promise secure memory erasure. | -| Selected account/vehicle identifiers | Client memory and authenticated API requests. They are excluded from module diagnostics; no response cache is written by the client. | -| SoC, battery time, range and odometer when available | Parsed results, native `CarState`, existing Core state/MQTT, display, applicable histories and backups. Core owns their lifecycle. | -| Query diagnostics and request totals | Client memory; scheduled DEBUG summary; selected fields returned by the settings test. Values and times can reveal vehicle activity even without identifiers. | -| Test cooldown metadata | Expiry times, a random HMAC key and keyed normalized-account digests in `openwb-nissanconnect-test/state` under the PHP temporary directory; account countdowns also exist in component memory. The digests are pseudonymous, not anonymous. | - -The private-terminal probe reads hidden input and prints results locally. It -does not use MQTT or the settings endpoint. Its terminal/agent checks cannot -prevent screen sharing, recording or host inspection. - -## Transport and access - -Nissan connections verify HTTPS certificates. Python `urllib` can inherit -environment/system proxies and trusts the host certificate configuration. -The browser-to-wallbox connection uses the settings page's HTTP/HTTPS transport; -password masking and same-origin POST do not encrypt HTTP. - -The endpoint has no separate authentication. Origin, content-type and Fetch -Metadata checks restrict browser cross-site submissions; a direct HTTP client -can supply those headers. Web-server/MQTT access controls remain installation -responsibilities. This module adds no developer analytics or model-provider -destination. Existing Cloud, MQTT bridges, remote access, proxies, monitoring and -backup services have their own recipients and retention. - -The inspected local broker bridge exports `soc_module/#` and `get/#` to the -public broker; access depends on its ACLs. An ordinary external status bridge -can export `openWB/vehicle/#`, including configuration. The Cloud generator uses -a different control path. This source review establishes neither that all -credentials are uploaded to Cloud nor that they always remain local. - -## Logs and retention - -Module diagnostics exclude credentials, identifiers, tokens, URLs and raw -responses. The settings worker disables logging; PHP discards worker stderr, -returns fixed errors and uses `Cache-Control: no-store`. Server/proxy logging of -request bodies is outside module control. - -Request totals retain request timestamps and a cumulative count in the native -vehicle module's memory, with a bounded event count. Restart or module recreation -discards that history. No additional state file or MQTT topic is introduced. -Logged totals and their coverage times follow the same private-log lifecycle as -the other query diagnostics; they can reveal query activity. Separate settings -tests do not read or combine the native module's request history. - -**Inherited Core debug-log redaction is incomplete.** At the inspected version, -shared MQTT handlers log payloads, the central filter does not mask `user_id` or -`vin` by default, and an escaped quotation mark in a JSON password can leave a -suffix visible. This was reproduced with synthetic input, not a real account. -The module's own safe summaries and three UI console-payload removals do not -fix shared backend logging. Keep logs private and use detailed logging for -bounded diagnosis; do not weaken a password to accommodate a log parser. - -Log rotation is size/event based, not a guaranteed deletion deadline. Reducing -the log level does not remove existing records, exports or backups. Treat their -sharing as a separate disclosure; do not submit private logs to issues or cloud -assistants. - -The settings test does not save account settings or vehicle responses to disk. -Its temporary state contains a local key, account digests and expiry times. -Expired accounts are pruned on load and persisted on the next successful write; -an early return can leave expired records on disk. No timer guarantees deletion -after five minutes. Provider pauses are capped at 24 hours; state is bounded to -1024 active account entries and 128 KiB. - -The endpoint checks Linux UID, a trusted temporary parent, a real 0700 directory -and regular 0600 state file owned by that UID. It rejects unsafe modes, symlinks, -multiple hard links and descriptor/path mismatches without silently repairing -or deleting them. Root and other code using the same UID remain outside this -boundary. Linux filesystem and synthetic HTTP checks have passed; that does not -establish permissions or isolation on every installation. - -## Backups and removal - -Normal openWB backups can include configuration, MQTT databases, broker settings, -logs and histories. In the inspected backup script, encryption depends on a -configured key file; compression and checksums alone provide no confidentiality. -No actual user's backup encryption or remote retention was inspected. - -Removing module files does not remove saved credentials. Replacing the active -configuration does not erase old logs, SD images, database snapshots, exported -settings, password-manager copies or previously shared data. Restoring a backup -can restore old credentials. Protect retained recovery copies and account for -their lifecycle separately. Battery and odometer values share these existing -storage/access boundaries; the odometer has no known measurement timestamp. diff --git a/packages/modules/vehicles/nissanconnect/docs/source-map.md b/packages/modules/vehicles/nissanconnect/docs/source-map.md deleted file mode 100644 index 08d5181912..0000000000 --- a/packages/modules/vehicles/nissanconnect/docs/source-map.md +++ /dev/null @@ -1,68 +0,0 @@ -# Nissan module source map - -This map describes the implemented source layout. Paths in the tree are relative -to the Core repository root. Production code, synthetic tests, manual tools and -documentation have separate directories; native integration entry points remain -at the module root. This is not a deployment inventory. - -## Directory and file responsibilities - -```text -packages/modules/vehicles/nissanconnect/ -├── __init__.py # Native Python package marker -├── config.py # openWB configuration and defaults -├── soc.py # openWB adapter: client measurements to CarState -├── connection_test.py # Production stdin worker used by the PHP endpoint -├── README.md # Introduction, configuration and test commands -├── NOTICE.md # Source attribution and license notices -├── src/ -│ ├── __init__.py # Internal implementation package -│ └── api.py # Authentication, transport, retries and in-memory request counters -├── tests/ -│ ├── __init__.py # Offline-test package -│ ├── conftest.py # Automatic network denial -│ ├── helpers.py # Shared synthetic responses, transport and clocks -│ ├── api_test.py # Authentication, validation and retry tests -│ ├── diagnostics_test.py # Query counters, calendar boundaries and stdin-worker contracts -│ ├── odometer_test.py # Odometer parsing, availability and query tests -│ ├── probe_test.py # Terminal guards and both isolated script entry points -│ └── soc_test.py # Native adapter and configuration-change tests -├── tools/ -│ ├── __init__.py # Optional manual-tool package -│ └── probe.py # Query in a private local terminal -└── docs/ - ├── source-map.md # This map and integration boundaries - ├── user-information.md # Short service-access and privacy notice - ├── diagnostics.md # Logs, implemented error behavior and connection tests - └── privacy.md # Data flows, storage and privacy limits -``` - -## Integration boundaries - -- `soc.py` remains `modules.vehicles.nissanconnect.soc`, the vehicle type's - dynamic Core entry point. Scheduling and charge policy remain Core-owned. -- `config.py` retains the existing configuration import convention. -- `connection_test.py` is a production stdin worker despite its filename. The - settings PHP endpoint calls this exact module-root path with isolated Python. - It loads the internal `src.api` implementation without importing Core services. -- `tools/probe.py` supports both a package import and direct isolated execution. - Its synthetic startup test cannot create a real API client. -- Tests import shared fixtures from `tests/helpers.py`, never another test file. - `tests/conftest.py` denies network access within this test package. -- Timeout, size and dimensional constants live beside their implementation; - names distinguish seconds, Unix seconds, milliseconds, bytes and characters. - -## Companion repository and reading guide - -`openWB/openwb-ui-settings` owns the Vue form and both PHP endpoint sources, -including their UI/PHP tests. Its overview is `docs/nissanconnect-source-map.md`. -Core owns the Python client, native adapter and production stdin worker. - -Start with [README.md](../README.md) for setup and executable test commands, -[user-information.md](user-information.md) for the short user notice, and -[diagnostics.md](diagnostics.md) for the query/connection-test contract. -README owns compatibility and setup guidance; diagnostics owns implemented -query/error behavior. Detailed project research is not part of the module docs. - -Keep this map, imports, test commands and companion paths synchronized with -source moves. Source layout changes do not update an installed wallbox. diff --git a/packages/modules/vehicles/nissanconnect/docs/user-information.md b/packages/modules/vehicles/nissanconnect/docs/user-information.md deleted file mode 100644 index 71318cb31e..0000000000 --- a/packages/modules/vehicles/nissanconnect/docs/user-information.md +++ /dev/null @@ -1,34 +0,0 @@ -# Before using MyNISSAN EU - -This experimental integration has practical test reports for the **Leaf ZE1** -in Europe. Other Nissan models are unverified. It reads data already stored by -Nissan and does not wake the vehicle; a successful request can return old data. - -**Service access.** This is an unofficial client, not a Nissan-approved -integration. Specific permission for this app-API access has not been -established. Nissan's account and service terms are separate from this code's -open-source license; the experimental label does not resolve that question. -Login or API changes can interrupt operation. Consult the terms applicable -to your account; [Nissan's terms index](https://www.nissan.de/rechtliches/agb.html) -provides the published German service terms. - -**Account data.** The browser sends entered details to your wallbox; it contacts -the Nissan login/data services over HTTPS. Saving settings stores credentials -in ordinary openWB configuration, which can be present in MQTT and backups, -without encryption added by this module. Password masking does not protect -storage, and an HTTP settings page does not encrypt browser-to-wallbox traffic. -The connection test itself does not save account settings or update charging. - -**Logs and sharing.** Detailed SoC logs contain vehicle values and times. -The reviewed Core debug-log filtering can also leave account identifiers or -password fragments visible. Keep settings, backups and logs private; do not -attach them to public issues or send them to cloud assistants. Use detailed -logging only for a bounded diagnostic session. - -**Other data paths and removal.** This module adds no developer analytics. -Existing Cloud, MQTT bridges, remote access and backup services have separate -data paths; their effective recipients and retention were not verified here. -Removing the module does not erase saved settings or old logs/backups, and -restoring a backup can restore old credentials. Keep needed recovery backups -protected. See [privacy and diagnostic data](privacy.md) for the reviewed paths -and limits; this information is not a GDPR compliance certification. diff --git a/packages/modules/vehicles/nissanconnect/soc.py b/packages/modules/vehicles/nissanconnect/soc.py index 2cf0c76a43..e505b59c1a 100644 --- a/packages/modules/vehicles/nissanconnect/soc.py +++ b/packages/modules/vehicles/nissanconnect/soc.py @@ -1,42 +1,25 @@ -"""Adapt Nissan measurements to the existing openWB vehicle wrapper and scheduler.""" -import json -import logging - from modules.common.abstract_device import DeviceDescriptor from modules.common.abstract_vehicle import VehicleUpdateData from modules.common.component_state import CarState from modules.common.configurable_vehicle import ConfigurableVehicle -from modules.vehicles.nissanconnect.src.api import NissanClient, RequestCounter +from modules.vehicles.nissanconnect.api import NissanClient from modules.vehicles.nissanconnect.config import NissanConnect -log = logging.getLogger(__name__) - - def create_vehicle(vehicle_config: NissanConnect, vehicle: int): - """Keep one client per configuration; delegate scheduling/error policy to Core.""" client = None configuration_key = None - # Per configured vehicle, including client replacement after edited credentials. - # Restart/recreation discards history; no file, MQTT topic or Core state is added. - request_counter = RequestCounter() def updater(vehicle_update_data: VehicleUpdateData) -> CarState: nonlocal client, configuration_key config = vehicle_config.configuration current = (config.user_id, config.password, config.vin) - # Keep tokens between updates; discard them when the account/vehicle changes. + # Reuse tokens until credentials or vehicle selection change. if client is None or current != configuration_key: - client = NissanClient(*current, request_counter=request_counter) + client = NissanClient(*current) configuration_key = current - try: - battery = client.fetch_battery(include_odometer=True) - finally: - # No account identifiers or secrets; battery values/times are still private. - if isinstance(client.last_query, dict): - log.debug("Nissan vehicle %s query: %s", vehicle, json.dumps(client.last_query, sort_keys=True)) - return CarState(soc=battery.soc_percent, range=battery.range_km, soc_timestamp=battery.measurement_unix_seconds, - odometer=battery.odometer_km) + soc_percent, range_km, measurement_unix_seconds, odometer_km = client.fetch_battery() + return CarState(soc=soc_percent, range=range_km, soc_timestamp=measurement_unix_seconds, odometer=odometer_km) return ConfigurableVehicle(vehicle_config=vehicle_config, component_updater=updater, vehicle=vehicle, calc_while_charging=False) diff --git a/packages/modules/vehicles/nissanconnect/src/__init__.py b/packages/modules/vehicles/nissanconnect/src/__init__.py deleted file mode 100644 index a40af5b46f..0000000000 --- a/packages/modules/vehicles/nissanconnect/src/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Internal Nissan API implementation.""" diff --git a/packages/modules/vehicles/nissanconnect/tests/__init__.py b/packages/modules/vehicles/nissanconnect/tests/__init__.py deleted file mode 100644 index 3bae1c5b67..0000000000 --- a/packages/modules/vehicles/nissanconnect/tests/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Synthetic offline tests for the Nissan module.""" diff --git a/packages/modules/vehicles/nissanconnect/tests/conftest.py b/packages/modules/vehicles/nissanconnect/tests/conftest.py deleted file mode 100644 index 9fabfcf74a..0000000000 --- a/packages/modules/vehicles/nissanconnect/tests/conftest.py +++ /dev/null @@ -1,13 +0,0 @@ -"""Tests in this module must never contact a vehicle, cloud API or MQTT broker.""" -import socket - -import pytest - - -@pytest.fixture(autouse=True) -def no_network(monkeypatch): - def denied(*args, **kwargs): - raise AssertionError("Network access is forbidden in NissanConnect offline tests") - monkeypatch.setattr(socket.socket, "connect", denied) - monkeypatch.setattr(socket, "create_connection", denied) - monkeypatch.setattr(socket, "getaddrinfo", denied) diff --git a/packages/modules/vehicles/nissanconnect/tests/diagnostics_test.py b/packages/modules/vehicles/nissanconnect/tests/diagnostics_test.py deleted file mode 100644 index a96ee74ec8..0000000000 --- a/packages/modules/vehicles/nissanconnect/tests/diagnostics_test.py +++ /dev/null @@ -1,261 +0,0 @@ -"""Synthetic query diagnostics and isolated settings-worker contracts.""" -from datetime import datetime -import json -import time -from unittest.mock import Mock - -import pytest - -from modules.vehicles.nissanconnect.src.api import AuthenticationError, NissanError, RequestCounter -from modules.vehicles.nissanconnect.tests.helpers import MEASURED_UNIX_SECONDS, VIN, full_script, make_client, reply -from modules.vehicles.nissanconnect.connection_test import run_test - - -def test_diagnostics_distinguish_contact_measurement_failure_and_deferred_update(): - client, transport, ticks_monotonic_seconds = make_client(full_script() + [reply('synthetic-private-body', 503)]) - client._unix_seconds = lambda: MEASURED_UNIX_SECONDS + 86400 + ticks_monotonic_seconds[0] - client.fetch_battery() - success = client.last_query.copy() - assert success['outcome'] == 'success' - assert success['http_requests'] == 7 - assert success['measurement_at'] == '2022-01-01T00:00:00+00:00' - assert success['request_started_at'] == '2022-01-02T00:00:00+00:00' - assert success['data_age_seconds'] == 86400 - assert (success['soc'], success['range_km']) == (47, 123.4) - ticks_monotonic_seconds[0] = 10 - with pytest.raises(NissanError): - client.fetch_battery() - failure = client.last_query.copy() - assert failure['outcome'] == 'failed' - assert failure['last_http_status'] == 503 - assert failure['soc'] is None and failure['measurement_at'] is None - assert failure['last_success_at'] == success['completed_at'] - assert failure['last_measurement_at'] == success['measurement_at'] - ticks_monotonic_seconds[0] = 20 - requests = len(transport.calls) - with pytest.raises(NissanError): - client.fetch_battery() - deferred = client.last_query - assert deferred['outcome'] == 'deferred' - assert deferred['http_requests'] == 0 and deferred['request_started_at'] is None - assert deferred['last_contact_at'] == failure['last_contact_at'] - assert deferred['last_success_at'] == success['completed_at'] - assert len(transport.calls) == requests - for value in ('synthetic-private-body', 'synthetic-password', VIN, 'synthetic-access', 'example.invalid'): - assert value not in json.dumps([success, failure, deferred]) - - -def test_worker_returns_only_query_result_and_does_not_log(caplog): - client, transport, _ = make_client(full_script() + [reply({"data": {"attributes": {"totalMileage": 12345.6}}})]) - result = run_test({'user_id': 'synthetic@example.invalid', 'password': 'synthetic-password'}, - client_factory=lambda *args: client) - assert result['success'] is True - assert result['query']['soc'] == 47 - assert result['query']['data_age_seconds'] == 86400 - assert len(transport.calls) == 8 - assert result["query"]["odometer_km"] == 12345.6 - assert not caplog.records - for value in ('synthetic-password', 'example.invalid', VIN, 'synthetic-access'): - assert value not in json.dumps(result) - - -@pytest.mark.parametrize('payload', [ - None, [], {}, {'user_id': 'a', 'password': 'p', 'url': 'https://invalid/'}, - {'user_id': 'a', 'password': 'p', 'vin': []}, - {'user_id': 'a', 'password': 'p', 'vin': 'wrong'}, - {'user_id': 'a', 'password': 'p' * 4097}]) -def test_worker_rejects_invalid_input_without_creating_client(payload): - factory = Mock() - assert run_test(payload, factory) == {'success': False, 'code': 'invalid_input'} - factory.assert_not_called() - - -@pytest.mark.parametrize('error,code', [ - (AuthenticationError('synthetic-private'), 'authentication_failed'), - (NissanError('synthetic-private'), 'query_failed'), - (RuntimeError('synthetic-private'), 'internal_error')]) -def test_worker_never_echoes_error_details(error, code): - client = Mock(last_query=None) - client.fetch_battery.side_effect = error - result = run_test({'user_id': 'a', 'password': 'p'}, lambda *args: client) - assert result['success'] is False and result['code'] == code - assert 'synthetic-private' not in json.dumps(result) - - -@pytest.mark.parametrize('status,headers,expected', [ - (401, {}, 0), (503, {}, 0), (503, {'Retry-After': 'bad'}, 0), - (503, {'Retry-After': '60'}, 60), (429, {}, 300), (429, {'Retry-After': '900'}, 900)]) -def test_provider_advice_is_separate_from_scheduled_error_backoff(status, headers, expected): - client, _, _ = make_client([reply({}, status, headers)]) - with pytest.raises(NissanError): - client.fetch_battery() - assert client.last_query['provider_retry_after_seconds'] == expected - assert client.last_query['retry_after_seconds'] == max(300, expected) - - -def test_login_success_followed_by_battery_failure_is_not_a_successful_test(): - client, _, _ = make_client(full_script()[:-1] + [reply({}, 503)]) - result = run_test({'user_id': 'synthetic@example.invalid', 'password': 'synthetic-password'}, - client_factory=lambda *args: client) - assert result['success'] is False - assert result['query']['phase'] == 'battery' - assert result['query']['provider_retry_after_seconds'] == 0 - - -def test_request_totals_separate_rolling_window_from_local_day(): - start_unix_seconds = datetime(2022, 1, 1, 23, 59, 50).timestamp() - counter = RequestCounter() - counter.record(0, start_unix_seconds) - counter.record(20, start_unix_seconds + 20) - after_midnight = counter.snapshot(20, start_unix_seconds + 20) - assert after_midnight["http_requests_last_24h"] == 2 - assert after_midnight["http_requests_since_start"] == 2 - assert after_midnight["http_requests_since_midnight"] == 1 - assert after_midnight["http_requests_last_24h_complete"] is False - assert after_midnight["http_requests_since_midnight_complete"] is True - - # Advance across another midnight without sending a request. The second - # event remains inside the rolling window but belongs to the previous day. - next_day = counter.snapshot(86410, start_unix_seconds + 86410) - assert next_day["http_requests_last_24h"] == 1 - assert next_day["http_requests_since_start"] == 2 - assert next_day["http_requests_since_midnight"] == 0 - assert next_day["http_requests_last_24h_complete"] is True - expired = counter.snapshot(86420, start_unix_seconds + 86420) - assert expired["http_requests_last_24h"] == 0 - assert len(counter._events) == 0 - assert expired["http_requests_since_start"] == 2 - assert expired["http_requests_counting_since"] == after_midnight["http_requests_counting_since"] - - # Expiring all timestamp events must not reset the cumulative count. - counter.record(86421, start_unix_seconds + 86421) - resumed = counter.snapshot(86421, start_unix_seconds + 86421) - assert resumed["http_requests_last_24h"] == 1 - assert resumed["http_requests_since_start"] == 3 - assert resumed["http_requests_counting_since"] == after_midnight["http_requests_counting_since"] - - -def test_request_totals_report_partial_coverage_and_reset_on_recreation(): - now_unix_seconds = datetime(2022, 1, 2, 12).timestamp() - counter = RequestCounter() - counter.record(0, now_unix_seconds) - current = counter.snapshot(1, now_unix_seconds + 1) - assert current["http_requests_last_24h"] == 1 - assert current["http_requests_since_start"] == 1 - assert current["http_requests_since_midnight"] == 1 - assert current["http_requests_last_24h_complete"] is False - assert current["http_requests_since_midnight_complete"] is False - assert datetime.fromisoformat(current["http_requests_counting_since"]).timestamp() == now_unix_seconds - recreated = RequestCounter().snapshot(2, now_unix_seconds + 2) - assert recreated["http_requests_last_24h"] == 0 - assert recreated["http_requests_since_start"] == 0 - assert recreated["http_requests_since_midnight"] == 0 - assert recreated["http_requests_counter_reset_reason"] == "module_started" - assert recreated["http_requests_counting_since"] != current["http_requests_counting_since"] - - -@pytest.mark.parametrize("jump_seconds", [-3600, 3600]) -def test_request_totals_restart_coverage_after_clock_correction(jump_seconds): - start_unix_seconds = datetime(2022, 1, 2, 12).timestamp() - counter = RequestCounter() - counter.record(0, start_unix_seconds) - current = counter.snapshot(10, start_unix_seconds + 10 + jump_seconds) - assert current["http_requests_last_24h"] == 0 - assert current["http_requests_since_start"] == 0 - assert datetime.fromisoformat(current["http_requests_counting_since"]).timestamp() == ( - start_unix_seconds + 10 + jump_seconds) - assert current["http_requests_since_midnight"] == 0 - assert current["http_requests_last_24h_complete"] is False - assert current["http_requests_since_midnight_complete"] is False - assert current["http_requests_counter_reset_reason"] == "clock_changed" - - -def test_request_totals_bound_memory_without_claiming_complete_history(monkeypatch): - from modules.vehicles.nissanconnect.src import api - - monkeypatch.setattr(api, "MAX_REQUEST_COUNTER_EVENTS", 2) - start_unix_seconds = datetime(2022, 1, 2, 12).timestamp() - counter = RequestCounter() - for offset_seconds in range(3): - counter.record(offset_seconds, start_unix_seconds + offset_seconds) - current = counter.snapshot(2, start_unix_seconds + 2) - assert current["http_requests_last_24h"] == 1 - assert current["http_requests_since_start"] == 1 - assert current["http_requests_counter_reset_reason"] == "capacity_limit" - assert datetime.fromisoformat(current["http_requests_counting_since"]).timestamp() == start_unix_seconds + 2 - assert current["http_requests_last_24h_complete"] is False - assert current["http_requests_since_midnight_complete"] is False - - -@pytest.mark.skipif(not hasattr(time, "tzset"), reason="System timezone switching requires POSIX") -@pytest.mark.parametrize("midnight_iso,event_iso,now_iso,rolling_count,day_count,midnight_offset", [ - ("2026-10-24T22:00:00+00:00", "2026-10-24T22:15:00+00:00", "2026-10-25T22:45:00+00:00", 0, 1, "+02:00"), - ("2026-03-28T23:00:00+00:00", "2026-03-28T23:15:00+00:00", "2026-03-29T22:00:00+00:00", 1, 0, "+02:00"), -]) -def test_request_totals_respect_short_and_long_local_days( - monkeypatch, midnight_iso, event_iso, now_iso, rolling_count, day_count, midnight_offset): - try: - with monkeypatch.context() as context: - context.setenv("TZ", "Europe/Berlin") - time.tzset() - start_unix_seconds = datetime.fromisoformat(midnight_iso).timestamp() - 3600 - event_unix_seconds = datetime.fromisoformat(event_iso).timestamp() - now_unix_seconds = datetime.fromisoformat(now_iso).timestamp() - counter = RequestCounter() - counter.observe(0, start_unix_seconds) - counter.record(event_unix_seconds - start_unix_seconds, event_unix_seconds) - current = counter.snapshot(now_unix_seconds - start_unix_seconds, now_unix_seconds) - assert current["http_requests_last_24h"] == rolling_count - assert current["http_requests_since_start"] == 1 - assert current["http_requests_since_midnight"] == day_count - assert current["http_requests_since_midnight_complete"] is True - assert current["http_requests_local_midnight"].endswith("T00:00:00" + midnight_offset) - finally: - time.tzset() - - -def test_request_totals_count_login_odometer_failures_and_zero_request_backoff(): - client, transport, ticks = make_client(full_script() + [ - reply({"data": {"attributes": {"totalMileage": 12345.6}}}), reply({}, 503)]) - client._unix_seconds = lambda: MEASURED_UNIX_SECONDS + 86400 + ticks[0] - client._request_counter = RequestCounter() - client.fetch_battery(include_odometer=True) - assert client.last_query["http_requests"] == 8 - assert client.last_query["http_requests_last_24h"] == 8 - assert client.last_query["http_requests_since_start"] == 8 - ticks[0] = 10 - with pytest.raises(NissanError): - client.fetch_battery(include_odometer=True) - assert client.last_query["http_requests"] == 1 - assert client.last_query["http_requests_last_24h"] == 9 - assert client.last_query["http_requests_since_start"] == 9 - ticks[0] = 20 - with pytest.raises(NissanError): - client.fetch_battery(include_odometer=True) - assert client.last_query["http_requests"] == 0 - assert client.last_query["http_requests_last_24h"] == 9 - assert client.last_query["http_requests_since_start"] == 9 - assert len(transport.calls) == 9 - - -def test_request_totals_include_transport_failure_without_a_response(): - def fail_transport(calls): - raise NissanError("Synthetic transport failure") - - client, _, _ = make_client([fail_transport]) - client._request_counter = RequestCounter() - with pytest.raises(NissanError): - client.fetch_battery() - assert client.last_query["last_http_status"] is None - assert client.last_query["http_requests_last_24h"] == 1 - assert client.last_query["http_requests_since_start"] == 1 - - -def test_separate_connection_test_has_no_native_vehicle_history(): - client, _, _ = make_client(full_script() + [reply({"data": {"attributes": {"totalMileage": 0}}})]) - result = run_test({'user_id': 'synthetic@example.invalid', 'password': 'synthetic-password'}, - client_factory=lambda *args: client) - assert result["success"] is True - assert result["query"]["http_requests"] == 8 - assert "http_requests_last_24h" not in result["query"] - assert "http_requests_since_start" not in result["query"] diff --git a/packages/modules/vehicles/nissanconnect/tests/helpers.py b/packages/modules/vehicles/nissanconnect/tests/helpers.py deleted file mode 100644 index 8f11ab5ff5..0000000000 --- a/packages/modules/vehicles/nissanconnect/tests/helpers.py +++ /dev/null @@ -1,60 +0,0 @@ -"""Shared synthetic responses, transport and clocks; no account captures.""" -import json -from urllib.parse import parse_qs, urlsplit - -from modules.vehicles.nissanconnect.src.api import HttpResponse, NissanClient - - -# Deliberately invented test values, unrelated to any account or vehicle. -VIN = "SJNFAAZE1U0000001" -SECOND_VIN = "SJNFAAZE1U0000002" -MEASURED_UNIX_SECONDS = 1640995200.0 -FORM = ('
' - '
') - - -def reply(body, status=200, headers=None): - return HttpResponse(status, headers or {}, body if isinstance(body, str) else json.dumps(body)) - - -def battery(soc=47, timestamp="2022-01-01T00:00:00Z", **extra): - return {"data": {"attributes": dict(batteryLevel=soc, lastUpdateTime=timestamp, **extra)}} - - -class ScriptedTransport: - def __init__(self, responses): - self.responses = list(responses) - self.calls = [] - self.cookies_cleared = 0 - - def clear_cookies(self): - self.cookies_cleared += 1 - - def request(self, method, url, **kwargs): - self.calls.append((method, url, kwargs)) - assert self.responses, "Unexpected extra HTTP request" - response = self.responses.pop(0) - return response(self.calls) if callable(response) else response - - -def login_script(): - def authorize(calls): - state = parse_qs(urlsplit(calls[-2][1]).query)["state"][0] - return reply("", 302, {"Location": "com://wso2.service.nci?code=synthetic-code&state=" + state}) - return [reply(FORM), authorize, reply({"id_token": "synthetic-id"}), - reply({"access_token": "synthetic-access", "refresh_token": "synthetic-refresh", "expires_in": 3600})] - - -def make_client(responses, vin=None): - transport = ScriptedTransport(responses) - ticks_monotonic_seconds = [0.0] - client = NissanClient("synthetic@example.invalid", "synthetic-password", vin, transport, - monotonic_seconds=lambda: ticks_monotonic_seconds[0], - unix_seconds=lambda: MEASURED_UNIX_SECONDS + 86400) - return client, transport, ticks_monotonic_seconds - - -def full_script(vehicles=None): - return login_script() + [reply({"userId": "synthetic-user"}), - reply({"data": [{"vin": VIN}] if vehicles is None else vehicles}), - reply(battery(rangeHvacOn=123.4))] diff --git a/packages/modules/vehicles/nissanconnect/tests/odometer_test.py b/packages/modules/vehicles/nissanconnect/tests/odometer_test.py deleted file mode 100644 index 5b470129a4..0000000000 --- a/packages/modules/vehicles/nissanconnect/tests/odometer_test.py +++ /dev/null @@ -1,146 +0,0 @@ -"""Optional odometer contracts with invented values and a blocked network.""" -import json - -import pytest - -from modules.vehicles.nissanconnect.src.api import CARS_BASE_URL, NissanError, RateLimitError, parse_odometer -from modules.vehicles.nissanconnect.tests.helpers import VIN, battery, full_script, make_client, reply -from modules.vehicles.nissanconnect.connection_test import run_test - - -def cockpit(value=12345.6, **extra): - return {"data": {"attributes": dict(totalMileage=value, **extra)}} - - -@pytest.mark.parametrize("value", [0, 12345, 12345.6]) -def test_only_total_mileage_is_used_in_kilometres(value): - assert parse_odometer(cockpit(value, mileage=99, rangeHvacOn=321)) == value - - -@pytest.mark.parametrize("value", [None, True, False, -1, "12345", [], {}, float("nan"), float("inf"), 10 ** 400]) -def test_invalid_odometer_is_absent_instead_of_zero(value): - assert parse_odometer(cockpit(value)) is None - - -@pytest.mark.parametrize("body", [None, [], {}, {"data": None}, {"data": {"attributes": []}}, - {"data": {"attributes": {"mileage": 12345}}}]) -def test_missing_odometer_and_malformed_shapes_are_optional(body): - assert parse_odometer(body) is None - - -def test_passive_query_preserves_battery_time_and_does_not_reuse_old_odometer(): - client, transport, ticks_monotonic_seconds = make_client( - full_script() + [reply(cockpit()), reply(battery()), reply(cockpit(None))]) - first = client.fetch_battery(include_odometer=True) - assert first.odometer_km == 12345.6 - assert first.measurement_unix_seconds == 1640995200 - assert transport.calls[-1][:2] == ("GET", CARS_BASE_URL + "v1/cars/" + VIN + "/cockpit") - assert transport.calls[-1][2]["headers"]["Authorization"] == "Bearer synthetic-access" - assert client.last_query["odometer_outcome"] == "success" - assert client.last_query["odometer_http_status"] == 200 - assert client.last_query["odometer_received_at"] == "2022-01-02T00:00:00+00:00" - assert client.last_query["measurement_at"] == "2022-01-01T00:00:00+00:00" - ticks_monotonic_seconds[0] = 10 - second = client.fetch_battery(include_odometer=True) - assert second.soc_percent == 47 and second.odometer_km is None - assert client.last_query["odometer_outcome"] == "unavailable" - assert client.last_query["odometer_received_at"] is None - assert all("/actions/" not in url for _, url, _ in transport.calls) - assert not transport.responses - - -@pytest.mark.parametrize("status", [401, 403, 404, 500, 503]) -def test_optional_http_error_keeps_soc_and_does_not_repeat_login_or_block_battery(status): - script = full_script() + [reply("synthetic-private-body", status), reply(battery(48)), - reply(battery(49)), reply(cockpit(12346))] - client, transport, ticks_monotonic_seconds = make_client(script) - result = client.fetch_battery(include_odometer=True) - assert result.soc_percent == 47 and result.odometer_km is None - assert len(transport.calls) == 8 - assert client.last_query["outcome"] == "success" - assert client.last_query["odometer_outcome"] == "failed" - assert client.last_query["odometer_http_status"] == status - assert client.last_query["retry_after_seconds"] == 0 - assert "synthetic-private-body" not in json.dumps(client.last_query) - ticks_monotonic_seconds[0] = 10 - assert client.fetch_battery(include_odometer=True).soc_percent == 48 - assert len(transport.calls) == 9 - assert client.last_query["odometer_outcome"] == "deferred" - assert client.last_query["odometer_http_status"] is None - assert client.last_query["odometer_retry_after_seconds"] == 290 - ticks_monotonic_seconds[0] = 300 - assert client.fetch_battery(include_odometer=True).odometer_km == 12346 - assert len(transport.calls) == 11 - assert not transport.responses - - -@pytest.mark.parametrize("status,headers,pause,error_type", [ - (429, {}, 300, RateLimitError), (429, {"Retry-After": "900"}, 900, RateLimitError), - (503, {"Retry-After": "600"}, 600, NissanError)]) -def test_provider_pause_from_optional_query_survives_battery_success(status, headers, pause, error_type): - script = full_script() + [reply({}, status, headers), reply(battery()), reply(cockpit())] - client, transport, ticks_monotonic_seconds = make_client(script) - assert client.fetch_battery(include_odometer=True).soc_percent == 47 - assert client.last_query["provider_retry_after_seconds"] == pause - count = len(transport.calls) - with pytest.raises(error_type, match="paused"): - client.fetch_battery(include_odometer=True) - assert len(transport.calls) == count - assert client.last_query["odometer_km"] is None - assert client.last_query["odometer_outcome"] == "not_requested" - ticks_monotonic_seconds[0] = pause - assert client.fetch_battery(include_odometer=True).odometer_km == 12345.6 - assert client.last_query["provider_retry_after_seconds"] == 0 - - -@pytest.mark.parametrize("body", ["invalid synthetic JSON", {"errors": ["synthetic-private-body"]}]) -def test_bad_optional_response_keeps_successful_battery(body): - client, _, _ = make_client(full_script() + [reply(body)]) - assert client.fetch_battery(include_odometer=True).soc_percent == 47 - assert client.last_query["odometer_outcome"] == "failed" - assert "synthetic-private-body" not in json.dumps(client.last_query) - - -def test_optional_transport_error_keeps_successful_battery(): - def timeout(calls): - raise NissanError("Synthetic transport timeout.") - client, _, _ = make_client(full_script() + [timeout]) - assert client.fetch_battery(include_odometer=True).soc_percent == 47 - assert client.last_query["odometer_http_status"] is None - assert client.last_query["outcome"] == "success" - - -def test_no_cockpit_request_after_battery_failure_or_exhausted_budget(): - client, transport, _ = make_client(full_script()[:-1] + [reply(battery(None))]) - with pytest.raises(NissanError): - client.fetch_battery(include_odometer=True) - assert not any(url.endswith("/cockpit") for _, url, _ in transport.calls) - client, transport, ticks_monotonic_seconds = make_client(full_script()) - - def slow_battery(calls): - ticks_monotonic_seconds[0] = 61 - return reply(battery()) - transport.responses[-1] = slow_battery - assert client.fetch_battery(include_odometer=True).soc_percent == 47 - assert len(transport.calls) == 7 - assert client.last_query["odometer_outcome"] == "failed" - - -def test_unsaved_test_accepts_partial_success_and_preserves_provider_pause(): - client, _, _ = make_client(full_script() + [reply({}, 429, {"Retry-After": "900"})]) - result = run_test({"user_id": "synthetic@example.invalid", "password": "synthetic-password"}, - client_factory=lambda *args: client) - assert result["success"] is True and result["query"]["soc"] == 47 - assert result["query"]["odometer_km"] is None - assert result["query"]["provider_retry_after_seconds"] == 900 - for secret in (VIN, "synthetic-password", "synthetic-access", "example.invalid"): - assert secret not in json.dumps(result) - - -def test_zero_retry_after_does_not_remove_rate_limit_backoff(): - client, transport, _ = make_client(full_script() + [reply({}, 429, {"Retry-After": "0"})]) - assert client.fetch_battery(include_odometer=True).soc_percent == 47 - assert client.last_query["retry_after_seconds"] == 300 - with pytest.raises(RateLimitError, match="paused"): - client.fetch_battery(include_odometer=True) - assert len(transport.calls) == 8 diff --git a/packages/modules/vehicles/nissanconnect/tests/probe_test.py b/packages/modules/vehicles/nissanconnect/tests/probe_test.py deleted file mode 100644 index 8a256338b7..0000000000 --- a/packages/modules/vehicles/nissanconnect/tests/probe_test.py +++ /dev/null @@ -1,99 +0,0 @@ -import os -from pathlib import Path -import subprocess -from unittest.mock import Mock - -import pytest - -from modules.vehicles.nissanconnect import connection_test -from modules.vehicles.nissanconnect.tools import probe -from modules.vehicles.nissanconnect.src.api import BatteryStatus - - -@pytest.fixture -def local_terminal(monkeypatch): - for name in ("CODEX_THREAD_ID", "CODEX_SANDBOX", "CODEX_CI"): - monkeypatch.delenv(name, raising=False) - monkeypatch.setattr(probe.sys.stdin, "isatty", lambda: True) - monkeypatch.setattr(probe.sys.stdout, "isatty", lambda: True) - # Do not change the test process's global logging state. - monkeypatch.setattr(probe.logging, "disable", Mock()) - - -def test_probe_refuses_agent_context(monkeypatch, local_terminal): - monkeypatch.setenv("CODEX_THREAD_ID", "synthetic-agent-session") - credentials = Mock(side_effect=AssertionError("Credentials must not be requested")) - monkeypatch.setattr(probe, "getpass", credentials) - assert probe.main() == 2 - credentials.assert_not_called() - - -def test_probe_refuses_redirected_output(monkeypatch, local_terminal): - monkeypatch.setattr(probe.sys.stdout, "isatty", lambda: False) - assert probe.main() == 2 - - -def test_probe_aborts_before_requesting_credentials(monkeypatch, local_terminal): - monkeypatch.setattr("builtins.input", lambda prompt: "n") - credentials = Mock() - monkeypatch.setattr(probe, "getpass", credentials) - assert probe.main() == 0 - credentials.assert_not_called() - - -def test_probe_uses_hidden_inputs_without_echo(monkeypatch, local_terminal, capsys): - monkeypatch.setattr(probe.sys.stdout, "isatty", lambda: True) - monkeypatch.setattr("builtins.input", lambda prompt: "y") - monkeypatch.setattr(probe, "getpass", Mock(side_effect=["synthetic@example.invalid", "synthetic-password", ""])) - client = Mock() - client.fetch_battery.return_value = BatteryStatus(47, 1640995200, 123, 12345.6) - factory = Mock(return_value=client) - monkeypatch.setattr(probe, "NissanClient", factory) - assert probe.main() == 0 - output = capsys.readouterr().out - assert "synthetic@example.invalid" not in output - assert "synthetic-password" not in output - assert "SoC: 47 %" in output - assert "Odometer: 12345.6 km (measurement time unavailable)" in output - client.fetch_battery.assert_called_once_with(include_odometer=True) - - -def test_probe_aborts_if_hidden_input_is_unavailable(monkeypatch, local_terminal): - monkeypatch.setattr("builtins.input", lambda prompt: "y") - monkeypatch.setattr(probe, "getpass", Mock(side_effect=probe.GetPassWarning("synthetic"))) - factory = Mock() - monkeypatch.setattr(probe, "NissanClient", factory) - assert probe.main() == 2 - factory.assert_not_called() - - -@pytest.mark.parametrize("entrypoint,script_path,expected_exit_code,expected_output", [ - (probe, "tools/probe.py", 2, - "Aborted: Please run in a private terminal outside a Codex or agent session."), - (connection_test, "connection_test.py", 0, - '{"success": false, "code": "agent_environment"}'), -]) -def test_direct_entrypoint_with_isolated_python_loads_internal_api( - tmp_path, entrypoint, script_path, expected_exit_code, expected_output): - # Exercise both supported direct-start locations with a synthetic API only. - # A missing or incorrect import must fail before the agent guard can return. - (tmp_path / "src").mkdir() - (tmp_path / "src/__init__.py").write_text("", encoding="utf-8") - script = tmp_path / script_path - script.parent.mkdir(exist_ok=True) - script.write_text(Path(entrypoint.__file__).read_text(encoding="utf-8"), encoding="utf-8") - (tmp_path / "src/api.py").write_text( - 'class NissanError(Exception): pass\n' - 'class AuthenticationError(NissanError): pass\n' - 'class RateLimitError(NissanError): pass\n' - 'VIN_PATTERN = "synthetic"\n' - 'class NissanClient:\n' - ' def __init__(self, *args, **kwargs):\n' - ' raise AssertionError("No real client in the startup test")\n', - encoding="utf-8") - result = subprocess.run( - [probe.sys.executable, "-I", "-B", str(script)], cwd=str(tmp_path), - env=dict(os.environ, CODEX_CI="1"), capture_output=True, text=True, timeout=10) - assert result.returncode == expected_exit_code, result.stderr - assert result.stdout.strip() == expected_output - assert result.stderr == "" diff --git a/packages/modules/vehicles/nissanconnect/tests/soc_test.py b/packages/modules/vehicles/nissanconnect/tests/soc_test.py deleted file mode 100644 index ecc56f3261..0000000000 --- a/packages/modules/vehicles/nissanconnect/tests/soc_test.py +++ /dev/null @@ -1,116 +0,0 @@ -import importlib -import sys -from types import ModuleType -from unittest.mock import Mock - -import pytest - -from modules.vehicles.nissanconnect.src.api import BatteryStatus -from modules.vehicles.nissanconnect.config import NissanConnect, NissanConnectConfiguration - - -@pytest.fixture -def soc_module(monkeypatch): - # The standalone suite must not bootstrap openWB services on the test PC. - # In the normal core suite this dependency is already imported by conftest. - dependency = "modules.common.configurable_vehicle" - if dependency not in sys.modules: - stub = ModuleType(dependency) - stub.ConfigurableVehicle = Mock() - monkeypatch.setitem(sys.modules, dependency, stub) - return importlib.import_module("modules.vehicles.nissanconnect.soc") - - -def test_openwb_adapter_preserves_timestamp_reuses_client_and_handles_config_change(monkeypatch, soc_module): - soc = soc_module - - # Only replace the scheduling/storage wrapper; use the real CarState and updater. - wrapper = Mock() - monkeypatch.setattr(soc, "ConfigurableVehicle", wrapper) - fake_client = Mock() - fake_client.fetch_battery.return_value = BatteryStatus(0, 1640995200, 0, 12345.6) - factory = Mock(return_value=fake_client) - monkeypatch.setattr(soc, "NissanClient", factory) - config = NissanConnect(configuration=NissanConnectConfiguration("synthetic@example.invalid", "synthetic-password")) - soc.create_vehicle(config, 7) - updater = wrapper.call_args.kwargs["component_updater"] - state = updater(None) - assert (state.soc, state.range, state.soc_timestamp, state.odometer) == (0, 0, 1640995200, 12345.6) - fake_client.fetch_battery.assert_called_once_with(include_odometer=True) - updater(None) - assert factory.call_count == 1 - config.configuration.password = "synthetic-new-password" - updater(None) - assert factory.call_count == 2 - assert factory.call_args_list[0].kwargs["request_counter"] is factory.call_args_list[1].kwargs["request_counter"] - soc.create_vehicle(config, 8) - wrapper.call_args.kwargs["component_updater"](None) - assert factory.call_args.kwargs["request_counter"] is not factory.call_args_list[0].kwargs["request_counter"] - assert wrapper.call_args.kwargs["calc_while_charging"] is False - assert wrapper.call_args_list[0].kwargs["vehicle"] == 7 - - -def test_configuration_descriptor_and_module_name_agree(soc_module): - config = soc_module.device_descriptor.configuration_factory() - assert config.type == "nissanconnect" - assert config.configuration.vin is None - - -def test_configuration_roundtrip_uses_openwb_serializer(): - from dataclass_utils import asdict, dataclass_from_dict - - config = NissanConnect(configuration=NissanConnectConfiguration( - "synthetic@example.invalid", "synthetic-password", "SJNFAAZE1U0000001")) - encoded = asdict(config) - decoded = dataclass_from_dict(NissanConnect, encoded) - assert decoded.type == "nissanconnect" - assert isinstance(decoded.configuration, NissanConnectConfiguration) - assert decoded.configuration.user_id == config.configuration.user_id - assert decoded.configuration.password == config.configuration.password - assert decoded.configuration.vin == config.configuration.vin - - -def test_module_logs_only_allowlisted_diagnostics(monkeypatch, soc_module, caplog): - import json - import logging - from modules.vehicles.nissanconnect.tests.helpers import VIN, full_script, make_client, reply - from modules.vehicles.nissanconnect.src.api import NissanError - - client, transport, _ = make_client(full_script() + [reply({"data": {"attributes": {"totalMileage": 12345.6}}})]) - wrapper = Mock() - monkeypatch.setattr(soc_module, "ConfigurableVehicle", wrapper) - - def create_client(*args, **kwargs): - client._request_counter = kwargs["request_counter"] - return client - monkeypatch.setattr(soc_module, "NissanClient", create_client) - config = NissanConnect(configuration=NissanConnectConfiguration("synthetic@example.invalid", "synthetic-password")) - soc_module.create_vehicle(config, 7) - updater = wrapper.call_args.kwargs["component_updater"] - caplog.set_level(logging.DEBUG, logger=soc_module.__name__) - updater(None) - assert len(caplog.records) == 1 - assert '"outcome": "success"' in caplog.text and '"data_age_seconds": 86400' in caplog.text - assert '"soc": 47.0' in caplog.text and 'Nissan vehicle 7' in caplog.text - summary = json.loads(caplog.records[0].args[1]) - assert summary["http_requests_last_24h"] == 8 - assert summary["http_requests_since_start"] == 8 - assert summary["http_requests_since_midnight"] == 8 - assert summary["http_requests_last_24h_complete"] is False - for value in (VIN, "example.invalid", "synthetic-password", "synthetic-access", "synthetic-refresh"): - assert value not in caplog.text - assert not any(record.exc_info for record in caplog.records) - - # A deferred call is logged as such, without a new contact or measured value. - client._retry_at_monotonic_seconds = 300 - client._retry_reason = "Synthetic wait." - calls = len(transport.calls) - with pytest.raises(NissanError): - updater(None) - assert len(transport.calls) == calls - assert '"outcome": "deferred"' in caplog.records[-1].getMessage() - deferred = json.loads(caplog.records[-1].args[1]) - assert deferred["request_started_at"] is None - assert deferred["http_requests"] == 0 - assert deferred["http_requests_last_24h"] == 8 - assert deferred["http_requests_since_start"] == 8 diff --git a/packages/modules/vehicles/nissanconnect/tools/__init__.py b/packages/modules/vehicles/nissanconnect/tools/__init__.py deleted file mode 100644 index 612d5d72e6..0000000000 --- a/packages/modules/vehicles/nissanconnect/tools/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Optional private-terminal diagnostic tools.""" diff --git a/packages/modules/vehicles/nissanconnect/tools/probe.py b/packages/modules/vehicles/nissanconnect/tools/probe.py deleted file mode 100644 index 406fad6295..0000000000 --- a/packages/modules/vehicles/nissanconnect/tools/probe.py +++ /dev/null @@ -1,78 +0,0 @@ -"""Run manually in a private local terminal, never through a cloud agent tool. - -No credentials as command-line arguments, no files, no wallbox access. -The output is private and must not be copied into an issue or cloud chat. -""" -from datetime import datetime, timezone -from getpass import getpass, GetPassWarning -import logging -import os -import sys -import warnings - -if __package__: - from ..src.api import NissanClient, NissanError -else: - # Isolated/embedded Python needs the module root to find the internal src package. - sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - from src.api import NissanClient, NissanError - - -AGENT_ENVIRONMENT_VARIABLES = ("CODEX_THREAD_ID", "CODEX_SANDBOX", "CODEX_CI") -EXIT_SUCCESS = 0 -EXIT_QUERY_FAILED = 1 -EXIT_UNSAFE_TERMINAL = 2 -EXIT_INTERRUPTED = 130 - - -def main(): - if any(os.environ.get(name) for name in AGENT_ENVIRONMENT_VARIABLES): - print("Aborted: Please run in a private terminal outside a Codex or agent session.") - return EXIT_UNSAFE_TERMINAL - if not sys.stdin.isatty() or not sys.stdout.isatty(): - print("Aborted: An interactive local terminal is required; no redirection or pipes.") - return EXIT_UNSAFE_TERMINAL - print("Local MyNISSAN test: This request sends credentials directly to Nissan.") - print("No wallbox connection. No files are written. The result is confidential.") - print("Only use outside cloud agents, terminal recording and screen sharing.") - print("The environment check is a precaution, not a complete technical safeguard.") - if input("Query cached battery and odometer data now? [y/N] ").strip().lower() != "y": - return EXIT_SUCCESS - try: - with warnings.catch_warnings(): - # getpass must abort instead of falling back to an echoed input. - warnings.simplefilter("error", GetPassWarning) - user = getpass("MyNISSAN email (hidden): ") - password = getpass("MyNISSAN password (hidden): ") - vin = getpass("VIN (hidden; leave blank for a single vehicle): ") - # This separate process owns logging; never expose dependency debug output. - logging.disable(logging.CRITICAL) - status = NissanClient(user, password, vin).fetch_battery(include_odometer=True) - measured = datetime.fromtimestamp(status.measurement_unix_seconds, tz=timezone.utc).astimezone() - print("SoC: {:g} %".format(status.soc_percent)) - print("Measurement time: " + measured.isoformat()) - if status.range_km is not None: - print("Range: {:g} km".format(status.range_km)) - if status.odometer_km is not None: - print("Odometer: {:g} km (measurement time unavailable)".format(status.odometer_km)) - else: - print("Odometer: Not available; battery query succeeded.") - print("Compare with MyNISSAN. Do not copy values, screenshots or credentials into a cloud chat.") - return EXIT_SUCCESS - except GetPassWarning: - print("Hidden input is unavailable in this terminal. Aborted.") - return EXIT_UNSAFE_TERMINAL - except NissanError as error: - print("Query failed: " + str(error)) - return EXIT_QUERY_FAILED - except (KeyboardInterrupt, EOFError): - print("Aborted.") - return EXIT_INTERRUPTED - except Exception: - # Never print an unexpected exception/traceback with potentially private data. - print("Unexpected local error; no confidential details were displayed.") - return EXIT_QUERY_FAILED - - -if __name__ == "__main__": - raise SystemExit(main())