From b1310187746e799adf5f2c8a2db18c2f495fa17f Mon Sep 17 00:00:00 2001 From: Sebastian Schildt Date: Tue, 1 Sep 2026 10:36:33 +0200 Subject: [PATCH 01/11] Add Redesign guide Signed-off-by: Sebastian Schildt --- Redesign.md | 146 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 146 insertions(+) create mode 100644 Redesign.md diff --git a/Redesign.md b/Redesign.md new file mode 100644 index 0000000..a50b20a --- /dev/null +++ b/Redesign.md @@ -0,0 +1,146 @@ +# API / SDK Redesign + +## Context & constraints +- PyPI distribution stays **`kuksa-client`**, top-level package stays **`kuksa_client`** — existing users must be unaffected. +- The redesigned SDK is added **in this repo**, in a new subpackage **`kuksa_client.v2`** (no name clash with today's `kuksa_client.grpc` / `kuksa_client.ws` / proto package `kuksa.val.v2`). +- Both old and new ship in the same wheel (one release train); the old API is frozen + deprecated. + +## Current issues (from codebase) +1. Dual-stack complexity: `kuksa_client/grpc/__init__.py` (1592 LOC) and `grpc/aio.py` interleave kuksa.val.v1/v2 — `try_v2` flags, `UNIMPLEMENTED` fallbacks, `EntryUpdate.from_tuple` vs `from_message`, `ListMetadata` branch-expansion, `ensure_id_mapping`. +2. Value encoding duplicated: `v1_to_message`/`v2_to_message` are near-identical `DataType → proto field` dicts; string-casting magic (`cast_array_values`, `cast_bool`, `cast_str`) is CLI logic living in the library. +3. Return shapes drift by call path: simplified API returns `Dict[str, Datapoint]`, full API `List[DataEntry]`, CLI re-serializes via `DatabrokerEncoder` isinstance-chain. +4. Redundant object model: `Metadata`/`Datapoint`/`DataEntry`/`EntryRequest`/`EntryUpdate`/`SubscribeEntry`/`Field`/`View`/`MetadataField` re-implement proto messages with `from_message`/`to_message`/`to_dict` everywhere. +5. No first-class provider API: `OpenProviderStream` used internally only to receive actuate requests; signal publishing not exposed. +6. CLI/threading complexity: `KuksaClientThread` + `cli_backend` with message queues, JSON-string responses, ws/VISS + gRPC backends, camelCase + snake_case APIs mixed. +7. Build complexity: proto generation in `setup.py` custom commands + git submodule. + +## Goals +- Support **kuksa.val.v2 only** — no protocol abstractions. +- Support **providers** (OpenProviderStream: provide signals/actuators, publish, actuation). +- "Reasonably fast": native-value sets with cached type lookup; high-frequency via provider stream. +- Pythonic; typed signatures with native Python values. +- Not exposing every gRPC feature is acceptable — but keep a clean **escape hatch** to raw proto. +- Backwards compatible: old code kept for a while; class rename `VSSClient → KuksaClient`. +- Keep the CLI, rebuilt on the new client. + +## Decisions (confirmed) +- **Namespace:** new SDK at `kuksa_client.v2` in the same distribution. +- **Sync/async:** shared core — both first-class; protocol logic once, I/O per-client. +- **Granularity:** clean core + escape hatch (raw proto access for power users/providers). + +## Target module layout (`kuksa_client/v2/`) +``` +__init__.py # public surface: KuksaClient, DataType, Datapoint, Metadata, Provider, errors +aio.py # async KuksaClient +core.py # shared protocol logic (get/set/subscribe/actuate/metadata) using injected _call/_stream +transport.py # connection/TLS/auth; blocking + aio stubs, abstract call/stream interface +types.py # Datapoint(value, timestamp), Metadata, DataType, ValueRestriction (pure, no proto leakage) +codec.py # single source of truth: python value <-> proto Value/Datapoint +metadata.py # MetadataStore: cached metadata (id, data_type, entry_type) + type lookups, invalidation +patterns.py # wildcard pattern matching (compile pattern -> segment matcher), client-side +provider.py # Provider on OpenProviderStream +errors.py # KuksaError hierarchy (transport/gRPC vs stream/application errors) +``` + +## API surface +```python +from kuksa_client.v2 import KuksaClient, DataType, Datapoint, Provider + +with KuksaClient("127.0.0.1", 55555) as client: + dp = client.get("Vehicle.Speed") # Datapoint (raises NotFound if path doesn't exist) + values = client.get(["Vehicle.Speed", "Vehicle.ADAS.ABS.IsActive"]) # Dict[str, Datapoint] + client.set({"Vehicle.Speed": 42}) # native values; type auto-resolved + cached + client.set({"Vehicle.Speed": Datapoint(42, ts)}) + client.set({"Vehicle.Speed": 42}, data_type=DataType.FLOAT) # explicit type = no lookup + for updates in client.subscribe(["Vehicle.Speed"]): # Iterator[Dict[str, Datapoint]] + ... + client.actuate({"Vehicle.Body.Wiper.Pos": 45}) # target values + md = client.get_metadata("Vehicle.Speed") # Metadata + tree = client.list_metadata("Vehicle") + has = client.has_signal("Vehicle.Speed") # bool + missing = client.missing_signals(["Vehicle.Speed", "Vehicle.NoSuch"]) # set[str] + client.authorize(token) + info = client.get_server_info() + +provider = Provider(client) +provider.provide_signals({"Vehicle.Speed": DataType.FLOAT}) # claim signals +provider.publish({"Vehicle.Speed": 42.5}) # publish values (stream, high-frequency path) +provider.provide_actuators(["Vehicle.Body.Wiper.Pos"]) # claim actuators +for req in provider.actuation_requests(): # receive actuation requests + provider.accept(req, ok=True, reason=None) +``` +Async mirrors this 1:1 with `await`/`async for` from `kuksa_client.v2.aio`. + +**Escape hatch:** `codec` exposes `to_proto_value`/`from_proto_value`, `client.raw_*` (or the stub) for raw v2 messages — providers and power users are not blocked. + +### get / set semantics +- `get(path)` returns a `Datapoint`. A **non-existent** VSS path raises `NotFound`; a path that exists but has no data yet returns `Datapoint(value=None)`. The two cases must be distinguishable — never collapse "missing" into `None`. +- `get([paths...]) -> Dict[str, Datapoint]` is **all-or-nothing**: if any requested path does not exist, the whole call raises and returns nothing. Paths that exist but have no data are fine and yield `Datapoint(value=None)`. (This maps 1:1 onto the v2 `GetValues` RPC: `NOT_FOUND` if any signal is missing, otherwise positional `data_points` are zipped back with the request order.) +- `set` auto-resolves types via `MetadataStore` (one cached lookup); a batch resolves all paths with a single `ListMetadata` first. + +### Path handling — no magical wildcards +- `get`, `set`, `subscribe` operate on **exact VSS paths only**. No silent `ListMetadata` expansion of branches or `.*` (unlike today's `subscribe_current_values`, which auto-expands/falls back). +- "Everything under a branch" is done via an **explicit helper** that returns concrete leaf paths, which the user then feeds to `get`/`subscribe`. The expansion is a named operation — never hidden inside another call. + +### Wildcard / path expansion +- **Grammar:** reuse the databroker `wildcard_matching.md` semantics — `*` matches exactly one path segment, `**` matches zero-or-more segments, both valid anywhere in the path; a plain branch path matches the branch and everything below; the `**` combined with consecutive `*` segments exception carries over. +- **`patterns.py`** implements the matcher **client-side** (compile pattern → segment matcher), pinning the semantics and making it unit-testable and independent of any future server-side changes. +- **Never send `*` to the databroker:** all heavy lifting happens client-side. `expand`/`list_metadata` always call `ListMetadata(root=)`, where the prefix is everything *before the first wildcard*, and match the remainder in `patterns.py`. A pattern starting with a wildcard (`**.TyrePressure`) has an empty prefix and therefore triggers a full-tree `ListMetadata(root="")` — acceptable, but documented/commented as such. +- **`expand(pattern, entry_type=None) -> list[str]`** — the single bridge into `get`/`subscribe`. Fetches `ListMetadata(root=)` to bound the data, matches client-side, optionally filters by `EntryType` (SENSOR / ACTUATOR / ATTRIBUTE), and returns sorted concrete leaf paths. Covers both "give me a list of paths to analyse" and "all sensors/actuators/leaves under X" (e.g. `expand("**.TyrePressure", entry_type=EntryType.SENSOR)`). +- **`list_metadata(pattern) -> list[Metadata]`** (already planned) accepts the same patterns and returns entries *with* metadata (each `Metadata` carries its `.path`) for further analysis; `expand` is `list_metadata` + path extraction + optional type filter. +- Example flow: `client.subscribe(client.expand("**.TyrePressure"))`. + +### Metadata is read-only +- `kuksa.val.v2` has **no metadata write path** (no `Set`/`UpdateMetadata` RPC) — metadata can only be *read* via `ListMetadata`. Consequently `get_metadata`/`list_metadata` exist in v2, but `set_metadata`, `updateVSSTree`, and `updateMetaData` have no v2 equivalent and are **dropped** (see migration table). + +### Signal availability checks +- An app often depends on a set of VSS signals but cannot know whether a given car/databroker supports them, so the client offers explicit existence checks: + - `has_signal(path) -> bool` + - `has_signals(paths) -> bool` — `True` if all given paths exist + - `missing_signals(paths) -> set[str]` — cheap companion; apps usually need to know *which* ones are missing to log/adapt (existence check is one batched `ListMetadata` call, so this comes for free) +- **Semantics:** existence in the VSS tree known to the broker (`ListMetadata`; `NOT_FOUND` → `False`), *not* "currently provided by a provider" (that is dynamic and out of scope for v1). +- **Tie-in with `MetadataStore`:** consults the cache first; on miss does one `ListMetadata` per batch and caches **positively and negatively** (absent paths), invalidated on reconnect like the rest of the store. Bonus: `ListMetadata` returns full metadata incl. `id`, `data_type`, and `entry_type`, so a `has_signals()` check also warms the type/id cache and makes subsequent `set()`/provider calls lookup-free. +- Exact paths only (see path handling above). + +### Provider & reconnect +- `Provider` is backed by the bidirectional `OpenProviderStream`. Provide/publish/actuate message types are **id-keyed** (`ProvideSignalRequest` = `map`, `PublishValuesRequest` = `map`), so the provider path relies on the `MetadataStore` id↔path cache populated via `ListMetadata`. `request_id` matching is used for `PublishValues`/`GetProviderValue` request/response correlation. +- **Reconnect:** `OpenProviderStream` is a gRPC stream, so a connection drop / broker restart terminates the stream — the provider's iteration ends/raises rather than hanging. The client invalidates its `MetadataStore`/id cache on reconnect; re-registration (re-`ProvideSignal`/re-`ProvideActuation`) is the caller's explicit action. Note broker-assigned signal ids are arbitrary and may change across a restart, so cached ids must never survive a reconnect. + +### Errors & authentication +- `KuksaError` hierarchy distinguishes **transport/gRPC errors** (unary RPC status codes) from **application/stream errors** (in-stream `Error`/`ErrorCode` messages and `ProviderErrorIndication` on the provider stream). +- `authorize(token)` attaches the token as per-call gRPC metadata (the v1 `GetServerInfo` pseudo-auth trick is not carried over). + +## Typing strategy +- Currency of the API = **native Python values** (int/float/str/bool/list). One complete `DataType ↔ python type` map in `codec.py`. +- **`TIMESTAMP` / `TIMESTAMP_ARRAY` are dropped** — the v2 `Value` oneof has no timestamp field, so these data types cannot be represented in values. `INT8`/`INT16`/`UINT8`/`UINT16` have no dedicated proto fields and are encoded as `int32`/`uint32`; the codec map carries this aliasing. +- No string-casting in the library; coercion lives in the CLI. +- `Datapoint` is a dataclass `Datapoint(value, timestamp: datetime | None)`; typed signatures throughout. + +## Performance +- `MetadataStore` is an **in-memory cache bound to a connection** (cleared on reconnect). **No TTL needed**: VSS metadata is assumed static while a system is running. Invalidation happens on NOT_FOUND or via an explicit `refresh`/re-fetch method for special cases → `set()` without explicit type is one cached lookup, not a round-trip. +- High-frequency publishing is the `Provider`/OpenProviderStream path. + +## CLI +- Rebuilt on the new sync client; drop ws/VISS and the thread/queue machinery. +- Keep the interactive cmd2 shell for compatibility; add one-shot commands: + `kuksa-client get Vehicle.Speed` / `kuksa-client set Vehicle.Speed=42` / `kuksa-client subscribe Vehicle.Speed`. +- Value coercion in the CLI layer. + +## Backwards compatibility & migration +- Old API (`kuksa_client.grpc`, `kuksa_client.grpc.aio`, `kuksa_client.KuksaClientThread`) frozen, deprecated via docs + `DeprecationWarning`, kept ≥ 2 minor releases. +- Class rename `VSSClient → KuksaClient`; publish a mapping table (getValue→get, setValue→set, updateVSSTree/updateMetaData→dropped (metadata is read-only in v2), subscribe→subscribe). +- ws/VISS support dropped in v2 (not ported). + +## Testing +- In-memory **v2-only** mock databroker (gRPC servicer), same pattern as current `tests/conftest.py`. +- Codec round-trip / property tests across all `DataType`s. +- Client + provider tests against the mock; old-API suite kept green for regression. +- Optional dockerized integration suite against a real databroker. + +## Build & packaging +- Keep distribution `kuksa-client`; add `kuksa_client/v2/` to the package. +- **Keep proto generation during build** (as today): the `.proto` files in the `kuksa-proto` submodule stay the single source of truth for the API. Committed/bundled generated files risk going stale and complicate proto/grpcio version bumps, so generation at build time from the submodule is intentional and should be preserved (and adapted for the new `kuksa_client/v2/` code that also consumes `kuksa.val.v2`). + +## Open questions / next steps +- Exact CLI one-shot command grammar. +- Provider: which advanced `OpenProviderStream` features to expose in the first v2 SDK release (subscription `filters`/`UpdateFilterRequest`, on-demand `GetProviderValue`, and `ProviderErrorIndication`) vs. defer to the raw escape hatch. From 8eda54f56156f9e44e70bc7e9a7430652a8d1449 Mon Sep 17 00:00:00 2001 From: Sebastian Schildt Date: Tue, 1 Sep 2026 21:29:32 +0200 Subject: [PATCH 02/11] Initial version 2 API and CLI Signed-off-by: Sebastian Schildt --- .gitignore | 2 + README.md | 37 +- Redesign.md | 37 +- docs/cli.md | 288 ++--- docs/examples/async.md | 69 ++ docs/examples/{ => legacy}/async-grpc.md | 0 docs/examples/{ => legacy}/sync-grpc.md | 0 docs/examples/{ => legacy}/threaded.md | 0 docs/examples/provider.md | 66 ++ docs/examples/sync.md | 73 ++ docs/library.md | 194 +++- kuksa-client/kuksa_client/__init__.py | 6 + kuksa-client/kuksa_client/__main__.py | 1017 +++++++++--------- kuksa-client/kuksa_client/grpc/__init__.py | 7 + kuksa-client/kuksa_client/grpc/aio.py | 7 + kuksa-client/kuksa_client/v2/__init__.py | 399 +++++++ kuksa-client/kuksa_client/v2/aio.py | 462 ++++++++ kuksa-client/kuksa_client/v2/codec.py | 210 ++++ kuksa-client/kuksa_client/v2/core.py | 260 +++++ kuksa-client/kuksa_client/v2/errors.py | 125 +++ kuksa-client/kuksa_client/v2/metadata.py | 88 ++ kuksa-client/kuksa_client/v2/patterns.py | 122 +++ kuksa-client/kuksa_client/v2/provider.py | 332 ++++++ kuksa-client/kuksa_client/v2/transport.py | 64 ++ kuksa-client/kuksa_client/v2/types.py | 113 ++ kuksa-client/tests/v2/__init__.py | 5 + kuksa-client/tests/v2/conftest.py | 111 ++ kuksa-client/tests/v2/mock_databroker.py | 291 +++++ kuksa-client/tests/v2/test_cli.py | 180 ++++ kuksa-client/tests/v2/test_client.py | 118 ++ kuksa-client/tests/v2/test_client_async.py | 103 ++ kuksa-client/tests/v2/test_codec.py | 111 ++ kuksa-client/tests/v2/test_patterns.py | 58 + kuksa-client/tests/v2/test_provider.py | 39 + kuksa-client/tests/v2/test_provider_async.py | 43 + 35 files changed, 4282 insertions(+), 755 deletions(-) create mode 100644 docs/examples/async.md rename docs/examples/{ => legacy}/async-grpc.md (100%) rename docs/examples/{ => legacy}/sync-grpc.md (100%) rename docs/examples/{ => legacy}/threaded.md (100%) create mode 100644 docs/examples/provider.md create mode 100644 docs/examples/sync.md create mode 100644 kuksa-client/kuksa_client/v2/__init__.py create mode 100644 kuksa-client/kuksa_client/v2/aio.py create mode 100644 kuksa-client/kuksa_client/v2/codec.py create mode 100644 kuksa-client/kuksa_client/v2/core.py create mode 100644 kuksa-client/kuksa_client/v2/errors.py create mode 100644 kuksa-client/kuksa_client/v2/metadata.py create mode 100644 kuksa-client/kuksa_client/v2/patterns.py create mode 100644 kuksa-client/kuksa_client/v2/provider.py create mode 100644 kuksa-client/kuksa_client/v2/transport.py create mode 100644 kuksa-client/kuksa_client/v2/types.py create mode 100644 kuksa-client/tests/v2/__init__.py create mode 100644 kuksa-client/tests/v2/conftest.py create mode 100644 kuksa-client/tests/v2/mock_databroker.py create mode 100644 kuksa-client/tests/v2/test_cli.py create mode 100644 kuksa-client/tests/v2/test_client.py create mode 100644 kuksa-client/tests/v2/test_client_async.py create mode 100644 kuksa-client/tests/v2/test_codec.py create mode 100644 kuksa-client/tests/v2/test_patterns.py create mode 100644 kuksa-client/tests/v2/test_provider.py create mode 100644 kuksa-client/tests/v2/test_provider_async.py diff --git a/.gitignore b/.gitignore index 74018e0..c6098ec 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ log_* *_pb2*.py* venv **/.vssclient_history +**/.kuksa_client_history **/__pycache__ **/*.egg-info kuksa-client/dist +kuksa-client/kuksa/ diff --git a/README.md b/README.md index 03affe2..b50f37b 100644 --- a/README.md +++ b/README.md @@ -7,10 +7,16 @@ More about Eclipse KUKSA can be found in the [repository](https://github.com/ecl ## Introduction -KUKSA Python SDK provides both a command-line interface (CLI) and a standalone library to interact with either -[KUKSA Server](https://github.com/eclipse/kuksa.val/tree/master/kuksa-val-server) or +KUKSA Python SDK provides both a command-line interface (CLI) and a standalone +library to interact with the [KUKSA Databroker](https://github.com/eclipse-kuksa/kuksa-databroker). +The redesigned `kuksa_client.v2` API targets the `kuksa.val.v2` protocol and +offers both synchronous and asynchronous clients, plus a provider API. The +older APIs (`kuksa_client.grpc`, `kuksa_client.grpc.aio`, +`kuksa_client.KuksaClientThread`) are frozen and deprecated but remain +available for backwards compatibility. + ## Building and Installing the KUKSA Python SDK The fastest way to start using KUKSA Python SDK is to install a pre-built version from pypi.org: @@ -29,10 +35,18 @@ After you have installed the kuksa-client package via pip you can run the test c kuksa-client ``` -With default CLI arguments, the client will try to connect to a local Databroker, e.g. a server supporting the `kuksa.val.v1` protocol without using TLS. This is equivalent to executing +With default CLI arguments, the client will try to connect to a local Databroker supporting the `kuksa.val.v2` protocol without using TLS. This is equivalent to executing + +```console +kuksa-client --server grpc://127.0.0.1:55555 +``` + +One-shot commands are also available: ```console -kuksa-client grpc://127.0.0.1:55555 +kuksa-client get Vehicle.Speed +kuksa-client set Vehicle.Speed=42 +kuksa-client subscribe Vehicle.Speed ``` More details on how to use the CLI is available in the KUKSA Python SDK [CLI documentation](https://github.com/eclipse-kuksa/kuksa-python-sdk/blob/main/docs/cli.md) @@ -43,9 +57,18 @@ The KUKSA Python SDK CLI is available as a [prebuilt docker container](https://g ## Using KUKSA Python SDK as library -The KUKSA Python SDK provides three APIS for connecting and communicating with [KUKSA Server](https://github.com/eclipse/kuksa.val/tree/master/kuksa-val-server) -and [KUKSA Databroker](https://github.com/eclipse-kuksa/kuksa-databroker). -For more details see the KUKSA Python SDK [Library documentation](https://github.com/eclipse-kuksa/kuksa-python-sdk/blob/main/docs/library.md). +The KUKSA Python SDK provides a synchronous and an asynchronous client for the +`kuksa.val.v2` protocol, plus a provider API. For more details see the KUKSA +Python SDK [Library documentation](https://github.com/eclipse-kuksa/kuksa-python-sdk/blob/main/docs/library.md). + +```python +from kuksa_client.v2 import KuksaClient + +with KuksaClient("127.0.0.1", 55555) as client: + speed = client.get("Vehicle.Speed") + print(speed.value) + client.set({"Vehicle.Speed": 42}) +``` ## Contributing to KUKSA Python SDK diff --git a/Redesign.md b/Redesign.md index a50b20a..5bd4a4d 100644 --- a/Redesign.md +++ b/Redesign.md @@ -63,12 +63,19 @@ with KuksaClient("127.0.0.1", 55555) as client: info = client.get_server_info() provider = Provider(client) -provider.provide_signals({"Vehicle.Speed": DataType.FLOAT}) # claim signals +provider.provide_signals({"Vehicle.Speed": None}) # claim signals (path -> min sample interval in ms, or None) provider.publish({"Vehicle.Speed": 42.5}) # publish values (stream, high-frequency path) provider.provide_actuators(["Vehicle.Body.Wiper.Pos"]) # claim actuators -for req in provider.actuation_requests(): # receive actuation requests +for req in provider.actuation_requests(): # receive actuation requests (batches of ActuationRequest) provider.accept(req, ok=True, reason=None) ``` + +> Note: `provide_signals` maps paths to a **minimum sample interval in milliseconds** +> (or `None` for the databroker default), *not* to a `DataType` — `ProvideSignalRequest` +> is `map` and carries no data type. The signal's data type is +> implicit in the broker's VSS tree. The path → id mapping (and type lookup for `publish`) +> is resolved via the `MetadataStore`. + Async mirrors this 1:1 with `await`/`async for` from `kuksa_client.v2.aio`. **Escape hatch:** `codec` exposes `to_proto_value`/`from_proto_value`, `client.raw_*` (or the stub) for raw v2 messages — providers and power users are not blocked. @@ -144,3 +151,29 @@ Async mirrors this 1:1 with `await`/`async for` from `kuksa_client.v2.aio`. ## Open questions / next steps - Exact CLI one-shot command grammar. - Provider: which advanced `OpenProviderStream` features to expose in the first v2 SDK release (subscription `filters`/`UpdateFilterRequest`, on-demand `GetProviderValue`, and `ProviderErrorIndication`) vs. defer to the raw escape hatch. + +## TODO (potential next steps) + +### Provider / OpenProviderStream (not yet implemented) +- **`GetProviderValue` request/response handling** — the broker may ask a provider for the + current value of a claimed signal; today such requests are logged and ignored. +- **Subscription filters** — `UpdateFilterRequest` / `UpdateFilterResponse` (min sample + interval / duration per signal) are received on the provider stream but not exposed or + acted upon. +- **`ProviderErrorIndication`** — sending provider-side error indications is not exposed. +- **`PublishValuesResponse` error surfacing** — `publish()` is currently fire-and-forget; + per-signal publish errors are only logged, not raised or returned to the caller. +- **`ActuateStream`** — the low-latency single-actuator streaming RPC is not surfaced. + +### Client +- Batch `set` currently resolves types with one `ListMetadata(root=path)` per uncached + path; a common-prefix / whole-subtree warm could reduce first-call round-trips. +- `get`/`set` do not yet expose per-call gRPC timeouts/metadata overrides. + +### CLI +- Interactive shell: `unsubscribe` for subscriptions, richer tab-completion, and value + coercion parity with the legacy `setValue` string rules (escaped quotes, arrays). + +### Packaging / CI +- Wire the new `tests/v2` suite into CI explicitly (it already runs under `pytest tests/`). +- Consider an optional dockerized integration suite against a real databroker. diff --git a/docs/cli.md b/docs/cli.md index 881ceaa..75f183a 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -1,262 +1,122 @@ # Using the Command Line Interface (CLI) -After you have installed the kuksa-client package via pip you can run the test client CLI directly by executing: +After you have installed the kuksa-client package via pip you can run the client +CLI directly by executing: ```console kuksa-client ``` -With default CLI arguments, the client will try to connect to a local Databroker, e.g. a server supporting the `kuksa.val.v1` protocol without using TLS. This is equivalent to executing +This starts an interactive shell connected to a local databroker (a server +supporting the `kuksa.val.v2` protocol). This is equivalent to: ```console -kuksa-client grpc://127.0.0.1:55555 +kuksa-client --server grpc://127.0.0.1:55555 ``` - -If everything works as expected and the server can be contacted you will get an output similar to below. - +If the server can be contacted you will get an output similar to: ```console -Welcome to Kuksa Client version - - `-:+o/shhhs+:` - ./oo/+o/``.-:ohhs- - `/o+- /o/ `.. :yho` - +o/ /o/ oho ohy` - :o+ /o/`+hh. sh+ - +o: /oo+o+` /hy - +o: /o+/oo- +hs - .oo` oho `oo- .hh: - :oo. oho -+: -hh/ - .+o+-`oho `:shy- - ./o/ohy//+oyhho- - `-/+oo+/:. - -Default tokens directory: /some/path/kuksa_certificates/jwt - -Connecting to VSS server at 127.0.0.1 port 55555 using KUKSA GRPC protocol. -TLS will not be used. -INFO 2023-09-15 18:48:13,415 kuksa_client.grpc No Root CA present, it will not be posible to use a secure connection! -INFO 2023-09-15 18:48:13,415 kuksa_client.grpc.aio Establishing insecure channel -gRPC channel connected. -Test Client> +Connecting to databroker at 127.0.0.1 port 55555... +Connected to databroker version 0.7.1 +Kuksa Client> ``` -If you wish to connect to a VISS server e.g. `kuksa-val-server` (not using TLS), you should instead run: +## One-shot commands + +Instead of the interactive shell, a single command can be executed: ```console -kuksa-client ws://127.0.0.1:8090 -``` +kuksa-client --server grpc://127.0.0.1:55555 get Vehicle.Speed +kuksa-client --server grpc://127.0.0.1:55555 set Vehicle.Speed=42 +kuksa-client --server grpc://127.0.0.1:55555 subscribe Vehicle.Speed +``` + +Available one-shot commands: + +| Command | Description | +|---------|-------------| +| `get ` | Get the value of one or more paths | +| `set ` | Set values (e.g. `Vehicle.Speed=42`) | +| `actuate ` | Actuate actuators (e.g. `Vehicle.Body.Wiper.Pos=45`) | +| `subscribe ` | Subscribe to one or more paths | +| `get-metadata ` | Get the metadata of a path | +| `list-metadata ` | List metadata matching a pattern | +| `expand ` | Expand a wildcard pattern into paths | +| `has-signal ` | Check whether a signal exists | +| `server-info` | Show databroker info | + +## Interactive shell commands + +| Command | Description | +|---------|-------------| +| `connect ` | Connect to a databroker | +| `disconnect` | Disconnect from the databroker | +| `authorize ` | Authorize with a JWT token or token file | +| `get ` | Get the value of one or more paths | +| `set ` | Set values | +| `actuate ` | Actuate actuators (target values) | +| `subscribe ` | Subscribe to updates | +| `subscribe -b ` | Subscribe in the background; updates print as alerts while the prompt stays usable | +| `get_metadata ` | Get the metadata of a path | +| `list_metadata ` | List metadata matching a pattern | +| `expand ` | Expand a wildcard pattern into paths | +| `has_signal ` | Check whether a signal exists | +| `info` / `version` | Show client info / version | + +Refer `help` for further information. ## Logging -The log level of `kuksa-client` can be set using the LOG_LEVEL environment variable. The following levels are supported - -* `error` -* `warning` -* `info` (default) -* `debug` - - -To set the log level to DEBUG +The log level can be set with the `LOG_LEVEL` environment variable +(`error`, `warning`, `info` (default), `debug`): ```console -$ LOG_LEVEL=debug kuksa-client -``` - -It is possible to control log level in detail. -The example below sets log level to DEBUG, but for asyncio INFO. - -```console -$ LOG_LEVEL=debug,asyncio=info kuksa-client -``` - -## TLS with databroker - -KUKSA Client uses TLS to connect to Databroker when the schema part of the server URI is `grpcs`. -The KUKSA Python SDK does not include any default certificates or keys. -The root certificate used to authenticate the Databroker must be specified with `--cacertificate `. -If you want to use KUKSA example Root CA you need to provide it from [kuksa-common](https://github.com/eclipse-kuksa/kuksa-common/tree/main/tls). - - -``` -kuksa-client --cacertificate ~/kuksa-common/tls/CA.pem grpcs://localhost:55555 -``` - -The example server protocol list 127.0.0.1 as an alternative name, but the TLS-client currently used does not accept it, -instead a valid server name must be given as argument. -Currently `Server` and `localhost` are valid names from the example certificates. - -``` -kuksa-client --cacertificate ~/kuksa-common/tls/CA.pem --tls-server-name Server grpcs://127.0.0.1:55555 +LOG_LEVEL=debug kuksa-client ``` -## TLS with Websocket -Websocket access also supports TLS. KUKSA Client uses TLS to connect to Weboscket when the schema part of the server URI is `wss`. A valid command to connect to a local TLS enabled VSS Server (KUKSA Databroker, VISSR, ...) supporting Websocket is - - -``` -kuksa-client --cacertificate ~/kuksa-common/tls/CA.pem wss://localhost:8090 -``` - -In some environments the `--tls-server-name` argument must be used to specify alternative server name -if connecting to the server by numerical IP address like `wss://127.0.0.1:8090`. - -## Authorizing against KUKSA Server - -If the connected KUKSA Server or KUKSA Databroker require authorization the first step after a connection is made is to authorize. KUKSA Server and KUKSA Databroker use different token formats. +## TLS -The KUKSA jwt tokens for testing can be found in the [kuksa-common repository](https://github.com/eclipse/kuksa.val/tree/master/kuksa_certificates/jwt). - -Select one of the tokens and use the `authorize` command like below: +KUKSA Client uses TLS to connect to a databroker when the server scheme is +`grpcs`. The root certificate must be specified with `--cacertificate `: ```console -Test Client> authorize /some/path/kuksa_certificates/jwt/super-admin.json.token +kuksa-client --server grpcs://localhost:55555 --cacertificate ~/kuksa-common/tls/CA.pem ``` -## Authorizing against KUKSA Databroker - -If the KUKSA Databroker use default example tokens then one of the -tokens in [kuksa-common](https://github.com/eclipse-kuksa/kuksa-common/tree/main/jwt) can be used, like in the example below: +If connecting by IP address, `--tls-server-name` may also be required: ```console -Test Client> authorize /some/path/jwt/provide-all.token +kuksa-client --server grpcs://127.0.0.1:55555 --cacertificate ~/kuksa-common/tls/CA.pem --tls-server-name Server ``` -## Usage Instructions +## Authorization -Refer help for further information +If the databroker requires authorization, authorize with a token or token file: ```console -Test Client> help -v - -Documented commands (use 'help -v' for verbose/'help ' for details): - -Communication Set-up Commands -================================================================================ -authorize Authorize the client to interact with the server -connect Connect to a VSS server -disconnect Disconnect from the VISS/gRPC Server -getServerAddress Gets the IP Address for the VISS/gRPC Server - -Info Commands -================================================================================ -info Show summary info of the client -version Show version of the client - -Kuksa Interaction Commands -================================================================================ -getMetaData Get MetaData of the path -getTargetValue Get the value of a path -getTargetValues Get the value of given paths -getValue Get the value of a path -getValues Get the value of given paths -setTargetValue Set the target value of a path -setTargetValues Set the target value of given paths -setValue Set the value of a path -setValues Set the value of given paths -subscribe Subscribe to updates of given paths -unsubscribe Unsubscribe an existing subscription -updateMetaData Update MetaData of a given path -updateVSSTree Update VSS Tree Entry - -``` - -This is an example showing how some of the commands can be used: - -![try kuksa-client out](https://raw.githubusercontent.com/eclipse/kuksa.val/master/doc/pictures/testclient_basic.gif "test client usage") - -## Syntax for specifying data in the command line interface - -Values used as argument to for example `setValue` shall match the type given. Quotes (single and double) are -generally not needed, except in a few special cases. A few valid examples on setting float is shown below: - -``` -setValue Vehicle.Speed 43 -setValue Vehicle.Speed "45" -setValue Vehicle.Speed '45.2' -``` - -For strings escaped quotes are needed if you want quotes to be sent to Server/Databroker, like if you want to store -`Almost "red"` as value. Alternatively you can use outer single quotes and inner double quotes. - -*NOTE: KUKSA Server and Databroker currently handle (escaped) quotes in strings differently!* -*The behavior described below is in general correct for KUKSA Databroker, but result may be different if interacting with KUKSA Server!* -*For consistent behavior it is recommended not to include (escaped) quotes in strings, except when needed to separate values* - -The two examples below are equal: - -``` -setValue Vehicle.Cabin.Light.InteractiveLightBar.Effect 'Almost \"red\"' -setValue Vehicle.Cabin.Light.InteractiveLightBar.Effect 'Almost "red"' +Kuksa Client> authorize /some/path/jwt/provide-all.token ``` -Alternatively you can use inner single quotes, but then the value will be represented by double quotes (`Almost "blue"`) -when stored anyhow. +or via the one-shot commands using `--token`: +```console +kuksa-client --token /some/path/jwt/provide-all.token --server grpc://127.0.0.1:55555 get Vehicle.Speed ``` -setValue Vehicle.Cabin.Light.InteractiveLightBar.Effect "Almost 'blue'" -setValue Vehicle.Cabin.Light.InteractiveLightBar.Effect "Almost \'blue\'" -``` - -If not using outer quotes the inner quotes will be lost, the examples below are equal. -Leading/trailing spaces are ignored. - -``` -setValue Vehicle.Cabin.Light.InteractiveLightBar.Effect Almost 'green' -setValue Vehicle.Cabin.Light.InteractiveLightBar.Effect Almost green -setValue Vehicle.Cabin.Light.InteractiveLightBar.Effect 'Almost green' -setValue Vehicle.Cabin.Light.InteractiveLightBar.Effect "Almost green" -setValue Vehicle.Cabin.Light.InteractiveLightBar.Effect 'Almost green ' -``` - -It is possible to set array values. In general the value should be a valid JSON representation of the array. -For maximum compatibility for both KUKSA Server and KUKSA Databroker the following recommendations applies: -* Always use single quotes around the array value. For some cases, like if there is no blanks or comma in the value, it is not needed, but it is good practice. -* Always use double quotes around string values. -* Never use single quotes inside string values -* Double quotes inside string values are allowed but must be escaped (`\"`) +## Value syntax -Some examples supported by both KUKSA databroker and KUKSA Server are shown below +Values passed to `set` are coerced to the signal's data type automatically: -Setting a string array in KUKSA Databroker with simple identifiers is not a problem. -Also not if they contain blanks - -``` -// Array with two string elements -setValue Vehicle.OBD.DTCList '["abc","def"]' -// Array with two int elements (Note no quotes) -setValue Vehicle.SomeInt '[123,456]' -// Array with two elements, "hello there" and "def" -setValue Vehicle.OBD.DTCList '["hello there","def"]' -// Array with doubl quotes in string value; hello "there" -setValue Vehicle.OBD.DTCList '["hello, \"there\"","def"]' +```console +Kuksa Client> set Vehicle.Speed=43 +Kuksa Client> set Vehicle.Speed=45.2 +Kuksa Client> set Vehicle.Cabin.Light.InteractiveLightBar.Effect='Almost green' ``` -## Updating VSS Structure - -Using the test client, it is also possible to update and extend the VSS data structure. -More details can be found [here](https://github.com/eclipse/kuksa.val/blob/master/doc/KUKSA.val_server/liveUpdateVSSTree.md). - -**Note**: You can also use `setValue` to change the value of an array, but the value should not contains any non-quoted spaces. Consider the following examples: +Array values use JSON-like syntax: ```console -Test Client> setValue Vehicle.OBD.DTCList ["dtc1","dtc2"] -{ - "action": "set", - "requestId": "f7b199ce-4d86-4759-8d9a-d6f8f935722d", - "ts": "2022-03-22T17:19:34.1647965974Z" -} - -Test Client> setValue Vehicle.OBD.DTCList '["dtc1", "dtc2"]' -{ - "action": "set", - "requestId": "d4a19322-67d8-4fad-aa8a-2336404414be", - "ts": "2022-03-22T17:19:44.1647965984Z" -} - -Test Client> setValue Vehicle.OBD.DTCList ["dtc1", "dtc2"] -usage: setValue [-h] Path Value -setValue: error: unrecognized arguments: dtc2 ] +Kuksa Client> set Vehicle.OBD.DTCList='["abc","def"]' +Kuksa Client> set Vehicle.SomeInt='[123,456]' ``` diff --git a/docs/examples/async.md b/docs/examples/async.md new file mode 100644 index 0000000..507697b --- /dev/null +++ b/docs/examples/async.md @@ -0,0 +1,69 @@ +# Asynchronous API (asyncio) + +`kuksa_client.v2.aio.KuksaClient` is an asynchronous client for the +`kuksa.val.v2` protocol. + +## Usage + +```python +import asyncio + +from kuksa_client.v2.aio import KuksaClient + +async def main(): + async with KuksaClient("127.0.0.1", 55555) as client: + speed = await client.get("Vehicle.Speed") + if speed.value is not None: + print(speed.value) + +asyncio.run(main()) +``` + +## Setting and actuating + +```python +async def main(): + async with KuksaClient("127.0.0.1", 55555) as client: + await client.set({"Vehicle.Speed": 42}) + await client.actuate({ + "Vehicle.Body.Windshield.Front.Wiping.System.TargetPosition": 45, + }) + +asyncio.run(main()) +``` + +## Subscribing + +```python +async def main(): + async with KuksaClient("127.0.0.1", 55555) as client: + async for updates in client.subscribe(["Vehicle.Speed"]): + for path, datapoint in updates.items(): + print(f"{path} is now {datapoint.value}") + +asyncio.run(main()) +``` + +## Wildcards and metadata + +```python +async def main(): + async with KuksaClient("127.0.0.1", 55555) as client: + for path in await client.expand("Vehicle.Cabin.**"): + print(path) + + for metadata in await client.list_metadata("Vehicle.Cabin.Sunroof.*"): + print(metadata.path, metadata.data_type) + +asyncio.run(main()) +``` + +## Authorization + +```python +async def main(): + async with KuksaClient("127.0.0.1", 55555, token="your-jwt-token") as client: + print((await client.get("Vehicle.Speed")).value) + +asyncio.run(main()) +``` diff --git a/docs/examples/async-grpc.md b/docs/examples/legacy/async-grpc.md similarity index 100% rename from docs/examples/async-grpc.md rename to docs/examples/legacy/async-grpc.md diff --git a/docs/examples/sync-grpc.md b/docs/examples/legacy/sync-grpc.md similarity index 100% rename from docs/examples/sync-grpc.md rename to docs/examples/legacy/sync-grpc.md diff --git a/docs/examples/threaded.md b/docs/examples/legacy/threaded.md similarity index 100% rename from docs/examples/threaded.md rename to docs/examples/legacy/threaded.md diff --git a/docs/examples/provider.md b/docs/examples/provider.md new file mode 100644 index 0000000..f4f907e --- /dev/null +++ b/docs/examples/provider.md @@ -0,0 +1,66 @@ +# Providers + +A provider claims ownership of signals and actuators on the databroker, publishes +values at high frequency, and receives actuation requests. + +## Synchronous provider + +```python +from kuksa_client.v2 import KuksaClient, Provider + +with KuksaClient("127.0.0.1", 55555) as client: + provider = Provider(client) + + # Claim a signal (value is the minimum sample interval in ms, or None). + provider.provide_signals({"Vehicle.Speed": None}) + + # Claim an actuator. + provider.provide_actuators( + ["Vehicle.Body.Windshield.Front.Wiping.System.TargetPosition"] + ) + + # Publish a value (high-frequency path). + provider.publish({"Vehicle.Speed": 42.5}) + + # Receive and acknowledge actuation requests. + for requests in provider.actuation_requests(): + for request in requests: + print(f"Actuate {request.path} to {request.value}") + provider.accept(request, ok=True) + + provider.close() +``` + +## Asynchronous provider + +```python +import asyncio + +from kuksa_client.v2.aio import KuksaClient, Provider + +async def main(): + async with KuksaClient("127.0.0.1", 55555) as client: + provider = Provider(client) + await provider.provide_signals({"Vehicle.Speed": None}) + await provider.publish({"Vehicle.Speed": 42.5}) + + await provider.provide_actuators( + ["Vehicle.Body.Windshield.Front.Wiping.System.TargetPosition"] + ) + async for requests in provider.actuation_requests(): + for request in requests: + print(f"Actuate {request.path} to {request.value}") + await provider.accept(request, ok=True) + + await provider.close() + +asyncio.run(main()) +``` + +## Notes + +- `Provider` is backed by the bidirectional `OpenProviderStream` RPC. If the + connection drops or the broker restarts, the stream ends and iteration/raises + rather than hanging. Re-registration is the caller's explicit action. +- Advanced stream features (filters, `GetProviderValue`, error indications) are + not yet exposed and can be reached via the raw `client.stub` escape hatch. diff --git a/docs/examples/sync.md b/docs/examples/sync.md new file mode 100644 index 0000000..1732e1e --- /dev/null +++ b/docs/examples/sync.md @@ -0,0 +1,73 @@ +# Synchronous API + +`kuksa_client.v2.KuksaClient` is a synchronous client for the `kuksa.val.v2` +protocol. + +## Usage + +```python +from kuksa_client.v2 import KuksaClient + +with KuksaClient("127.0.0.1", 55555) as client: + speed = client.get("Vehicle.Speed") + if speed.value is not None: + print(speed.value) +``` + +You can also connect explicitly instead of using the context manager: + +```python +client = KuksaClient("127.0.0.1", 55555) +client.connect() +print(client.get("Vehicle.Speed").value) +client.disconnect() +``` + +## Setting and actuating + +```python +from kuksa_client.v2 import KuksaClient, Datapoint + +with KuksaClient("127.0.0.1", 55555) as client: + client.set({"Vehicle.Speed": 42}) + client.set({"Vehicle.Speed": Datapoint(42)}) + + client.actuate({ + "Vehicle.Body.Windshield.Front.Wiping.System.TargetPosition": 45, + }) +``` + +## Subscribing + +```python +with KuksaClient("127.0.0.1", 55555) as client: + for updates in client.subscribe(["Vehicle.Speed"]): + for path, datapoint in updates.items(): + print(f"{path} is now {datapoint.value}") +``` + +## Wildcards and metadata + +```python +with KuksaClient("127.0.0.1", 55555) as client: + for path in client.expand("Vehicle.Cabin.**"): + print(path) + + for metadata in client.list_metadata("Vehicle.Cabin.Sunroof.*"): + print(metadata.path, metadata.data_type) +``` + +## Authorization + +```python +with KuksaClient("127.0.0.1", 55555) as client: + client.authorize("your-jwt-token") + print(client.get("Vehicle.Speed").value) +``` + +The token may also be passed to the constructor: + +```python +with KuksaClient("127.0.0.1", 55555, token="your-jwt-token") as client: + ... +``` diff --git a/docs/library.md b/docs/library.md index 7089558..bd4d45a 100644 --- a/docs/library.md +++ b/docs/library.md @@ -1,44 +1,184 @@ # Using KUKSA Python SDK as Library +The `kuksa-client` package provides two generations of APIs: -## Usage +- **`kuksa_client.v2`** — the redesigned, Pythonic client for the `kuksa.val.v2` + protocol (this document). +- **Legacy APIs** (`kuksa_client.grpc`, `kuksa_client.grpc.aio`, + `kuksa_client.KuksaClientThread`) — frozen and deprecated, kept for backwards + compatibility. See [the migration notes](#migrating-from-the-legacy-api). -The kuksa-client package needs to be installed with `pip`. Then the package can be imported: +## Install + +```console +pip install kuksa-client +``` + +## The `kuksa_client.v2` API + +The new SDK targets `kuksa.val.v2` only and comes in two flavours that mirror +each other 1:1: + +- `kuksa_client.v2.KuksaClient` — synchronous +- `kuksa_client.v2.aio.KuksaClient` — asynchronous (asyncio) + +The public surface is available from `kuksa_client.v2`: + +```python +from kuksa_client.v2 import KuksaClient, DataType, Datapoint, Provider +``` + +### Quick start (synchronous) + +```python +from kuksa_client.v2 import KuksaClient + +with KuksaClient("127.0.0.1", 55555) as client: + speed = client.get("Vehicle.Speed") # -> Datapoint + print(speed.value) + + client.set({"Vehicle.Speed": 42}) # native values, type auto-resolved + + values = client.get(["Vehicle.Speed", "Vehicle.ADAS.ABS.IsActive"]) # -> dict + + for updates in client.subscribe(["Vehicle.Speed"]): + print(updates["Vehicle.Speed"].value) +``` + +The asynchronous client is identical, except methods are `await`ed and +subscriptions are `async for` loops: ```python ->>> import kuksa_client ->>> kuksa_client.__version__ -'' +import asyncio +from kuksa_client.v2.aio import KuksaClient + +async def main(): + async with KuksaClient("127.0.0.1", 55555) as client: + speed = await client.get("Vehicle.Speed") + await client.set({"Vehicle.Speed": 42}) + async for updates in client.subscribe(["Vehicle.Speed"]): + print(updates["Vehicle.Speed"].value) + +asyncio.run(main()) ``` -## Available APIs +## Client reference + +### Connection -This package holds 3 different APIs depending on your application's requirements: +```python +KuksaClient( + host="127.0.0.1", + port=55555, + token=None, # optional JWT token + root_certificates=None, # optional pathlib.Path to a CA for TLS + tls_server_name=None, # optional TLS server name override +) +``` -- `kuksa_client.grpc.aio.VSSClient` provides an asynchronous client that only supports `grpc` to interact with `kuksa_databroker` - ([check out examples](examples/async-grpc.md)). -- `kuksa_client.grpc.VSSClient` provides a synchronous client that only supports `grpc` to interact with `kuksa_databroker` - ([check out examples](examples/sync-grpc.md)). -- `kuksa_client.KuksaClientThread` provides a thread-based client that supports both `ws` and `grpc` to interact with either `kuksa-val-server` or `kuksa_databroker` - ([check out examples](examples/threaded.md)). +Both clients are context managers; entering them connects, exiting disconnects. +You may also call `connect()` / `disconnect()` explicitly. +### Values (`get` / `set`) -## TLS configuration +- `get(path)` returns a `Datapoint`. A non-existent path raises `NotFound`; + a path that exists but has no data yet returns `Datapoint(value=None)`. +- `get([paths...]) -> Dict[str, Datapoint]` is all-or-nothing: if any path is + missing the whole call raises and returns nothing. +- `set(values, data_type=None)` publishes native values (or `Datapoint`s). + Types are resolved via `ListMetadata` and cached; pass `data_type` to skip the + lookup. +- `actuate(values, data_type=None)` sends actuator target values. -Clients like [KUKSA CAN Provider](https://github.com/eclipse-kuksa/kuksa-can-provider) -that use KUKSA Client library must typically set the path to the root CA certificate. -If the path is set the VSSClient will try to establish a secure connection. +```python +from kuksa_client.v2 import KuksaClient, DataType, Datapoint +with KuksaClient("127.0.0.1", 55555) as client: + client.set({"Vehicle.Speed": 42}) # auto-resolve type + client.set({"Vehicle.Speed": 42}, data_type=DataType.FLOAT) + client.set({"Vehicle.Speed": Datapoint(42)}) + client.actuate({"Vehicle.Body.Windshield.Front.Wiping.System.TargetPosition": 45}) ``` -# Shall TLS be used (default False for Databroker, True for KUKSA Server) -# tls = False -tls = True -# TLS-related settings -# Path to root CA, needed if using TLS -root_ca_path=../../kuksa.val/kuksa_certificates/CA.pem -# Server name, typically only needed if accessing server by IP address like 127.0.0.1 -# and typically only if connection to KUKSA Databroker -# If using KUKSA example certificates the names "Server" or "localhost" can be used. -# tls_server_name=Server +### Metadata + +```python +md = client.get_metadata("Vehicle.Speed") # -> Metadata (raises NotFound) +tree = client.list_metadata("Vehicle.Cabin") # -> list[Metadata] ``` + +### Wildcards and path expansion + +`get`/`set`/`subscribe` operate on exact paths only. To work with a branch use +`expand()` to obtain concrete leaf paths first: + +```python +paths = client.expand("**.TyrePressure") # -> list[str] +sensors = client.expand("Vehicle.**", entry_type=EntryType.SENSOR) +client.subscribe(client.expand("**.TyrePressure")) +``` + +`*` matches exactly one path segment, `**` matches zero or more segments. + +### Signal availability + +```python +client.has_signal("Vehicle.Speed") # -> bool +client.has_signals(["Vehicle.Speed", "Vehicle.NoSuch"]) # -> bool +client.missing_signals(["Vehicle.Speed", "Vehicle.NoSuch"]) # -> set[str] +``` + +### Authorization and server info + +```python +client.authorize(token) # attach token to subsequent requests +info = client.get_server_info() # -> ServerInfo(name, version, commit_hash) +``` + +## Providers + +A provider claims signals/actuators, publishes values at high frequency and +receives actuation requests: + +```python +from kuksa_client.v2 import KuksaClient, DataType, Provider + +with KuksaClient("127.0.0.1", 55555) as client: + provider = Provider(client) + provider.provide_signals({"Vehicle.Speed": None}) # path -> sample interval (ms) + provider.publish({"Vehicle.Speed": 42.5}) + + provider.provide_actuators(["Vehicle.Body.Windshield.Front.Wiping.System.TargetPosition"]) + for requests in provider.actuation_requests(): + for request in requests: + print(f"Actuate {request.path} to {request.value}") + provider.accept(request, ok=True) + provider.close() +``` + +## Escape hatch + +Power users can reach the raw `kuksa.val.v2` gRPC stub and codec: + +```python +client.stub # the raw VALStub +from kuksa_client.v2 import codec +codec.to_proto_value(42, DataType.FLOAT) # native -> proto +codec.from_proto_value(...) # proto -> native +``` + +## Migrating from the legacy API + +| Legacy (`kuksa_client.grpc`) | New (`kuksa_client.v2`) | +|------------------------------|--------------------------| +| `get_current_values([...])` | `get([...])` | +| `set_current_values({...})` | `set({...})` | +| `get_target_values([...])` | `actuate({...})` | +| `set_target_values({...})` | `actuate({...})` | +| `get_metadata([...])` | `get_metadata(path)` / `list_metadata(pattern)` | +| `subscribe_current_values([...])` | `subscribe([...])` | +| `updateVSSTree` / `updateMetaData` | dropped (metadata is read-only in v2) | +| `VSSClient` | `KuksaClient` | + +The legacy APIs remain available but emit a `DeprecationWarning`. Their +examples are kept under [`examples/legacy/`](examples/legacy/). diff --git a/kuksa-client/kuksa_client/__init__.py b/kuksa-client/kuksa_client/__init__.py index dda1269..c9581f8 100644 --- a/kuksa-client/kuksa_client/__init__.py +++ b/kuksa-client/kuksa_client/__init__.py @@ -20,6 +20,7 @@ import asyncio import threading +import warnings from typing import Any from typing import Dict from typing import Iterable @@ -32,6 +33,11 @@ class KuksaClientThread(threading.Thread): # Constructor def __init__(self, config): + warnings.warn( + "KuksaClientThread is deprecated. Use kuksa_client.v2.KuksaClient instead.", + DeprecationWarning, + stacklevel=2, + ) super().__init__() self.backend = cli_backend.Backend.from_config(config) diff --git a/kuksa-client/kuksa_client/__main__.py b/kuksa-client/kuksa_client/__main__.py index 9f87b31..437b397 100755 --- a/kuksa-client/kuksa_client/__main__.py +++ b/kuksa-client/kuksa_client/__main__.py @@ -17,31 +17,31 @@ # SPDX-License-Identifier: Apache-2.0 ######################################################################## -import functools +import argparse import json -import logging.config import logging import os import pathlib import sys import threading -import time +from urllib.parse import urlparse -from pygments import highlight -from pygments import lexers -from pygments import formatters from cmd2 import Cmd from cmd2 import Cmd2ArgumentParser -from cmd2 import CompletionItem -from cmd2 import Completions from cmd2 import with_argparser from cmd2 import with_category from cmd2 import constants -from urllib.parse import urlparse +from pygments import formatters +from pygments import highlight +from pygments import lexers -from kuksa_client import KuksaClientThread from kuksa_client import _metadata from kuksa_client.kuksa_logger import KuksaLogger +from kuksa_client.v2 import DataType +from kuksa_client.v2 import EntryType +from kuksa_client.v2 import KuksaClient +from kuksa_client.v2 import KuksaError +from kuksa_client.v2 import NotFound scriptDir = os.path.dirname(os.path.realpath(__file__)) @@ -50,194 +50,171 @@ DEFAULT_CACERTIFICATE = os.environ.get("CACERTIFICATE", None) DEFAULT_TLS_SERVER_NAME = os.environ.get("TLS_SERVER_NAME", None) - logger = logging.getLogger(__name__) -def assignment_statement(arg): - path, value = arg.split("=", maxsplit=1) - return (path, value) - - -def display_completions(completions, delimiter): - # Index of what prefix to remove from displayed items - # I.e. "Vehicle." should be removed if the common prefix is "Vehicle.Ve". - prefix_idx = os.path.commonprefix(completions).rfind(delimiter) + 1 - matches = [] - for path in completions: - path = path[prefix_idx:] - # Only display completions up to (and including) the next delimiter. - next_dot = path.find(delimiter) - if next_dot != -1: - path = path[: next_dot + 1] - matches.append(path) - return matches - - -def metadata_tree_to_dict(tree): - def add_children(flattened_tree, path, value): - if "children" in value: - for child_path, value in value["children"].items(): - add_children(flattened_tree, f"{path}.{child_path}", value) - else: - flattened_tree[path] = value - - flattened_tree = {} - - for key, value in tree.items(): - add_children(flattened_tree, key, value) +# --------------------------------------------------------------------------- +# Value coercion (CLI layer only) +# --------------------------------------------------------------------------- + +_BOOL_TRUE = {"true", "t", "1", "yes", "on"} +_BOOL_FALSE = {"false", "f", "0", "no", "off"} +_INT_TYPES = { + DataType.INT8, + DataType.INT16, + DataType.INT32, + DataType.INT64, + DataType.UINT8, + DataType.UINT16, + DataType.UINT32, + DataType.UINT64, +} +_FLOAT_TYPES = {DataType.FLOAT, DataType.DOUBLE} +_INT_ARRAYS = { + DataType.INT8_ARRAY, + DataType.INT16_ARRAY, + DataType.INT32_ARRAY, + DataType.INT64_ARRAY, + DataType.UINT8_ARRAY, + DataType.UINT16_ARRAY, + DataType.UINT32_ARRAY, + DataType.UINT64_ARRAY, +} +_FLOAT_ARRAYS = {DataType.FLOAT_ARRAY, DataType.DOUBLE_ARRAY} + + +def _parse_array(text, data_type): + stripped = text.strip() + if stripped.startswith("[") and stripped.endswith("]"): + stripped = stripped[1:-1] + items = [item.strip() for item in stripped.split(",") if item.strip() != ""] + if data_type == DataType.STRING_ARRAY: + def cast(s): + return s.strip("\"'") + elif data_type == DataType.BOOLEAN_ARRAY: + cast = _coerce_bool + elif data_type in _INT_ARRAYS: + cast = int + elif data_type in _FLOAT_ARRAYS: + cast = float + else: + cast = str + return [cast(item) for item in items] + + +def _coerce_bool(text): + lowered = text.strip().lower() + if lowered in _BOOL_TRUE: + return True + if lowered in _BOOL_FALSE: + return False + raise ValueError(f"Invalid boolean value: {text}") + + +def coerce_value(text, data_type): + if data_type is None or data_type == DataType.UNSPECIFIED: + return text + if data_type == DataType.BOOLEAN: + return _coerce_bool(text) + if data_type in _FLOAT_TYPES: + return float(text) + if data_type in _INT_TYPES: + return int(text) + if data_type.name.endswith("_ARRAY"): + return _parse_array(text, data_type) + return text + + +def coerce_assignments(client, assignments): + """ + Coerce ``Path=Value`` assignment strings into a ``{path: native value}`` + mapping using each signal's data type. + """ + updates = {} + for assignment in assignments: + if "=" not in assignment: + raise KuksaError(f"Invalid assignment: {assignment} (expected Path=Value)") + path, value = assignment.split("=", maxsplit=1) + data_type = client.get_metadata(path).data_type + updates[path] = coerce_value(value, data_type) + return updates + + +# --------------------------------------------------------------------------- +# Path completion (interactive shell) +# --------------------------------------------------------------------------- + +def _matching_paths(shell, text): + if shell.client is None: + return [] + if not shell._completion_paths: + try: + shell._completion_paths = shell.client.expand("") + except KuksaError: + return [] + lowered = text.lower() + return [ + path for path in shell._completion_paths if path.lower().startswith(lowered) + ] + + +def path_completer(shell, text, line, begidx, endidx): + """Complete VSS signal paths (e.g. ``get Vehicle.S``).""" + return shell.basic_complete( + text, line, begidx, endidx, _matching_paths(shell, text) + ) - return flattened_tree +def set_completer(shell, text, line, begidx, endidx): + """Complete the path portion of a ``Path=Value`` argument.""" + if "=" in text: + path_part = text.split("=", maxsplit=1)[0] + endidx = begidx + len(path_part) + return shell.basic_complete( + path_part, line, begidx, endidx, _matching_paths(shell, path_part) + ) + return shell.basic_complete( + text, line, begidx, endidx, _matching_paths(shell, text) + ) -# pylint: disable=too-many-instance-attributes -# pylint: disable=too-many-public-methods -class TestClient(Cmd): - def refresh_metadata(self): - if self.server.startswith("grpc"): - entries = json.loads(self.getMetaData("**")) - if "error" in entries: - raise Exception("Wrong databroker version, please use a newer version") - # Convert to dict with paths as key - self.metadata = {entry["path"]: entry for entry in entries} - else: - entries = json.loads(self.getMetaData("")) - if "metadata" in entries: - # Convert to dict with paths as key - self.metadata = metadata_tree_to_dict(entries["metadata"]) - - def path_completer(self, text, line, begidx, endidx): - if not self.connection_established(): - return Completions() - - if len(self.pathCompletionItems) == 0: - self.refresh_metadata() - - delimiter = "." - if "/" in text: - delimiter = "/" - text = text.replace(delimiter, ".") - - self.pathCompletionItems = [] - for path in self.metadata.keys(): - if path.lower().startswith(text.lower()): - if delimiter != ".": - path = path.replace(".", delimiter) - self.pathCompletionItems.append(CompletionItem(path)) - - return self.basic_complete(text, line, begidx, endidx, self.pathCompletionItems) - - def subscribeCallback(self, logPath, resp): - if logPath is None: - self.add_alert( - msg=highlight( - json.dumps(json.loads(resp), indent=2), - lexers.JsonLexer(), - formatters.TerminalFormatter(), - ) - ) - else: - with logPath.open("a", encoding="utf-8") as logFile: - logFile.write(resp + "\n") - def subscriptionIdCompleter(self, text, line, begidx, endidx): - self.pathCompletionItems = [] - for sub_id in self.subscribeIds: - self.pathCompletionItems.append(CompletionItem(sub_id)) - return self.basic_complete(text, line, begidx, endidx, self.pathCompletionItems) +# --------------------------------------------------------------------------- +# Interactive shell +# --------------------------------------------------------------------------- +class KuksaShell(Cmd): COMM_SETUP_COMMANDS = "Communication Set-up Commands" - VSS_COMMANDS = "Kuksa Interaction Commands (Supported by both KUKSA Databroker and KUKSA Server)" - VSS_COMMANDS_SERVER = "Kuksa Interaction Commands (Only supported by KUKSA Server)" + VSS_COMMANDS = "Kuksa Interaction Commands" INFO_COMMANDS = "Info Commands" ap_connect = Cmd2ArgumentParser() ap_connect.add_argument( "server", - help=f"VSS server to connect to. Format: protocol://host[:port]. \ - Supported protocols: [grpc, grpcs, ws, wss]. Example: {DEFAULT_KUKSA_ADDRESS}", + help="Databroker to connect to. Format: grpc://host[:port] or grpcs://host[:port].", ) - ap_disconnect = Cmd2ArgumentParser() ap_authorize = Cmd2ArgumentParser() - tokenfile_completer_method = functools.partial( - Cmd.path_complete, - path_filter=lambda path: (os.path.isdir(path) or path.endswith(".token")), - ) - ap_authorize.add_argument( - "token_or_tokenfile", - help="JWT(or the file storing the token) for authorizing the client.", - completer=tokenfile_completer_method, - ) + ap_authorize.add_argument("token", help="JWT token or path to a .token file") - ap_setValue = Cmd2ArgumentParser() - ap_setValue.add_argument( - "Path", help="Path to be set", completer=path_completer - ) - ap_setValue.add_argument("Value", nargs="+", help="Value to be set") - ap_setValue.add_argument( - "-a", "--attribute", help="Attribute to be set", default="value" + ap_get = Cmd2ArgumentParser() + ap_get.add_argument( + "Path", help="Path whose value is to be read", nargs="+", completer=path_completer ) - ap_setValues = Cmd2ArgumentParser() - ap_setValues.add_argument( + ap_set = Cmd2ArgumentParser() + ap_set.add_argument( "Path=Value", - help="Path and new value this path is to be set with", + help="Path and new value, e.g. Vehicle.Speed=42", nargs="+", - type=assignment_statement, - ) - ap_setValues.add_argument( - "-a", "--attribute", help="Attribute to be set", default="value" - ) - - ap_getValue = Cmd2ArgumentParser() - ap_getValue.add_argument( - "Path", help="Path to be read", completer=path_completer - ) - ap_getValue.add_argument( - "-a", "--attribute", help="Attribute to be get", default="value" - ) - - ap_getValues = Cmd2ArgumentParser() - ap_getValues.add_argument( - "Path", - help="Path whose value is to be read", - nargs="+", - completer=path_completer, - ) - ap_getValues.add_argument( - "-a", "--attribute", help="Attribute to be get", default="value" - ) - - ap_setTargetValue = Cmd2ArgumentParser() - ap_setTargetValue.add_argument( - "Path", - help="Path whose target value to be set", - completer=path_completer, + completer=set_completer, ) - ap_setTargetValue.add_argument("Value", help="Value to be set") - ap_setTargetValues = Cmd2ArgumentParser() - ap_setTargetValues.add_argument( + ap_actuate = Cmd2ArgumentParser() + ap_actuate.add_argument( "Path=Value", - help="Path and new target value this path is to be set with", + help="Path and target value, e.g. Vehicle.Body.Wiper.Pos=45", nargs="+", - type=assignment_statement, - ) - - ap_getTargetValue = Cmd2ArgumentParser() - ap_getTargetValue.add_argument( - "Path", - help="Path whose target value is to be read", - completer=path_completer, - ) - - ap_getTargetValues = Cmd2ArgumentParser() - ap_getTargetValues.add_argument( - "Path", - help="Path whose target value is to be read", - nargs="+", - completer=path_completer, + completer=set_completer, ) ap_subscribe = Cmd2ArgumentParser() @@ -245,418 +222,448 @@ def subscriptionIdCompleter(self, text, line, begidx, endidx): "Path", help="Path to subscribe to", nargs="+", completer=path_completer ) ap_subscribe.add_argument( - "-a", "--attribute", help="Attribute to subscribe to", default="value" - ) - - ap_subscribe.add_argument( - "-f", - "--output-to-file", - help="Redirect the subscription output to file", + "-b", + "--background", action="store_true", + help="Subscribe in the background and print updates as alerts", ) - ap_unsubscribe = Cmd2ArgumentParser() - ap_unsubscribe.add_argument( - "SubscribeId", - help="Corresponding subscription Id", - completer=subscriptionIdCompleter, + ap_get_metadata = Cmd2ArgumentParser() + ap_get_metadata.add_argument( + "Path", help="Path whose metadata is to be read", completer=path_completer ) - ap_getMetaData = Cmd2ArgumentParser() - ap_getMetaData.add_argument( - "Path", - help="Path whose metadata is to be read", - completer=path_completer, - ) - ap_updateMetaData = Cmd2ArgumentParser() - ap_updateMetaData.add_argument( - "Path", help="Path whose MetaData is to update", completer=path_completer - ) - ap_updateMetaData.add_argument( - "Json", - help="MetaData to update. Note, only attributes can be update, if update children or the whole vss tree, use" - " `updateVSSTree` instead.", + ap_list_metadata = Cmd2ArgumentParser() + ap_list_metadata.add_argument( + "Pattern", help="Exact path or wildcard pattern", completer=path_completer ) - ap_updateVSSTree = Cmd2ArgumentParser() - jsonfile_completer_method = functools.partial( - Cmd.path_complete, - path_filter=lambda path: (os.path.isdir(path) or path.endswith(".json")), - ) - ap_updateVSSTree.add_argument( - "Json", - help="Json tree to update VSS", - completer=jsonfile_completer_method, + ap_expand = Cmd2ArgumentParser() + ap_expand.add_argument("Pattern", help="Wildcard pattern", completer=path_completer) + ap_expand.add_argument( + "-t", + "--entry-type", + choices=[e.name for e in EntryType], + default=None, + help="Only list signals of this entry type", ) - # Constructor, request names after protocol to avoid errors - def __init__( - self, - server=None, - token_or_tokenfile=None, - cacertificate=None, - tls_server_name=None, - ): + ap_has_signal = Cmd2ArgumentParser() + ap_has_signal.add_argument("Path", help="Path to check", completer=path_completer) + + def __init__(self, server, token_or_tokenfile=None, cacertificate=None, tls_server_name=None): shortcuts = constants.DEFAULT_SHORTCUTS shortcuts.update({"exit": "quit"}) super().__init__( - persistent_history_file=".vssclient_history", + persistent_history_file=".kuksa_client_history", persistent_history_length=100, shortcuts=shortcuts, allow_cli_args=False, ) - - self.prompt = "Test Client> " - self.max_completion_items = 20 - self.server = server or DEFAULT_KUKSA_ADDRESS - - self.metadata = {} - self.pathCompletionItems = [] - self.subscribeIds = set() - self.commThread = None + self.prompt = "Kuksa Client> " + self.server = server self.token_or_tokenfile = token_or_tokenfile self.cacertificate = cacertificate self.tls_server_name = tls_server_name + self.client = None + self._completion_paths = [] + self._subscribe_threads = [] - with (pathlib.Path(scriptDir) / "logo").open("r", encoding="utf-8") as f: - logo = f.read() - print(logo.replace("%ver%", str(_metadata.__version__))) - + with (pathlib.Path(scriptDir) / "logo").open("r", encoding="utf-8") as logo_file: + print(logo_file.read().replace("%ver%", str(_metadata.__version__))) print() self.connect() - @with_category(COMM_SETUP_COMMANDS) - @with_argparser(ap_authorize) - def do_authorize(self, args): - """Authorize the client to interact with the server""" - if args.token_or_tokenfile is not None: - self.token_or_tokenfile = args.token_or_tokenfile - if self.connection_established(): - resp = self.commThread.authorize(self.token_or_tokenfile) - print(highlight(resp, lexers.JsonLexer(), formatters.TerminalFormatter())) + # ------------------------------------------------------------------ + def _load_token(self, token_or_tokenfile): + if token_or_tokenfile is None: + return None + path = pathlib.Path(token_or_tokenfile) + if path.is_file(): + return path.expanduser().read_text(encoding="utf-8").rstrip("\n") + return token_or_tokenfile - @with_category(VSS_COMMANDS) - @with_argparser(ap_setValue) - def do_setValue(self, args): - """Set the value of a path""" - if self.connection_established(): - # If there is a blank before a single/double quote on the kuksa-client cli then - # the argparser shell will remove it, there is nothing we can do to it - # This gives off behavior for examples like: - # setValue Vehicle.OBD.DTCList [ "dtc1, dtc2", ddd] - # which will be treated as input of 3 elements - # The recommended approach is to have quotes (of a different type) around the whole value - # if your strings includes quotes, commas or other items - # setValue Vehicle.OBD.DTCList '[ "dtc1, dtc2", ddd]' - # or - # setValue Vehicle.OBD.DTCList "[ 'dtc1, dtc2', ddd]" - # If you really need to include a quote in the values use backslash and use the quote type - # you want as inner value: - # setValue Vehicle.OBD.DTCList "[ 'dtc1, \'dtc2', ddd]" - # Will result in two elements in the array; "dtc1, 'dtc2" and "ddd" - value = str(" ".join(args.Value)) - resp = self.commThread.setValue(args.Path, value, args.attribute) - print(highlight(resp, lexers.JsonLexer(), formatters.TerminalFormatter())) - self.pathCompletionItems = [] + def _connect_kwargs(self): + srv = urlparse(self.server) + host = srv.hostname or "127.0.0.1" + port = srv.port or 55555 + kwargs = { + "host": host, + "port": port, + "tls_server_name": self.tls_server_name, + } + token = self._load_token(self.token_or_tokenfile) + if token: + kwargs["token"] = token + if srv.scheme in ("grpcs",): + if self.cacertificate is None: + print("TLS cannot be used as no CA Certificate was specified!") + return None + kwargs["root_certificates"] = pathlib.Path(self.cacertificate) + return kwargs - @with_category(VSS_COMMANDS) - @with_argparser(ap_setValues) - def do_setValues(self, args): - """Set the value of given paths""" - if self.connection_established(): - resp = self.commThread.setValues( - dict(getattr(args, "Path=Value")), args.attribute + def _require_client(self): + if self.client is None: + self.connect() + if self.client is None: + raise KuksaError("Not connected to a databroker") + return self.client + + # ------------------------------------------------------------------ + def connect(self): + if self.client is not None: + self.client.disconnect() + self.client = None + self._completion_paths = [] + kwargs = self._connect_kwargs() + if kwargs is None: + return + print(f"Connecting to databroker at {kwargs['host']} port {kwargs['port']}...") + self.client = KuksaClient(**kwargs) + self.client.connect() + try: + info = self.client.get_server_info() + print(f"Connected to {info.name} version {info.version}") + except KuksaError as exc: + print(f"Connected (server info unavailable: {exc})") + + def _print_json(self, obj): + print( + highlight( + json.dumps(obj, indent=2, default=str), + lexers.JsonLexer(), + formatters.TerminalFormatter(), ) - print(highlight(resp, lexers.JsonLexer(), formatters.TerminalFormatter())) - self.pathCompletionItems = [] + ) - @with_category(VSS_COMMANDS) - @with_argparser(ap_setTargetValue) - def do_setTargetValue(self, args): - """Set the target value of a path""" - if self.connection_established(): - resp = self.commThread.setValue(args.Path, args.Value, "targetValue") - print(highlight(resp, lexers.JsonLexer(), formatters.TerminalFormatter())) - self.pathCompletionItems = [] + def _stop_subscriptions(self): + for thread in self._subscribe_threads: + thread.join(timeout=1) + self._subscribe_threads = [] - @with_category(VSS_COMMANDS) - @with_argparser(ap_setTargetValues) - def do_setTargetValues(self, args): - """Set the target value of given paths""" - if self.connection_established(): - resp = self.commThread.setValues( - dict(getattr(args, "Path=Value")), "targetValue" - ) - print(highlight(resp, lexers.JsonLexer(), formatters.TerminalFormatter())) - self.pathCompletionItems = [] + @with_category(COMM_SETUP_COMMANDS) + @with_argparser(ap_connect) + def do_connect(self, args): + """Connect to a databroker""" + self.server = args.server + self.connect() - @with_category(VSS_COMMANDS) - @with_argparser(ap_getValue) - def do_getValue(self, args): - """Get the value of a path""" - if self.connection_established(): - resp = self.commThread.getValue(args.Path, args.attribute) - print(highlight(resp, lexers.JsonLexer(), formatters.TerminalFormatter())) - self.pathCompletionItems = [] + @with_category(COMM_SETUP_COMMANDS) + def do_disconnect(self, _args): + """Disconnect from the databroker""" + if self.client is not None: + self.client.disconnect() + self.client = None + self._completion_paths = [] + self._stop_subscriptions() + + @with_category(COMM_SETUP_COMMANDS) + @with_argparser(ap_authorize) + def do_authorize(self, args): + """Authorize the client with a JWT token""" + token = self._load_token(args.token) + client = self._require_client() + client.authorize(token) + print("Authenticated") @with_category(VSS_COMMANDS) - @with_argparser(ap_getValues) - def do_getValues(self, args): - """Get the value of given paths""" - if self.connection_established(): - resp = self.commThread.getValues(args.Path, args.attribute) - print(highlight(resp, lexers.JsonLexer(), formatters.TerminalFormatter())) - self.pathCompletionItems = [] + @with_argparser(ap_get) + def do_get(self, args): + """Get the value of one or more paths""" + client = self._require_client() + try: + result = client.get(args.Path if len(args.Path) > 1 else args.Path[0]) + except KuksaError as exc: + print(f"Error: {exc}") + return + if isinstance(result, dict): + self._print_json({path: dp.value for path, dp in result.items()}) + else: + self._print_json({"value": result.value, "timestamp": result.timestamp}) @with_category(VSS_COMMANDS) - @with_argparser(ap_getTargetValue) - def do_getTargetValue(self, args): - """Get the value of a path""" - if self.connection_established(): - resp = self.commThread.getValue(args.Path, "targetValue") - print(highlight(resp, lexers.JsonLexer(), formatters.TerminalFormatter())) - self.pathCompletionItems = [] + @with_argparser(ap_set) + def do_set(self, args): + """Set the value of one or more paths""" + client = self._require_client() + try: + updates = coerce_assignments(client, getattr(args, "Path=Value")) + except (KuksaError, ValueError) as exc: + print(f"Error: {exc}") + return + try: + client.set(updates) + except KuksaError as exc: + print(f"Error: {exc}") @with_category(VSS_COMMANDS) - @with_argparser(ap_getTargetValues) - def do_getTargetValues(self, args): - """Get the value of given paths""" - if self.connection_established(): - resp = self.commThread.getValues(args.Path, "targetValue") - print(highlight(resp, lexers.JsonLexer(), formatters.TerminalFormatter())) - self.pathCompletionItems = [] + @with_argparser(ap_actuate) + def do_actuate(self, args): + """Actuate one or more actuators (target values)""" + client = self._require_client() + try: + updates = coerce_assignments(client, getattr(args, "Path=Value")) + except (KuksaError, ValueError) as exc: + print(f"Error: {exc}") + return + try: + client.actuate(updates) + except KuksaError as exc: + print(f"Error: {exc}") @with_category(VSS_COMMANDS) @with_argparser(ap_subscribe) def do_subscribe(self, args): - """Subscribe to updates of given paths""" - if self.connection_established(): - if args.output_to_file: - logPath = ( - pathlib.Path.cwd() - / f"log_{'_'.join(args.Path).replace('/', '.')}_{args.attribute}_{str(time.time())}" + """Subscribe to updates of one or more paths""" + client = self._require_client() + if args.background: + thread = threading.Thread( + target=self._subscribe_background, + args=(client, args.Path), + daemon=True, + ) + self._subscribe_threads.append(thread) + thread.start() + print(f"Subscribed to {', '.join(args.Path)} (background)") + return + try: + for updates in client.subscribe(args.Path): + self._print_json({path: dp.value for path, dp in updates.items()}) + except KuksaError as exc: + print(f"Error: {exc}") + + def _subscribe_background(self, client, paths): + try: + for updates in client.subscribe(paths): + message = highlight( + json.dumps( + {path: dp.value for path, dp in updates.items()}, + indent=2, + default=str, + ), + lexers.JsonLexer(), + formatters.TerminalFormatter(), ) - callback = functools.partial(self.subscribeCallback, logPath) - else: - callback = functools.partial(self.subscribeCallback, None) - - resp = self.commThread.subscribeMultiple(args.Path, callback, args.attribute) - resJson = json.loads(resp) - if "subscriptionId" in resJson: - self.subscribeIds.add(resJson["subscriptionId"]) - if args.output_to_file: - logPath.touch() - print(f"Subscription log available at {logPath}") - print(highlight(resp, lexers.JsonLexer(), formatters.TerminalFormatter())) - self.pathCompletionItems = [] + self.add_alert(msg=message) + except KuksaError as exc: + if client.connected: + self.add_alert(msg=f"Subscription error: {exc}") + except Exception: + # The stream was terminated, e.g. by a disconnect. + pass @with_category(VSS_COMMANDS) - @with_argparser(ap_unsubscribe) - def do_unsubscribe(self, args): - """Unsubscribe an existing subscription""" - if self.connection_established(): - resp = self.commThread.unsubscribe(args.SubscribeId) - print(highlight(resp, lexers.JsonLexer(), formatters.TerminalFormatter())) - self.subscribeIds.discard(args.SubscribeId) - self.pathCompletionItems = [] + @with_argparser(ap_get_metadata) + def do_get_metadata(self, args): + """Get the metadata of a path""" + client = self._require_client() + try: + metadata = client.get_metadata(args.Path) + self._print_json(_metadata_to_dict(metadata)) + except KuksaError as exc: + print(f"Error: {exc}") - def stop(self): - if self.commThread is not None: - self.commThread.stop() - self.commThread.join() - - def getMetaData(self, path): - """Get MetaData of the path""" - if self.connection_established(): - return self.commThread.getMetaData(path) - return "{}" - - @with_category(VSS_COMMANDS_SERVER) - @with_argparser(ap_updateVSSTree) - def do_updateVSSTree(self, args): - """Update VSS Tree Entry""" - if self.connection_established(): - resp = self.commThread.updateVSSTree(args.Json) - if resp is not None: - print(highlight(resp, lexers.JsonLexer(), formatters.TerminalFormatter())) + @with_category(VSS_COMMANDS) + @with_argparser(ap_list_metadata) + def do_list_metadata(self, args): + """List metadata of signals matching a pattern""" + client = self._require_client() + try: + metadatas = client.list_metadata(args.Pattern) + self._print_json([_metadata_to_dict(m) for m in metadatas]) + except KuksaError as exc: + print(f"Error: {exc}") @with_category(VSS_COMMANDS) - @with_argparser(ap_updateMetaData) - def do_updateMetaData(self, args): - """Update MetaData of a given path""" - if self.connection_established(): - resp = self.commThread.updateMetaData(args.Path, args.Json) - print(highlight(resp, lexers.JsonLexer(), formatters.TerminalFormatter())) + @with_argparser(ap_expand) + def do_expand(self, args): + """Expand a wildcard pattern into concrete signal paths""" + client = self._require_client() + try: + entry_type = EntryType[args.entry_type] if args.entry_type else None + paths = client.expand(args.Pattern, entry_type=entry_type) + self._print_json(paths) + except KuksaError as exc: + print(f"Error: {exc}") @with_category(VSS_COMMANDS) - @with_argparser(ap_getMetaData) - def do_getMetaData(self, args): - """Get MetaData of the path""" - resp = self.getMetaData(args.Path) - print(highlight(resp, lexers.JsonLexer(), formatters.TerminalFormatter())) - self.pathCompletionItems = [] + @with_argparser(ap_has_signal) + def do_has_signal(self, args): + """Check whether a signal exists""" + client = self._require_client() + try: + print(client.has_signal(args.Path)) + except KuksaError as exc: + print(f"Error: {exc}") - @with_category(COMM_SETUP_COMMANDS) - @with_argparser(ap_disconnect) - def do_disconnect(self, _args): - """Disconnect from the VISS/gRPC Server""" - if hasattr(self, "commThread"): - if self.commThread is not None: - self.commThread.stop() - self.commThread = None - - def connection_established(self) -> bool: - """ - Check if thread has established a connection to the broker/server. - Note that this method does not indicate the current state of the connection, - This method may return True even if the broker/server currently is not reachable. - """ - if self.commThread is None or not self.commThread.connection_established(): - self.connect() - return self.commThread.connection_established() + @with_category(INFO_COMMANDS) + def do_info(self, _args): + """Show summary info of the client""" + print("kuksa-client version " + _metadata.__version__) + print("Uri: " + _metadata.__uri__) + print("Author: " + _metadata.__author__) + print("Copyright: " + _metadata.__copyright__) - def connect(self): - """Connect to the VISS/gRPC Server""" - if hasattr(self, "commThread"): - if self.commThread is not None: - self.commThread.stop() - self.commThread = None + @with_category(INFO_COMMANDS) + def do_version(self, _args): + """Show the client version""" + print(_metadata.__version__) - # Check we have a valid server URI - srv = urlparse(self.server) - config = {"port": 55555, "insecure": True} + def stop(self): + if self.client is not None: + self.client.disconnect() + self.client = None + self._stop_subscriptions() + + +def _metadata_to_dict(metadata): + result = { + "path": metadata.path, + "data_type": metadata.data_type.name, + "entry_type": metadata.entry_type.name, + } + for field in ("description", "comment", "deprecation", "unit"): + value = getattr(metadata, field, None) + if value is not None: + result[field] = value + if metadata.value_restriction is not None: + result["value_restriction"] = { + "min": metadata.value_restriction.min, + "max": metadata.value_restriction.max, + "allowed_values": metadata.value_restriction.allowed_values, + } + return result + + +# --------------------------------------------------------------------------- +# One-shot commands +# --------------------------------------------------------------------------- + +def _build_one_shot_parser(): + parser = argparse.ArgumentParser(prog="kuksa-client", description="KUKSA Databroker client") + parser.add_argument( + "--server", + default=DEFAULT_KUKSA_ADDRESS, + help="Databroker to connect to. Format: grpc://host[:port] or grpcs://host[:port].", + ) + parser.add_argument("--token", default=DEFAULT_TOKEN_OR_TOKENFILE, help="JWT token or path to a .token file") + parser.add_argument("--cacertificate", default=DEFAULT_CACERTIFICATE, help="Client root cert file (.pem)") + parser.add_argument("--tls-server-name", default=DEFAULT_TLS_SERVER_NAME, help="CA name of the server") - if srv.scheme in ["grpc", "grpcs"]: - config["protocol"] = "grpc" - elif srv.scheme in ["ws", "wss"]: - config["protocol"] = "ws" - config["port"] = 8090 - else: - print(f"Invalid server URI. Unsupported protocol: {srv.scheme} ") - return + subparsers = parser.add_subparsers(dest="command") - if srv.port is not None: - config["port"] = srv.port + p_get = subparsers.add_parser("get", help="Get the value of one or more paths") + p_get.add_argument("paths", nargs="+") - if srv.scheme in ["grpcs", "wss"]: - if self.cacertificate is None: - print("TLS cannot be used as no CA Certificate specifed!") - else: - config["insecure"] = False + p_set = subparsers.add_parser("set", help="Set values, e.g. Vehicle.Speed=42") + p_set.add_argument("assignments", nargs="+", help="Path=Value pairs") - if srv.hostname is None: - print("No hostname or IP given") - return + p_act = subparsers.add_parser("actuate", help="Actuate actuators, e.g. Vehicle.Body.Wiper.Pos=45") + p_act.add_argument("assignments", nargs="+", help="Path=Value pairs") - config["ip"] = srv.hostname + p_sub = subparsers.add_parser("subscribe", help="Subscribe to one or more paths") + p_sub.add_argument("paths", nargs="+") - # Explain were we are connecting to: - print( - f"Connecting to VSS server at {config['ip']} port {config['port']} \ -using {'KUKSA GRPC' if config['protocol'] == 'grpc' else 'VISS'} protocol." - ) - print(f"TLS will {'not be' if config['insecure'] else 'be'} used.") + p_md = subparsers.add_parser("get-metadata", help="Get the metadata of a path") + p_md.add_argument("path") - # Configs should only be added if they actually have a value - if self.token_or_tokenfile is not None: - config["token_or_tokenfile"] = self.token_or_tokenfile - if self.cacertificate is not None: - config["cacertificate"] = self.cacertificate - if self.tls_server_name is not None: - config["tls_server_name"] = self.tls_server_name + p_lmd = subparsers.add_parser("list-metadata", help="List metadata matching a pattern") + p_lmd.add_argument("pattern") - self.commThread = KuksaClientThread(config) - self.commThread.start() + p_exp = subparsers.add_parser("expand", help="Expand a wildcard pattern into paths") + p_exp.add_argument("pattern") - waitForConnection = threading.Condition() - waitForConnection.acquire() - waitForConnection.wait_for(self.commThread.connection_established, timeout=1) - waitForConnection.release() + p_has = subparsers.add_parser("has-signal", help="Check whether a signal exists") + p_has.add_argument("path") - if self.commThread.connection_established(): - pass - else: - print( - "Error: Websocket could not be connected or the gRPC channel could not be created." - ) - self.commThread.stop() - self.commThread = None + subparsers.add_parser("server-info", help="Show databroker info") - @with_category(COMM_SETUP_COMMANDS) - @with_argparser(ap_connect) - def do_connect(self, args): - """Connect to a VSS server""" - self.server = args.server - self.connect() + return parser - @with_category(INFO_COMMANDS) - def do_info(self, _args): - """Show summary info of the client""" - print("kuksa-client version " + _metadata.__version__) - print("Uri: " + _metadata.__uri__) - print("Author: " + _metadata.__author__) - print("Copyright: " + _metadata.__copyright__) - @with_category(INFO_COMMANDS) - def do_version(self, _args): - """Show version of the client""" - print(_metadata.__version__) +def _open_client(args): + srv = urlparse(args.server) + host = srv.hostname or "127.0.0.1" + port = srv.port or 55555 + kwargs = {"host": host, "port": port, "tls_server_name": args.tls_server_name} + token = args.token + if token and pathlib.Path(token).is_file(): + token = pathlib.Path(token).read_text(encoding="utf-8").rstrip("\n") + if token: + kwargs["token"] = token + if srv.scheme == "grpcs": + if args.cacertificate is None: + raise KuksaError("TLS cannot be used as no CA Certificate was specified!") + kwargs["root_certificates"] = pathlib.Path(args.cacertificate) + return KuksaClient(**kwargs) -# pylint: enable=too-many-public-methods -# pylint: enable=too-many-instance-attributes +def _run_one_shot(args): + client = _open_client(args) + try: + with client: + command = args.command + if command == "get": + paths = args.paths + result = client.get(paths if len(paths) > 1 else paths[0]) + if isinstance(result, dict): + print(json.dumps({p: dp.value for p, dp in result.items()}, indent=2, default=str)) + else: + print(json.dumps({"value": result.value, "timestamp": result.timestamp}, indent=2, default=str)) + elif command == "set": + client.set(coerce_assignments(client, args.assignments)) + elif command == "actuate": + client.actuate(coerce_assignments(client, args.assignments)) + elif command == "subscribe": + for updates in client.subscribe(args.paths): + print(json.dumps({p: dp.value for p, dp in updates.items()}, default=str)) + elif command == "get-metadata": + print(json.dumps(_metadata_to_dict(client.get_metadata(args.path)), indent=2)) + elif command == "list-metadata": + print(json.dumps([_metadata_to_dict(m) for m in client.list_metadata(args.pattern)], indent=2)) + elif command == "expand": + print("\n".join(client.expand(args.pattern))) + elif command == "has-signal": + print(client.has_signal(args.path)) + elif command == "server-info": + info = client.get_server_info() + print( + json.dumps( + { + "name": info.name, + "version": info.version, + "commit_hash": info.commit_hash, + }, + indent=2, + ) + ) + except (KuksaError, NotFound, ValueError) as exc: + print(f"Error: {exc}", file=sys.stderr) + return 1 + return 0 -# Main Function def main(): - kuksa_logger = KuksaLogger() kuksa_logger.init_logging() - parser = Cmd2ArgumentParser() - parser.add_argument( - "server", - nargs="?", - help=f"VSS server to connect to. Format: protocol://host[:port]. \ - Supported protocols: [grpc, grpcs, ws, wss]. Example: {DEFAULT_KUKSA_ADDRESS}", - default=DEFAULT_KUKSA_ADDRESS, - ) - parser.add_argument( - "--token_or_tokenfile", - default=DEFAULT_TOKEN_OR_TOKENFILE, - help="JWT token or path to a JWT token file (.token)", - ) - - # Add TLS arguments - parser.add_argument( - "--cacertificate", - default=DEFAULT_CACERTIFICATE, - help="Client root cert file (.pem). \ - Needed for TLS enabled transports (grpcs, wss)", - ) - # Observations for Python - # Connecting to "localhost" works well, subjectAltName seems to suffice - # Connecting to "127.0.0.1" does not work unless server-name specified - # For KUKSA.val example certs default name is "Server" - parser.add_argument( - "--tls-server-name", - default=DEFAULT_TLS_SERVER_NAME, - help="CA name of server, needed in some cases where subjectAltName does not suffice", - ) - + parser = _build_one_shot_parser() args = parser.parse_args() - clientApp = TestClient( + if args.command: + return _run_one_shot(args) + + shell = KuksaShell( args.server, - token_or_tokenfile=args.token_or_tokenfile, + token_or_tokenfile=args.token, cacertificate=args.cacertificate, tls_server_name=args.tls_server_name, ) try: - # We exit the loop when the user types "quit" or hits Ctrl-D. - clientApp.cmdloop() + shell.cmdloop() finally: - clientApp.stop() + shell.stop() + return 0 if __name__ == "__main__": diff --git a/kuksa-client/kuksa_client/grpc/__init__.py b/kuksa-client/kuksa_client/grpc/__init__.py index d98b893..f788759 100644 --- a/kuksa-client/kuksa_client/grpc/__init__.py +++ b/kuksa-client/kuksa_client/grpc/__init__.py @@ -23,6 +23,7 @@ import enum import logging import re +import warnings from typing import Any from typing import Collection from typing import Dict @@ -45,6 +46,12 @@ logger = logging.getLogger(__name__) +warnings.warn( + "kuksa_client.grpc is deprecated. Use the new kuksa_client.v2 API instead.", + DeprecationWarning, + stacklevel=2, +) + class DataType(enum.IntEnum): UNSPECIFIED = types_v1.DATA_TYPE_UNSPECIFIED diff --git a/kuksa-client/kuksa_client/grpc/aio.py b/kuksa-client/kuksa_client/grpc/aio.py index bdfa244..64be0da 100644 --- a/kuksa-client/kuksa_client/grpc/aio.py +++ b/kuksa-client/kuksa_client/grpc/aio.py @@ -19,6 +19,7 @@ import asyncio import contextlib import logging +import warnings from typing import AsyncIterator from typing import Callable from typing import Collection @@ -52,6 +53,12 @@ logger = logging.getLogger(__name__) +warnings.warn( + "kuksa_client.grpc.aio is deprecated. Use the new kuksa_client.v2.aio API instead.", + DeprecationWarning, + stacklevel=2, +) + class VSSClient(BaseVSSClient): def __init__(self, *args, **kwargs): diff --git a/kuksa-client/kuksa_client/v2/__init__.py b/kuksa-client/kuksa_client/v2/__init__.py new file mode 100644 index 0000000..78c33e9 --- /dev/null +++ b/kuksa-client/kuksa_client/v2/__init__.py @@ -0,0 +1,399 @@ +# /******************************************************************************** +# * Copyright (c) 2026 Contributors to the Eclipse Foundation +# * +# * See the NOTICE file(s) distributed with this work for additional +# * information regarding copyright ownership. +# * +# * This program and the accompanying materials are made available under the +# * terms of the Apache License 2.0 which is available at +# * http://www.apache.org/licenses/LICENSE-2.0 +# * +# * SPDX-License-Identifier: Apache-2.0 +# ********************************************************************************/ + +""" +Public surface of the redesigned ``kuksa.val.v2`` SDK. + +The synchronous client is exposed here as :class:`KuksaClient`; the async +variant lives in :mod:`kuksa_client.v2.aio`. +""" + +from __future__ import annotations + +import contextlib +from pathlib import Path +from typing import Any +from typing import Dict +from typing import Iterable +from typing import Iterator +from typing import List +from typing import Optional + +import grpc +from kuksa.val.v2 import val_pb2_grpc + +from . import patterns +from . import transport +from .core import _KuksaCore +from .errors import Aborted +from .errors import AlreadyExists +from .errors import DataLoss +from .errors import InvalidArgument +from .errors import KuksaError +from .errors import KuksaStreamError +from .errors import KuksaTransportError +from .errors import NotFound +from .errors import PermissionDenied +from .errors import Unauthenticated +from .errors import Unavailable +from .errors import from_grpc_error +from .provider import Provider +from .types import DataType +from .types import Datapoint +from .types import EntryType +from .types import Metadata +from .types import ServerInfo +from .types import ValueRestriction + +__all__ = [ + "KuksaClient", + "Provider", + "DataType", + "EntryType", + "Datapoint", + "Metadata", + "ValueRestriction", + "ServerInfo", + "KuksaError", + "KuksaTransportError", + "KuksaStreamError", + "NotFound", + "InvalidArgument", + "PermissionDenied", + "Unauthenticated", + "Unavailable", + "AlreadyExists", + "Aborted", + "DataLoss", +] + + +class KuksaClient(_KuksaCore): + """Synchronous client for a ``kuksa.val.v2`` databroker.""" + + def __init__( + self, + host: str = "127.0.0.1", + port: int = 55555, + token: Optional[str] = None, + root_certificates: Optional[Path] = None, + tls_server_name: Optional[str] = None, + ensure_startup_connection: bool = True, + ): + super().__init__( + host, + port, + token=token, + root_certificates=root_certificates, + tls_server_name=tls_server_name, + ensure_startup_connection=ensure_startup_connection, + ) + self._channel = None + self._stub = None + self._exit_stack = contextlib.ExitStack() + + # ------------------------------------------------------------------ + # Connection management + # ------------------------------------------------------------------ + def __enter__(self) -> "KuksaClient": + self.connect() + return self + + def __exit__(self, exc_type, exc_value, traceback) -> None: + self.disconnect() + + def connect(self) -> None: + self.disconnect() + self._channel = self._exit_stack.enter_context( + transport.create_sync_channel( + self.host, self.port, self.root_certificates, self.tls_server_name + ) + ) + self._stub = val_pb2_grpc.VALStub(self._channel) + self._connected = True + self._metadata_store.invalidate() + + def disconnect(self) -> None: + self._exit_stack.close() + self._channel = None + self._stub = None + self._connected = False + + @property + def connected(self) -> bool: + return self._connected + + # ------------------------------------------------------------------ + # Escape hatch: raw v2 stub + # ------------------------------------------------------------------ + @property + def stub(self): + """The raw ``kuksa.val.v2.VALStub`` for power users.""" + return self._stub + + # ------------------------------------------------------------------ + # I/O primitives + # ------------------------------------------------------------------ + def _call(self, rpc_name: str, request, timeout: Optional[float] = None): + return getattr(self._stub, rpc_name)( + request, timeout=timeout, metadata=self._metadata_kwargs() + ) + + def _stream(self, rpc_name: str, request, timeout: Optional[float] = None): + return getattr(self._stub, rpc_name)( + request, timeout=timeout, metadata=self._metadata_kwargs() + ) + + # ------------------------------------------------------------------ + # Internal metadata helpers + # ------------------------------------------------------------------ + def _fetch_metadata(self, root: str) -> List[Metadata]: + try: + response = self._call( + "ListMetadata", self._build_list_metadata_request(root) + ) + except grpc.RpcError as exc: + raise from_grpc_error(exc) from exc + return self._parse_and_cache_list_metadata(response) + + def _resolve_data_types(self, paths: Iterable[str]) -> Dict[str, DataType]: + result: Dict[str, DataType] = {} + for path in paths: + data_type = self._metadata_store.data_type(path) + if data_type is not None and data_type != DataType.UNSPECIFIED: + result[path] = data_type + continue + metadata = self._metadata_store.get(path) + if metadata is not None: + result[path] = metadata.data_type + continue + if self._metadata_store.is_missing(path): + raise NotFound(f"Path '{path}' does not exist on the server") + self._fetch_metadata(path) + metadata = self._metadata_store.get(path) + if metadata is None: + self._metadata_store.mark_missing(path) + raise NotFound(f"Path '{path}' does not exist on the server") + result[path] = metadata.data_type + return result + + def _resolve_signal_ids(self, paths: Iterable[str]) -> Dict[str, int]: + result: Dict[str, int] = {} + for path in paths: + signal_id = self._metadata_store.signal_id(path) + if signal_id is not None: + result[path] = signal_id + continue + if self._metadata_store.is_missing(path): + raise NotFound(f"Path '{path}' does not exist on the server") + self._fetch_metadata(path) + signal_id = self._metadata_store.signal_id(path) + if signal_id is None: + self._metadata_store.mark_missing(path) + raise NotFound(f"Path '{path}' does not exist on the server") + result[path] = signal_id + return result + + def _existing_signals(self, paths: Iterable[str]) -> set: + existing = set() + for path in paths: + if self._metadata_store.has(path): + existing.add(path) + continue + if self._metadata_store.is_missing(path): + continue + try: + self._fetch_metadata(path) + except NotFound: + self._metadata_store.mark_missing(path) + continue + if self._metadata_store.has(path): + existing.add(path) + else: + self._metadata_store.mark_missing(path) + return existing + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + @staticmethod + def _normalize_updates(values: Dict[str, Any]) -> Dict[str, Datapoint]: + return { + path: (value if isinstance(value, Datapoint) else Datapoint(value=value)) + for path, value in values.items() + } + + def get(self, path_or_paths): + """ + Get the current value of a signal, or of several signals. + + With a single path returns a :class:`Datapoint`; with a collection of + paths returns ``Dict[str, Datapoint]``. A missing path raises + :class:`NotFound`; a path that exists but has no data yet yields + ``Datapoint(value=None)``. + """ + self._check_connected() + if isinstance(path_or_paths, str): + try: + response = self._call( + "GetValue", self._build_get_value_request(path_or_paths) + ) + except grpc.RpcError as exc: + raise from_grpc_error(exc) from exc + return self._parse_get_value_response(response) + + paths = list(path_or_paths) + try: + response = self._call("GetValues", self._build_get_values_request(paths)) + except grpc.RpcError as exc: + raise from_grpc_error(exc) from exc + return self._parse_get_values_response(response, paths) + + def set( + self, + values: Dict[str, Any], + data_type: Optional[DataType] = None, + ) -> None: + """ + Set the current value of signals. + + ``values`` maps signal paths to native Python values or + :class:`Datapoint`. Data types are auto-resolved (and cached); pass + ``data_type`` to bypass the lookup for every path. + """ + self._check_connected() + updates = self._normalize_updates(values) + if data_type is not None: + data_types = {path: data_type for path in updates} + else: + data_types = self._resolve_data_types(updates.keys()) + for path, datapoint in updates.items(): + try: + self._call( + "PublishValue", + self._build_publish_value_request( + path, datapoint, data_types[path] + ), + ) + except grpc.RpcError as exc: + raise from_grpc_error(exc) from exc + + def actuate( + self, + values: Dict[str, Any], + data_type: Optional[DataType] = None, + ) -> None: + """ + Actuate one or more actuators simultaneously (target values). + """ + self._check_connected() + updates = self._normalize_updates(values) + if data_type is not None: + data_types = {path: data_type for path in updates} + else: + data_types = self._resolve_data_types(updates.keys()) + try: + self._call( + "BatchActuate", + self._build_batch_actuate_request(updates, data_types), + ) + except grpc.RpcError as exc: + raise from_grpc_error(exc) from exc + + def subscribe( + self, + paths: Iterable[str], + buffer_size: Optional[int] = None, + ) -> Iterator[Dict[str, Datapoint]]: + """ + Subscribe to updates of ``paths``. + + Yields ``Dict[str, Datapoint]`` for each batch of updates. The current + value of every subscribed signal is yielded immediately. + """ + self._check_connected() + request = self._build_subscribe_request(paths, buffer_size) + try: + stream = self._stream("Subscribe", request) + for response in stream: + yield self._parse_subscribe_response(response) + except grpc.RpcError as exc: + raise from_grpc_error(exc) from exc + + def get_metadata(self, path: str) -> Metadata: + """Return the metadata of a single signal.""" + self._check_connected() + self._resolve_data_types([path]) + metadata = self._metadata_store.get(path) + if metadata is None: + raise NotFound(f"Path '{path}' does not exist on the server") + return metadata + + def list_metadata(self, pattern: str) -> List[Metadata]: + """ + Return metadata for signals matching ``pattern`` (exact path or + wildcard), sorted by path. + """ + self._check_connected() + root = patterns.literal_prefix(pattern) + metadatas = self._fetch_metadata(root) + return sorted( + self._filter_by_pattern(metadatas, pattern), + key=lambda m: m.path or "", + ) + + def expand( + self, pattern: str, entry_type: Optional[EntryType] = None + ) -> List[str]: + """ + Return the concrete leaf signal paths matching ``pattern``. + + Optionally filter by :class:`EntryType` (SENSOR / ACTUATOR / ATTRIBUTE). + """ + self._check_connected() + metadatas = self.list_metadata(pattern) + paths = [ + metadata.path + for metadata in metadatas + if entry_type is None or metadata.entry_type == entry_type + ] + return sorted(paths) + + def has_signal(self, path: str) -> bool: + """Return whether ``path`` is a signal known to the databroker.""" + self._check_connected() + return bool(self._existing_signals([path])) + + def has_signals(self, paths: Iterable[str]) -> bool: + """Return whether all given ``paths`` exist.""" + self._check_connected() + paths = list(paths) + return len(self._existing_signals(paths)) == len(paths) + + def missing_signals(self, paths: Iterable[str]) -> set: + """Return the subset of ``paths`` that do not exist.""" + self._check_connected() + paths = list(paths) + existing = self._existing_signals(paths) + return {path for path in paths if path not in existing} + + def get_server_info(self) -> ServerInfo: + """Return databroker name / version / commit hash.""" + self._check_connected() + try: + response = self._call( + "GetServerInfo", self._build_get_server_info_request() + ) + except grpc.RpcError as exc: + raise from_grpc_error(exc) from exc + return self._parse_server_info(response) diff --git a/kuksa-client/kuksa_client/v2/aio.py b/kuksa-client/kuksa_client/v2/aio.py new file mode 100644 index 0000000..783179e --- /dev/null +++ b/kuksa-client/kuksa_client/v2/aio.py @@ -0,0 +1,462 @@ +# /******************************************************************************** +# * Copyright (c) 2026 Contributors to the Eclipse Foundation +# * +# * See the NOTICE file(s) distributed with this work for additional +# * information regarding copyright ownership. +# * +# * This program and the accompanying materials are made available under the +# * terms of the Apache License 2.0 which is available at +# * http://www.apache.org/licenses/LICENSE-2.0 +# * +# * SPDX-License-Identifier: Apache-2.0 +# ********************************************************************************/ + +""" +Asynchronous (asyncio) client and provider for ``kuksa.val.v2``. +""" + +from __future__ import annotations + +import asyncio +import contextlib +from pathlib import Path +from typing import Any +from typing import AsyncIterator +from typing import Dict +from typing import Iterable +from typing import List +from typing import Optional + +import grpc +from kuksa.val.v2 import val_pb2_grpc + +from . import patterns +from . import transport +from .core import _KuksaCore +from .errors import NotFound +from .errors import from_grpc_error +from .provider import ActuationRequest +from .provider import _ProviderBase +from .provider import _STOP +from .types import DataType +from .types import Datapoint +from .types import EntryType +from .types import Metadata +from .types import ServerInfo + +__all__ = ["KuksaClient", "Provider"] + + +class KuksaClient(_KuksaCore): + """Asynchronous client for a ``kuksa.val.v2`` databroker.""" + + def __init__( + self, + host: str = "127.0.0.1", + port: int = 55555, + token: Optional[str] = None, + root_certificates: Optional[Path] = None, + tls_server_name: Optional[str] = None, + ensure_startup_connection: bool = True, + ): + super().__init__( + host, + port, + token=token, + root_certificates=root_certificates, + tls_server_name=tls_server_name, + ensure_startup_connection=ensure_startup_connection, + ) + self._channel = None + self._stub = None + self._exit_stack = contextlib.AsyncExitStack() + + async def __aenter__(self) -> "KuksaClient": + await self.connect() + return self + + async def __aexit__(self, exc_type, exc_value, traceback) -> None: + await self.disconnect() + + async def connect(self) -> None: + await self.disconnect() + self._channel = await self._exit_stack.enter_async_context( + transport.create_aio_channel( + self.host, self.port, self.root_certificates, self.tls_server_name + ) + ) + self._stub = val_pb2_grpc.VALStub(self._channel) + self._connected = True + self._metadata_store.invalidate() + + async def disconnect(self) -> None: + await self._exit_stack.aclose() + self._channel = None + self._stub = None + self._connected = False + + @property + def connected(self) -> bool: + return self._connected + + @property + def stub(self): + """The raw ``kuksa.val.v2.VALStub`` for power users.""" + return self._stub + + async def _call(self, rpc_name: str, request, timeout: Optional[float] = None): + return await getattr(self._stub, rpc_name)( + request, timeout=timeout, metadata=self._metadata_kwargs() + ) + + def _stream(self, rpc_name: str, request, timeout: Optional[float] = None): + return getattr(self._stub, rpc_name)( + request, timeout=timeout, metadata=self._metadata_kwargs() + ) + + # ------------------------------------------------------------------ + # Internal metadata helpers + # ------------------------------------------------------------------ + async def _fetch_metadata(self, root: str) -> List[Metadata]: + try: + response = await self._call( + "ListMetadata", self._build_list_metadata_request(root) + ) + except grpc.RpcError as exc: + raise from_grpc_error(exc) from exc + return self._parse_and_cache_list_metadata(response) + + async def _resolve_data_types(self, paths: Iterable[str]) -> Dict[str, DataType]: + result: Dict[str, DataType] = {} + for path in paths: + data_type = self._metadata_store.data_type(path) + if data_type is not None and data_type != DataType.UNSPECIFIED: + result[path] = data_type + continue + metadata = self._metadata_store.get(path) + if metadata is not None: + result[path] = metadata.data_type + continue + if self._metadata_store.is_missing(path): + raise NotFound(f"Path '{path}' does not exist on the server") + await self._fetch_metadata(path) + metadata = self._metadata_store.get(path) + if metadata is None: + self._metadata_store.mark_missing(path) + raise NotFound(f"Path '{path}' does not exist on the server") + result[path] = metadata.data_type + return result + + async def _resolve_signal_ids(self, paths: Iterable[str]) -> Dict[str, int]: + result: Dict[str, int] = {} + for path in paths: + signal_id = self._metadata_store.signal_id(path) + if signal_id is not None: + result[path] = signal_id + continue + if self._metadata_store.is_missing(path): + raise NotFound(f"Path '{path}' does not exist on the server") + await self._fetch_metadata(path) + signal_id = self._metadata_store.signal_id(path) + if signal_id is None: + self._metadata_store.mark_missing(path) + raise NotFound(f"Path '{path}' does not exist on the server") + result[path] = signal_id + return result + + async def _existing_signals(self, paths: Iterable[str]) -> set: + existing = set() + for path in paths: + if self._metadata_store.has(path): + existing.add(path) + continue + if self._metadata_store.is_missing(path): + continue + try: + await self._fetch_metadata(path) + except NotFound: + self._metadata_store.mark_missing(path) + continue + if self._metadata_store.has(path): + existing.add(path) + else: + self._metadata_store.mark_missing(path) + return existing + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + @staticmethod + def _normalize_updates(values: Dict[str, Any]) -> Dict[str, Datapoint]: + return { + path: (value if isinstance(value, Datapoint) else Datapoint(value=value)) + for path, value in values.items() + } + + async def get(self, path_or_paths): + self._check_connected() + if isinstance(path_or_paths, str): + try: + response = await self._call( + "GetValue", self._build_get_value_request(path_or_paths) + ) + except grpc.RpcError as exc: + raise from_grpc_error(exc) from exc + return self._parse_get_value_response(response) + + paths = list(path_or_paths) + try: + response = await self._call( + "GetValues", self._build_get_values_request(paths) + ) + except grpc.RpcError as exc: + raise from_grpc_error(exc) from exc + return self._parse_get_values_response(response, paths) + + async def set( + self, values: Dict[str, Any], data_type: Optional[DataType] = None + ) -> None: + self._check_connected() + updates = self._normalize_updates(values) + if data_type is not None: + data_types = {path: data_type for path in updates} + else: + data_types = await self._resolve_data_types(updates.keys()) + for path, datapoint in updates.items(): + try: + await self._call( + "PublishValue", + self._build_publish_value_request( + path, datapoint, data_types[path] + ), + ) + except grpc.RpcError as exc: + raise from_grpc_error(exc) from exc + + async def actuate( + self, values: Dict[str, Any], data_type: Optional[DataType] = None + ) -> None: + self._check_connected() + updates = self._normalize_updates(values) + if data_type is not None: + data_types = {path: data_type for path in updates} + else: + data_types = await self._resolve_data_types(updates.keys()) + try: + await self._call( + "BatchActuate", + self._build_batch_actuate_request(updates, data_types), + ) + except grpc.RpcError as exc: + raise from_grpc_error(exc) from exc + + async def subscribe( + self, paths: Iterable[str], buffer_size: Optional[int] = None + ) -> AsyncIterator[Dict[str, Datapoint]]: + self._check_connected() + request = self._build_subscribe_request(paths, buffer_size) + try: + stream = self._stream("Subscribe", request) + async for response in stream: + yield self._parse_subscribe_response(response) + except grpc.RpcError as exc: + raise from_grpc_error(exc) from exc + + async def get_metadata(self, path: str) -> Metadata: + self._check_connected() + await self._resolve_data_types([path]) + metadata = self._metadata_store.get(path) + if metadata is None: + raise NotFound(f"Path '{path}' does not exist on the server") + return metadata + + async def list_metadata(self, pattern: str) -> List[Metadata]: + self._check_connected() + root = patterns.literal_prefix(pattern) + metadatas = await self._fetch_metadata(root) + return sorted( + self._filter_by_pattern(metadatas, pattern), + key=lambda m: m.path or "", + ) + + async def expand( + self, pattern: str, entry_type: Optional[EntryType] = None + ) -> List[str]: + self._check_connected() + metadatas = await self.list_metadata(pattern) + paths = [ + metadata.path + for metadata in metadatas + if entry_type is None or metadata.entry_type == entry_type + ] + return sorted(paths) + + async def has_signal(self, path: str) -> bool: + self._check_connected() + return bool(await self._existing_signals([path])) + + async def has_signals(self, paths: Iterable[str]) -> bool: + self._check_connected() + paths = list(paths) + return len(await self._existing_signals(paths)) == len(paths) + + async def missing_signals(self, paths: Iterable[str]) -> set: + self._check_connected() + paths = list(paths) + existing = await self._existing_signals(paths) + return {path for path in paths if path not in existing} + + async def get_server_info(self) -> ServerInfo: + self._check_connected() + try: + response = await self._call( + "GetServerInfo", self._build_get_server_info_request() + ) + except grpc.RpcError as exc: + raise from_grpc_error(exc) from exc + return self._parse_server_info(response) + + +class Provider(_ProviderBase): + """Asynchronous provider backed by ``OpenProviderStream``.""" + + def __init__(self, client: KuksaClient): + super().__init__(client) + self._stream = None + self._actuation_queue: asyncio.Queue = asyncio.Queue() + self._pending: Dict[str, asyncio.Event] = {} + self._stream_error = None + self._reader_task = None + + async def __aenter__(self) -> "Provider": + await self._open() + return self + + async def __aexit__(self, exc_type, exc_value, traceback) -> None: + await self.close() + + # ------------------------------------------------------------------ + # Stream plumbing + # ------------------------------------------------------------------ + async def _open(self) -> None: + self._check_not_closed() + if self._stream is not None: + return + self._stream = self._client._stub.OpenProviderStream( + metadata=self._client._metadata_kwargs() + ) + self._reader_task = asyncio.create_task(self._run()) + + async def _run(self) -> None: + try: + async for response in self._stream: + await self._dispatch(response) + except Exception as exc: # noqa: BLE001 + self._stream_error = exc + finally: + await self._actuation_queue.put(_STOP) + for event in self._pending.values(): + event.set() + + async def _dispatch(self, response) -> None: + action = response.WhichOneof("action") + if action == "batch_actuate_stream_request": + requests = [ + self._parse_actuation_request(actuate_request) + for actuate_request in response.batch_actuate_stream_request.actuate_requests + ] + await self._actuation_queue.put(requests) + elif action in ("provide_signal_response", "provide_actuation_response"): + event = self._pending.get(action) + if event is not None: + event.set() + elif action == "publish_values_response": + self._handle_publish_response(response.publish_values_response) + + async def _send(self, request) -> None: + await self._stream.write(request) + + def _raise_if_stream_error(self) -> None: + if self._stream_error is not None: + error = self._stream_error + if isinstance(error, grpc.RpcError): + raise from_grpc_error(error) from error + raise error + + def _register(self, kind: str) -> asyncio.Event: + event = asyncio.Event() + self._pending[kind] = event + return event + + async def _wait(self, kind: str, timeout: Optional[float] = None) -> None: + event = self._pending.get(kind) + try: + if event is not None: + await asyncio.wait_for(event.wait(), timeout=timeout) + finally: + self._pending.pop(kind, None) + self._raise_if_stream_error() + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + async def provide_signals( + self, signals, timeout: Optional[float] = None + ) -> None: + await self._open() + ids = await self._client._resolve_signal_ids(signals.keys()) + request = self._build_provide_signal_request(signals, ids) + self._register("provide_signal_response") + await self._send(request) + await self._wait("provide_signal_response", timeout=timeout) + + async def provide_actuators( + self, paths: Iterable[str], timeout: Optional[float] = None + ) -> None: + await self._open() + request = self._build_provide_actuation_request(paths) + self._register("provide_actuation_response") + await self._send(request) + await self._wait("provide_actuation_response", timeout=timeout) + + async def publish(self, values) -> None: + await self._open() + ids = await self._client._resolve_signal_ids(values.keys()) + data_types = await self._client._resolve_data_types(values.keys()) + request = self._build_publish_values_request( + values, ids, data_types, self._next_request_id() + ) + await self._send(request) + + async def actuation_requests(self) -> AsyncIterator[Iterable[ActuationRequest]]: + await self._open() + while True: + item = await self._actuation_queue.get() + if item is _STOP: + self._raise_if_stream_error() + return + yield item + + async def accept( + self, + actuation_request: ActuationRequest, + ok: bool = True, + reason: Optional[str] = None, + ) -> None: + self._check_not_closed() + request = self._build_batch_actuate_stream_response( + actuation_request.signal_id, ok, reason + ) + await self._send(request) + + async def close(self) -> None: + if self._closed: + return + self._closed = True + if self._reader_task is not None: + self._reader_task.cancel() + try: + await self._reader_task + except (asyncio.CancelledError, Exception): # noqa: BLE001 + pass + self._reader_task = None diff --git a/kuksa-client/kuksa_client/v2/codec.py b/kuksa-client/kuksa_client/v2/codec.py new file mode 100644 index 0000000..7370e5d --- /dev/null +++ b/kuksa-client/kuksa_client/v2/codec.py @@ -0,0 +1,210 @@ +# /******************************************************************************** +# * Copyright (c) 2026 Contributors to the Eclipse Foundation +# * +# * See the NOTICE file(s) distributed with this work for additional +# * information regarding copyright ownership. +# * +# * This program and the accompanying materials are made available under the +# * terms of the Apache License 2.0 which is available at +# * http://www.apache.org/licenses/LICENSE-2.0 +# * +# * SPDX-License-Identifier: Apache-2.0 +# ********************************************************************************/ + +""" +Single source of truth for converting between native Python values and the +``kuksa.val.v2`` protobuf ``Value`` / ``Datapoint`` messages. + +This is the only place in the SDK that knows the ``DataType -> proto field`` +mapping. It deliberately does no string casting; values are expected to already +be native Python values of the appropriate type. +""" + +from __future__ import annotations + +import datetime +from typing import Any +from typing import Dict +from typing import Tuple + +from kuksa.val.v2 import types_pb2 + +from .types import DataType +from .types import Datapoint +from .types import EntryType +from .types import Metadata +from .types import ValueRestriction + +# DataType -> (proto Value field name, python scalar type, is_array) +# Protobuf has no int8/int16/uint8/uint16, so those alias to int32/uint32. +_FIELD_MAP: Dict[DataType, Tuple[str, type, bool]] = { + DataType.STRING: ("string", str, False), + DataType.BOOLEAN: ("bool", bool, False), + DataType.INT8: ("int32", int, False), + DataType.INT16: ("int32", int, False), + DataType.INT32: ("int32", int, False), + DataType.INT64: ("int64", int, False), + DataType.UINT8: ("uint32", int, False), + DataType.UINT16: ("uint32", int, False), + DataType.UINT32: ("uint32", int, False), + DataType.UINT64: ("uint64", int, False), + DataType.FLOAT: ("float", float, False), + DataType.DOUBLE: ("double", float, False), + DataType.STRING_ARRAY: ("string_array", str, True), + DataType.BOOLEAN_ARRAY: ("bool_array", bool, True), + DataType.INT8_ARRAY: ("int32_array", int, True), + DataType.INT16_ARRAY: ("int32_array", int, True), + DataType.INT32_ARRAY: ("int32_array", int, True), + DataType.INT64_ARRAY: ("int64_array", int, True), + DataType.UINT8_ARRAY: ("uint32_array", int, True), + DataType.UINT16_ARRAY: ("uint32_array", int, True), + DataType.UINT32_ARRAY: ("uint32_array", int, True), + DataType.UINT64_ARRAY: ("uint64_array", int, True), + DataType.FLOAT_ARRAY: ("float_array", float, True), + DataType.DOUBLE_ARRAY: ("double_array", float, True), +} + + +def data_type_to_python_type(data_type: DataType) -> type: + """Return the native Python type a ``DataType`` maps to.""" + try: + _field, python_type, is_array = _FIELD_MAP[data_type] + except KeyError as exc: + raise ValueError(f"Unsupported data type {data_type}") from exc + if is_array: + return list + return python_type + + +def _validate_scalar(value: Any, python_type: type, data_type: DataType) -> Any: + if python_type is bool: + if not isinstance(value, bool): + raise TypeError( + f"Expected bool for {data_type.name}, got {type(value).__name__}" + ) + return value + if python_type is int: + if isinstance(value, bool) or not isinstance(value, int): + raise TypeError( + f"Expected int for {data_type.name}, got {type(value).__name__}" + ) + return value + if python_type is float: + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise TypeError( + f"Expected int/float for {data_type.name}, got {type(value).__name__}" + ) + return float(value) + if python_type is str: + if not isinstance(value, str): + raise TypeError( + f"Expected str for {data_type.name}, got {type(value).__name__}" + ) + return value + raise ValueError(f"Unsupported python type {python_type}") + + +def to_proto_value(value: Any, data_type: DataType) -> types_pb2.Value: + """Encode a native Python value into a v2 ``Value`` message.""" + if value is None: + return types_pb2.Value() + if data_type == DataType.UNSPECIFIED: + raise ValueError("Cannot encode a value with UNSPECIFIED data type") + try: + field, python_type, is_array = _FIELD_MAP[data_type] + except KeyError as exc: + raise ValueError(f"Unsupported data type {data_type}") from exc + + message = types_pb2.Value() + if is_array: + if not isinstance(value, (list, tuple)): + raise TypeError( + f"Expected list/tuple for {data_type.name}, got {type(value).__name__}" + ) + array = getattr(message, field) + for item in value: + array.values.append(_validate_scalar(item, python_type, data_type)) + else: + setattr(message, field, _validate_scalar(value, python_type, data_type)) + return message + + +def from_proto_value(value: types_pb2.Value) -> Any: + """Decode a v2 ``Value`` message into a native Python value. + + Returns ``None`` if no value is present. + """ + field = value.WhichOneof("typed_value") + if field is None: + return None + raw = getattr(value, field) + if field.endswith("_array"): + return list(raw.values) + return raw + + +def to_proto_datapoint( + datapoint: Datapoint, data_type: DataType +) -> types_pb2.Datapoint: + """Encode a :class:`Datapoint` into a v2 ``Datapoint`` message.""" + message = types_pb2.Datapoint() + if datapoint.value is not None: + message.value.CopyFrom(to_proto_value(datapoint.value, data_type)) + if datapoint.timestamp is not None: + message.timestamp.FromDatetime(datapoint.timestamp) + return message + + +def from_proto_datapoint(message: types_pb2.Datapoint) -> Datapoint: + """Decode a v2 ``Datapoint`` message into a :class:`Datapoint`.""" + value = from_proto_value(message.value) + timestamp = None + if message.HasField("timestamp") and ( + message.timestamp.seconds != 0 or message.timestamp.nanos != 0 + ): + try: + timestamp = message.timestamp.ToDatetime( + tzinfo=datetime.timezone.utc + ) + except ValueError: + # Out-of-range timestamps (year > 9999) are not representable. + timestamp = None + return Datapoint(value=value, timestamp=timestamp) + + +def from_proto_metadata(message: types_pb2.Metadata) -> Metadata: + """Decode a v2 ``Metadata`` message into :class:`Metadata`.""" + value_restriction = None + allowed_values = None + minimum = None + maximum = None + + if message.HasField("allowed_values"): + allowed_values = from_proto_value(message.allowed_values) + if message.HasField("min"): + minimum = from_proto_value(message.min) + if message.HasField("max"): + maximum = from_proto_value(message.max) + if any(v is not None for v in (allowed_values, minimum, maximum)): + value_restriction = ValueRestriction( + allowed_values=allowed_values, + min=minimum, + max=maximum, + ) + + min_sample_interval = None + if message.HasField("min_sample_interval"): + min_sample_interval = message.min_sample_interval.interval_ms + + return Metadata( + path=message.path or None, + id=message.id or None, + data_type=DataType(message.data_type), + entry_type=EntryType(message.entry_type), + description=message.description or None, + comment=message.comment or None, + deprecation=message.deprecation or None, + unit=message.unit or None, + value_restriction=value_restriction, + min_sample_interval=min_sample_interval, + ) diff --git a/kuksa-client/kuksa_client/v2/core.py b/kuksa-client/kuksa_client/v2/core.py new file mode 100644 index 0000000..72f4f47 --- /dev/null +++ b/kuksa-client/kuksa_client/v2/core.py @@ -0,0 +1,260 @@ +# /******************************************************************************** +# * Copyright (c) 2026 Contributors to the Eclipse Foundation +# * +# * See the NOTICE file(s) distributed with this work for additional +# * information regarding copyright ownership. +# * +# * This program and the accompanying materials are made available under the +# * terms of the Apache License 2.0 which is available at +# * http://www.apache.org/licenses/LICENSE-2.0 +# * +# * SPDX-License-Identifier: Apache-2.0 +# ********************************************************************************/ + +""" +Transport-agnostic protocol logic shared by the synchronous and asynchronous +clients. + +This module contains no I/O of its own. Concrete clients supply the low level +``_call``, ``_stream`` and ``_open_provider_stream`` primitives; everything else +(request building, response parsing, type/id resolution, error mapping, path +expansion) lives here so it is written exactly once. +""" + +from __future__ import annotations + +import logging +from pathlib import Path +from typing import Dict +from typing import Iterable +from typing import List +from typing import Optional + +import grpc + +from kuksa.val.v2 import types_pb2 +from kuksa.val.v2 import val_pb2 + +from . import codec +from . import patterns +from .errors import KuksaError +from .errors import NotFound +from .errors import from_grpc_error +from .metadata import MetadataStore +from .types import DataType +from .types import Datapoint +from .types import EntryType +from .types import Metadata +from .types import ServerInfo + +logger = logging.getLogger(__name__) + + +class _KuksaCore: + """Shared state and protocol logic. Subclasses supply the I/O primitives.""" + + def __init__( + self, + host: str, + port: int, + token: Optional[str] = None, + root_certificates: Optional[Path] = None, + tls_server_name: Optional[str] = None, + ensure_startup_connection: bool = True, + ): + self.host = host + self.port = port + self.token = token + self.root_certificates = root_certificates + self.tls_server_name = tls_server_name + self.ensure_startup_connection = ensure_startup_connection + self._authorization_header = self._get_authorization_header(token) + self._metadata_store = MetadataStore() + self._connected = False + + # ------------------------------------------------------------------ + # Abstract I/O primitives (implemented by sync / async subclasses) + # ------------------------------------------------------------------ + def _call(self, rpc_name: str, request, timeout: Optional[float] = None): + raise NotImplementedError + + def _stream(self, rpc_name: str, request, timeout: Optional[float] = None): + raise NotImplementedError + + def connect(self): + raise NotImplementedError + + def disconnect(self): + raise NotImplementedError + + # ------------------------------------------------------------------ + # Authorization + # ------------------------------------------------------------------ + @staticmethod + def _get_authorization_header(token: Optional[str]) -> Optional[str]: + if token is None: + return None + return "Bearer " + token + + def _metadata_kwargs(self) -> List: + if self._authorization_header is None: + return [] + return [("authorization", self._authorization_header)] + + def authorize(self, token: str) -> None: + """Attach ``token`` to subsequent requests as per-call gRPC metadata.""" + self._authorization_header = self._get_authorization_header(token) + + # ------------------------------------------------------------------ + # Connection state helpers + # ------------------------------------------------------------------ + def _check_connected(self) -> None: + if not self._connected: + raise KuksaError( + "Not connected to the databroker. " + "Use the client as a context manager or call connect() first." + ) + + def _translate_rpc_error(self, exc: grpc.RpcError) -> KuksaError: + return from_grpc_error(exc) + + # ------------------------------------------------------------------ + # Request builders (pure) + # ------------------------------------------------------------------ + @staticmethod + def _build_get_value_request(path: str) -> val_pb2.GetValueRequest: + return val_pb2.GetValueRequest(signal_id=types_pb2.SignalID(path=path)) + + @staticmethod + def _build_get_values_request(paths: Iterable[str]) -> val_pb2.GetValuesRequest: + return val_pb2.GetValuesRequest( + signal_ids=[types_pb2.SignalID(path=path) for path in paths] + ) + + @staticmethod + def _build_subscribe_request( + paths: Iterable[str], buffer_size: Optional[int] = None + ) -> val_pb2.SubscribeRequest: + request = val_pb2.SubscribeRequest(signal_paths=list(paths)) + if buffer_size is not None: + request.buffer_size = buffer_size + return request + + @staticmethod + def _build_list_metadata_request(root: str) -> val_pb2.ListMetadataRequest: + return val_pb2.ListMetadataRequest(root=root) + + @staticmethod + def _build_publish_value_request( + path: str, datapoint: Datapoint, data_type: DataType + ) -> val_pb2.PublishValueRequest: + return val_pb2.PublishValueRequest( + signal_id=types_pb2.SignalID(path=path), + data_point=codec.to_proto_datapoint(datapoint, data_type), + ) + + @staticmethod + def _build_batch_actuate_request( + updates: Dict[str, Datapoint], data_types: Dict[str, DataType] + ) -> val_pb2.BatchActuateRequest: + actuate_requests = [] + for path, datapoint in updates.items(): + actuate_requests.append( + val_pb2.ActuateRequest( + signal_id=types_pb2.SignalID(path=path), + value=codec.to_proto_value(datapoint.value, data_types[path]), + ) + ) + return val_pb2.BatchActuateRequest(actuate_requests=actuate_requests) + + @staticmethod + def _build_get_server_info_request() -> val_pb2.GetServerInfoRequest: + return val_pb2.GetServerInfoRequest() + + # ------------------------------------------------------------------ + # Response parsers (pure) + # ------------------------------------------------------------------ + @staticmethod + def _parse_get_value_response(response: val_pb2.GetValueResponse) -> Datapoint: + return codec.from_proto_datapoint(response.data_point) + + @staticmethod + def _parse_get_values_response( + response: val_pb2.GetValuesResponse, paths: List[str] + ) -> Dict[str, Datapoint]: + data_points = [codec.from_proto_datapoint(dp) for dp in response.data_points] + return dict(zip(paths, data_points)) + + @staticmethod + def _parse_subscribe_response( + response: val_pb2.SubscribeResponse, + ) -> Dict[str, Datapoint]: + return { + path: codec.from_proto_datapoint(datapoint) + for path, datapoint in response.entries.items() + } + + def _parse_and_cache_list_metadata( + self, response: val_pb2.ListMetadataResponse + ) -> List[Metadata]: + metadatas = [codec.from_proto_metadata(m) for m in response.metadata] + self._metadata_store.add_many(metadatas) + return metadatas + + @staticmethod + def _parse_server_info(response: val_pb2.GetServerInfoResponse) -> ServerInfo: + return ServerInfo( + name=response.name, + version=response.version, + commit_hash=response.commit_hash or None, + ) + + # ------------------------------------------------------------------ + # Shared orchestration helpers (pure logic, parameterised by fetch) + # ------------------------------------------------------------------ + @staticmethod + def _normalize_paths(paths) -> List[str]: + if isinstance(paths, str): + raise TypeError("Expected a collection of paths, got a single string") + return list(paths) + + @staticmethod + def _is_collection(paths) -> bool: + return isinstance(paths, (list, tuple, set, frozenset)) + + def _resolve_metadata_from_fetch( + self, path: str, fetched: Iterable[Metadata] + ) -> Metadata: + """Update the store from a fetch and return the metadata for ``path``.""" + for metadata in fetched: + self._metadata_store.add(metadata) + metadata = self._metadata_store.get(path) + if metadata is None: + self._metadata_store.mark_missing(path) + raise NotFound(f"Path '{path}' does not exist on the server") + return metadata + + @staticmethod + def _filter_by_pattern( + metadatas: Iterable[Metadata], pattern: str + ) -> List[Metadata]: + matcher = patterns.compile_pattern(pattern) + return [m for m in metadatas if m.path is not None and matcher.matches(m.path)] + + @staticmethod + def _expand_from_metadata( + metadatas: Iterable[Metadata], + pattern: str, + entry_type: Optional[EntryType] = None, + ) -> List[str]: + paths = set() + matcher = patterns.compile_pattern(pattern) + for metadata in metadatas: + if metadata.path is None: + continue + if not matcher.matches(metadata.path): + continue + if entry_type is not None and metadata.entry_type != entry_type: + continue + paths.add(metadata.path) + return sorted(paths) diff --git a/kuksa-client/kuksa_client/v2/errors.py b/kuksa-client/kuksa_client/v2/errors.py new file mode 100644 index 0000000..1162e42 --- /dev/null +++ b/kuksa-client/kuksa_client/v2/errors.py @@ -0,0 +1,125 @@ +# /******************************************************************************** +# * Copyright (c) 2026 Contributors to the Eclipse Foundation +# * +# * See the NOTICE file(s) distributed with this work for additional +# * information regarding copyright ownership. +# * +# * This program and the accompanying materials are made available under the +# * terms of the Apache License 2.0 which is available at +# * http://www.apache.org/licenses/LICENSE-2.0 +# * +# * SPDX-License-Identifier: Apache-2.0 +# ********************************************************************************/ + +""" +Error hierarchy for the v2 SDK. + +Transport/gRPC errors (unary RPC status codes) are distinct from +application/stream errors (in-stream ``Error`` / ``ErrorCode`` messages and +``ProviderErrorIndication`` on the provider stream). +""" + +from __future__ import annotations + +import grpc +from kuksa.val.v2 import types_pb2 + + +class KuksaError(Exception): + """Base class for all SDK errors.""" + + def __init__(self, message=None, *, code=None): + super().__init__(message) + self.message = message + self.code = code + + def __str__(self): + return str(self.message) + + +class KuksaTransportError(KuksaError): + """A gRPC/transport level error (e.g. an unexpected status code).""" + + +class NotFound(KuksaError): + """A requested signal/actuator does not exist.""" + + +class InvalidArgument(KuksaError): + """The request was invalid (bad path, wrong data type, out of range).""" + + +class PermissionDenied(KuksaError): + """Access was denied.""" + + +class Unauthenticated(KuksaError): + """No credentials were provided or they have expired.""" + + +class Unavailable(KuksaError): + """The service (or provider) is currently unavailable.""" + + +class AlreadyExists(KuksaError): + """A provider already claimed ownership of a signal/actuator.""" + + +class Aborted(KuksaError): + """A provider has not claimed the signals it tried to publish.""" + + +class DataLoss(KuksaError): + """An internal transmission failure occurred.""" + + +class KuksaStreamError(KuksaError): + """An application/stream level error (in-stream error message).""" + + +_GRPC_ERROR_TYPES = { + grpc.StatusCode.NOT_FOUND: NotFound, + grpc.StatusCode.INVALID_ARGUMENT: InvalidArgument, + grpc.StatusCode.PERMISSION_DENIED: PermissionDenied, + grpc.StatusCode.UNAUTHENTICATED: Unauthenticated, + grpc.StatusCode.UNAVAILABLE: Unavailable, + grpc.StatusCode.ALREADY_EXISTS: AlreadyExists, + grpc.StatusCode.ABORTED: Aborted, + grpc.StatusCode.DATA_LOSS: DataLoss, +} + +_ERROR_CODE_TYPES = { + types_pb2.ERROR_CODE_INVALID_ARGUMENT: InvalidArgument, + types_pb2.ERROR_CODE_NOT_FOUND: NotFound, + types_pb2.ERROR_CODE_PERMISSION_DENIED: PermissionDenied, +} + + +def from_grpc_error(exc: grpc.RpcError) -> KuksaError: + """Map a gRPC ``RpcError`` to the appropriate :class:`KuksaError`.""" + code = exc.code() + error_type = _GRPC_ERROR_TYPES.get(code, KuksaTransportError) + return error_type(exc.details(), code=code) + + +def from_error_message(error: types_pb2.Error) -> KuksaError: + """Map an in-stream v2 ``Error`` message to a :class:`KuksaError`.""" + error_type = _ERROR_CODE_TYPES.get(error.code, KuksaStreamError) + return error_type(error.message, code=error.code) + + +__all__ = [ + "KuksaError", + "KuksaTransportError", + "KuksaStreamError", + "NotFound", + "InvalidArgument", + "PermissionDenied", + "Unauthenticated", + "Unavailable", + "AlreadyExists", + "Aborted", + "DataLoss", + "from_grpc_error", + "from_error_message", +] diff --git a/kuksa-client/kuksa_client/v2/metadata.py b/kuksa-client/kuksa_client/v2/metadata.py new file mode 100644 index 0000000..e9fcee5 --- /dev/null +++ b/kuksa-client/kuksa_client/v2/metadata.py @@ -0,0 +1,88 @@ +# /******************************************************************************** +# * Copyright (c) 2026 Contributors to the Eclipse Foundation +# * +# * See the NOTICE file(s) distributed with this work for additional +# * information regarding copyright ownership. +# * +# * This program and the accompanying materials are made available under the +# * terms of the Apache License 2.0 which is available at +# * http://www.apache.org/licenses/LICENSE-2.0 +# * +# * SPDX-License-Identifier: Apache-2.0 +# ********************************************************************************/ + +""" +In-memory metadata cache bound to a connection. + +The cache maps signal paths to their ``id``, ``data_type`` and ``entry_type`` +(and the full :class:`Metadata`). It caches both positively (known signals) and +negatively (paths known not to exist) and is cleared on reconnect. + +No TTL is used: VSS metadata is assumed static while a system is running. +""" + +from __future__ import annotations + +from typing import Dict +from typing import Iterable +from typing import Optional +from typing import Set + +from .types import DataType +from .types import EntryType +from .types import Metadata + + +class MetadataStore: + def __init__(self): + self._metadata: Dict[str, Metadata] = {} + self._missing: Set[str] = set() + self._id_to_path: Dict[int, str] = {} + + def invalidate(self) -> None: + """Clear all cached metadata (e.g. on reconnect).""" + self._metadata.clear() + self._missing.clear() + self._id_to_path.clear() + + def has(self, path: str) -> bool: + return path in self._metadata + + def is_missing(self, path: str) -> bool: + return path in self._missing + + def get(self, path: str) -> Optional[Metadata]: + return self._metadata.get(path) + + def add(self, metadata: Metadata) -> None: + if metadata.path is not None: + self._metadata[metadata.path] = metadata + self._missing.discard(metadata.path) + if metadata.id is not None: + self._id_to_path[metadata.id] = metadata.path + + def add_many(self, metadatas: Iterable[Metadata]) -> None: + for metadata in metadatas: + self.add(metadata) + + def mark_missing(self, path: str) -> None: + self._missing.add(path) + self._metadata.pop(path, None) + + def data_type(self, path: str) -> Optional[DataType]: + metadata = self._metadata.get(path) + return metadata.data_type if metadata is not None else None + + def entry_type(self, path: str) -> Optional[EntryType]: + metadata = self._metadata.get(path) + return metadata.entry_type if metadata is not None else None + + def signal_id(self, path: str) -> Optional[int]: + metadata = self._metadata.get(path) + return metadata.id if metadata is not None else None + + def path_for_id(self, signal_id: int) -> Optional[str]: + return self._id_to_path.get(signal_id) + + def paths(self) -> Iterable[str]: + return self._metadata.keys() diff --git a/kuksa-client/kuksa_client/v2/patterns.py b/kuksa-client/kuksa_client/v2/patterns.py new file mode 100644 index 0000000..c3dd54b --- /dev/null +++ b/kuksa-client/kuksa_client/v2/patterns.py @@ -0,0 +1,122 @@ +# /******************************************************************************** +# * Copyright (c) 2026 Contributors to the Eclipse Foundation +# * +# * See the NOTICE file(s) distributed with this work for additional +# * information regarding copyright ownership. +# * +# * This program and the accompanying materials are made available under the +# * terms of the Apache License 2.0 which is available at +# * http://www.apache.org/licenses/LICENSE-2.0 +# * +# * SPDX-License-Identifier: Apache-2.0 +# ********************************************************************************/ + +""" +Client-side wildcard pattern matching. + +This pins the databroker's wildcard semantics (see ``wildcard_matching.md`` in +the kuksa-databroker project) so that path expansion never depends on +server-side behaviour: + +- ``*`` matches exactly one path segment, +- ``**`` matches zero or more path segments, +- both are valid anywhere in the pattern, +- a plain branch path (no wildcards) matches the branch and everything below it, +- ``**`` combined with consecutive ``*`` segments is not supported. +""" + +from __future__ import annotations + +import functools +from typing import Sequence +from typing import Tuple + +_WILDCARD = "*" + + +def _is_unsupported(segments: Sequence[str]) -> bool: + has_double_star = "**" in segments + has_consecutive_star = any( + a == "*" and b == "*" for a, b in zip(segments, segments[1:]) + ) + return has_double_star and has_consecutive_star + + +@functools.lru_cache(maxsize=None) +def _match( + segments: Tuple[str, ...], + seg_index: int, + path: Tuple[str, ...], + path_index: int, +) -> bool: + if seg_index == len(segments): + return path_index == len(path) + + segment = segments[seg_index] + if segment == "**": + # Match zero segments, or consume one path segment and stay on '**'. + return _match(segments, seg_index + 1, path, path_index) or ( + path_index < len(path) + and _match(segments, seg_index, path, path_index + 1) + ) + + if path_index >= len(path): + return False + + if segment == "*": + return _match(segments, seg_index + 1, path, path_index + 1) + + return segment == path[path_index] and _match( + segments, seg_index + 1, path, path_index + 1 + ) + + +class Matcher: + """A compiled wildcard pattern.""" + + __slots__ = ("segments", "literal", "match_all") + + def __init__(self, pattern: str): + self.segments = tuple(pattern.split(".")) if pattern else () + self.literal = bool(self.segments) and not any( + _WILDCARD in segment for segment in self.segments + ) + self.match_all = pattern == "" + + def matches(self, path: str) -> bool: + if self.match_all: + return True + if _is_unsupported(self.segments): + raise ValueError( + "Pattern combining '**' with consecutive '*' segments is not supported" + ) + path_segments = tuple(path.split(".")) + if self.literal: + pattern = ".".join(self.segments) + return path == pattern or path.startswith(pattern + ".") + return _match(self.segments, 0, path_segments, 0) + + +def compile_pattern(pattern: str) -> Matcher: + """Compile a wildcard pattern into a reusable matcher.""" + return Matcher(pattern) + + +def matches(pattern: str, path: str) -> bool: + """Return whether ``path`` (a concrete signal path) matches ``pattern``.""" + return compile_pattern(pattern).matches(path) + + +def literal_prefix(pattern: str) -> str: + """ + Return the longest literal prefix of ``pattern`` (everything before the + first wildcard), used to bound a ``ListMetadata(root=...)`` call. + """ + if not pattern: + return "" + segments = [] + for segment in pattern.split("."): + if _WILDCARD in segment: + break + segments.append(segment) + return ".".join(segments) diff --git a/kuksa-client/kuksa_client/v2/provider.py b/kuksa-client/kuksa_client/v2/provider.py new file mode 100644 index 0000000..28204b2 --- /dev/null +++ b/kuksa-client/kuksa_client/v2/provider.py @@ -0,0 +1,332 @@ +# /******************************************************************************** +# * Copyright (c) 2026 Contributors to the Eclipse Foundation +# * +# * See the NOTICE file(s) distributed with this work for additional +# * information regarding copyright ownership. +# * +# * This program and the accompanying materials are made available under the +# * terms of the Apache License 2.0 which is available at +# * http://www.apache.org/licenses/LICENSE-2.0 +# * +# * SPDX-License-Identifier: Apache-2.0 +# ********************************************************************************/ + +""" +Provider support, backed by the bidirectional ``OpenProviderStream`` RPC. + +A provider claims ownership of signals/actuators, publishes values at high +frequency, and receives actuation requests. +""" + +from __future__ import annotations + +import dataclasses +import logging +import queue +import threading +from typing import Any +from typing import Dict +from typing import Iterable +from typing import Iterator +from typing import Mapping +from typing import Optional + +import grpc + +from kuksa.val.v2 import types_pb2 +from kuksa.val.v2 import val_pb2 + +from . import codec +from .errors import KuksaStreamError +from .errors import from_grpc_error +from .types import Datapoint + +logger = logging.getLogger(__name__) + +_STOP = object() + + +@dataclasses.dataclass +class ActuationRequest: + """A single actuation request received from the databroker.""" + + path: str + value: Any + provider: "Provider" + signal_id: types_pb2.SignalID + + +class _ProviderBase: + """Shared provider protocol logic (message building, id/type resolution).""" + + def __init__(self, client): + self._client = client + self._request_id = 0 + self._closed = False + + def _next_request_id(self) -> int: + self._request_id += 1 + return self._request_id + + def _check_not_closed(self) -> None: + if self._closed: + raise KuksaStreamError("Provider is closed") + + def _path_from_signal_id(self, signal_id: types_pb2.SignalID) -> str: + if signal_id.HasField("path"): + return signal_id.path + if signal_id.HasField("id"): + path = self._client._metadata_store.path_for_id(signal_id.id) + if path is not None: + return path + return "" + + # ------------------------------------------------------------------ + # Request builders (pure) + # ------------------------------------------------------------------ + def _build_provide_signal_request( + self, signals: Mapping[str, Optional[int]], ids: Mapping[str, int] + ) -> val_pb2.OpenProviderStreamRequest: + signals_sample_intervals: Dict[int, types_pb2.SampleInterval] = {} + for path, interval in signals.items(): + sample_interval = types_pb2.SampleInterval() + if interval is not None: + sample_interval.interval_ms = interval + signals_sample_intervals[ids[path]] = sample_interval + return val_pb2.OpenProviderStreamRequest( + provide_signal_request=val_pb2.ProvideSignalRequest( + signals_sample_intervals=signals_sample_intervals + ) + ) + + def _build_provide_actuation_request( + self, paths: Iterable[str] + ) -> val_pb2.OpenProviderStreamRequest: + return val_pb2.OpenProviderStreamRequest( + provide_actuation_request=val_pb2.ProvideActuationRequest( + actuator_identifiers=[ + types_pb2.SignalID(path=path) for path in paths + ] + ) + ) + + def _build_publish_values_request( + self, + values: Mapping[str, Any], + ids: Mapping[str, int], + data_types: Mapping[str, Any], + request_id: int, + ) -> val_pb2.OpenProviderStreamRequest: + data_points: Dict[int, types_pb2.Datapoint] = {} + for path, value in values.items(): + datapoint = value if isinstance(value, Datapoint) else Datapoint(value=value) + data_points[ids[path]] = codec.to_proto_datapoint( + datapoint, data_types[path] + ) + return val_pb2.OpenProviderStreamRequest( + publish_values_request=val_pb2.PublishValuesRequest( + request_id=request_id, data_points=data_points + ) + ) + + def _build_batch_actuate_stream_response( + self, signal_id: types_pb2.SignalID, ok: bool, reason: Optional[str] + ) -> val_pb2.OpenProviderStreamRequest: + error = types_pb2.Error() + if ok: + error.code = types_pb2.ERROR_CODE_OK + else: + error.code = types_pb2.ERROR_CODE_INVALID_ARGUMENT + if reason: + error.message = reason + return val_pb2.OpenProviderStreamRequest( + batch_actuate_stream_response=val_pb2.BatchActuateStreamResponse( + signal_id=signal_id, error=error + ) + ) + + def _parse_actuation_request( + self, actuate_request: val_pb2.ActuateRequest + ) -> ActuationRequest: + return ActuationRequest( + path=self._path_from_signal_id(actuate_request.signal_id), + value=codec.from_proto_value(actuate_request.value), + provider=self, + signal_id=actuate_request.signal_id, + ) + + def _handle_publish_response( + self, response: val_pb2.PublishValuesResponse + ) -> None: + for signal_id, error in response.status.items(): + logger.warning( + "Publish error for signal id %s: %s (%s)", + signal_id, + error.message, + types_pb2.ErrorCode.Name(error.code), + ) + + +class Provider(_ProviderBase): + """Synchronous provider backed by ``OpenProviderStream``.""" + + def __init__(self, client): + super().__init__(client) + self._send_queue: queue.Queue = queue.Queue() + self._actuation_queue: queue.Queue = queue.Queue() + self._reader_thread = None + self._pending: Dict[str, threading.Event] = {} + self._stream_error = None + + def __enter__(self) -> "Provider": + self._open() + return self + + def __exit__(self, exc_type, exc_value, traceback) -> None: + self.close() + + # ------------------------------------------------------------------ + # Stream plumbing + # ------------------------------------------------------------------ + def _open(self) -> None: + self._check_not_closed() + if self._reader_thread is not None: + return + self._reader_thread = threading.Thread( + target=self._run, name="kuksa-provider", daemon=True + ) + self._reader_thread.start() + + def _request_iterator(self) -> Iterator[val_pb2.OpenProviderStreamRequest]: + while True: + item = self._send_queue.get() + if item is _STOP: + return + yield item + + def _run(self) -> None: + try: + responses = self._client._stub.OpenProviderStream( + self._request_iterator(), + metadata=self._client._metadata_kwargs(), + ) + for response in responses: + self._dispatch(response) + except Exception as exc: # noqa: BLE001 + self._stream_error = exc + finally: + self._actuation_queue.put(_STOP) + for event in self._pending.values(): + event.set() + + def _dispatch(self, response: val_pb2.OpenProviderStreamResponse) -> None: + action = response.WhichOneof("action") + if action == "batch_actuate_stream_request": + requests = [ + self._parse_actuation_request(actuate_request) + for actuate_request in response.batch_actuate_stream_request.actuate_requests + ] + self._actuation_queue.put(requests) + elif action in ("provide_signal_response", "provide_actuation_response"): + event = self._pending.get(action) + if event is not None: + event.set() + elif action == "publish_values_response": + self._handle_publish_response(response.publish_values_response) + else: + logger.warning("Unhandled provider stream response: %s", action) + + def _send(self, request: val_pb2.OpenProviderStreamRequest) -> None: + self._send_queue.put(request) + + def _raise_if_stream_error(self) -> None: + if self._stream_error is not None: + error = self._stream_error + if isinstance(error, grpc.RpcError): + raise from_grpc_error(error) from error + raise KuksaStreamError(str(error)) from error + + def _register(self, kind: str) -> threading.Event: + event = threading.Event() + self._pending[kind] = event + return event + + def _wait(self, kind: str, timeout: Optional[float] = None) -> None: + event = self._pending.get(kind) + try: + if event is not None: + event.wait(timeout=timeout) + finally: + self._pending.pop(kind, None) + self._raise_if_stream_error() + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + def provide_signals( + self, signals: Mapping[str, Optional[int]], timeout: Optional[float] = None + ) -> None: + """ + Claim ownership of ``signals`` (path -> sample interval in ms, or None). + """ + self._open() + ids = self._client._resolve_signal_ids(signals.keys()) + request = self._build_provide_signal_request(signals, ids) + self._register("provide_signal_response") + self._send(request) + self._wait("provide_signal_response", timeout=timeout) + + def provide_actuators( + self, paths: Iterable[str], timeout: Optional[float] = None + ) -> None: + """Claim ownership of the actuators identified by ``paths``.""" + self._open() + request = self._build_provide_actuation_request(paths) + self._register("provide_actuation_response") + self._send(request) + self._wait("provide_actuation_response", timeout=timeout) + + def publish(self, values: Mapping[str, Any]) -> None: + """Publish values (high-frequency path).""" + self._open() + ids = self._client._resolve_signal_ids(values.keys()) + data_types = self._client._resolve_data_types(values.keys()) + request = self._build_publish_values_request( + values, ids, data_types, self._next_request_id() + ) + self._send(request) + + def actuation_requests(self) -> Iterator[Iterable[ActuationRequest]]: + """Iterate over incoming actuation request batches.""" + self._open() + while True: + item = self._actuation_queue.get() + if item is _STOP: + self._raise_if_stream_error() + return + yield item + + def accept( + self, + actuation_request: ActuationRequest, + ok: bool = True, + reason: Optional[str] = None, + ) -> None: + """Acknowledge (or reject) a received actuation request.""" + self._check_not_closed() + request = self._build_batch_actuate_stream_response( + actuation_request.signal_id, ok, reason + ) + self._send(request) + + def close(self) -> None: + if self._closed: + return + self._closed = True + self._send_queue.put(_STOP) + if self._reader_thread is not None: + self._reader_thread.join(timeout=5) + self._reader_thread = None + + +__all__ = ["Provider", "ActuationRequest"] diff --git a/kuksa-client/kuksa_client/v2/transport.py b/kuksa-client/kuksa_client/v2/transport.py new file mode 100644 index 0000000..99fdd02 --- /dev/null +++ b/kuksa-client/kuksa_client/v2/transport.py @@ -0,0 +1,64 @@ +# /******************************************************************************** +# * Copyright (c) 2026 Contributors to the Eclipse Foundation +# * +# * See the NOTICE file(s) distributed with this work for additional +# * information regarding copyright ownership. +# * +# * This program and the accompanying materials are made available under the +# * terms of the Apache License 2.0 which is available at +# * http://www.apache.org/licenses/LICENSE-2.0 +# * +# * SPDX-License-Identifier: Apache-2.0 +# ********************************************************************************/ + +""" +Connection / TLS / auth helpers shared by the synchronous and asynchronous +clients. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Optional + +import grpc + + +def _build_credentials(root_certificates: Optional[Path]): + if root_certificates is None: + return None + return grpc.ssl_channel_credentials(root_certificates.read_bytes()) + + +def _channel_options(tls_server_name: Optional[str]): + if tls_server_name: + return [("grpc.ssl_target_name_override", tls_server_name)] + return None + + +def create_sync_channel( + host: str, + port: int, + root_certificates: Optional[Path] = None, + tls_server_name: Optional[str] = None, +) -> grpc.Channel: + target = f"{host}:{port}" + credentials = _build_credentials(root_certificates) + if credentials is not None: + return grpc.secure_channel(target, credentials, _channel_options(tls_server_name)) + return grpc.insecure_channel(target) + + +def create_aio_channel( + host: str, + port: int, + root_certificates: Optional[Path] = None, + tls_server_name: Optional[str] = None, +) -> grpc.aio.Channel: + target = f"{host}:{port}" + credentials = _build_credentials(root_certificates) + if credentials is not None: + return grpc.aio.secure_channel( + target, credentials, _channel_options(tls_server_name) + ) + return grpc.aio.insecure_channel(target) diff --git a/kuksa-client/kuksa_client/v2/types.py b/kuksa-client/kuksa_client/v2/types.py new file mode 100644 index 0000000..e503ad1 --- /dev/null +++ b/kuksa-client/kuksa_client/v2/types.py @@ -0,0 +1,113 @@ +# /******************************************************************************** +# * Copyright (c) 2026 Contributors to the Eclipse Foundation +# * +# * See the NOTICE file(s) distributed with this work for additional +# * information regarding copyright ownership. +# * +# * This program and the accompanying materials are made available under the +# * terms of the Apache License 2.0 which is available at +# * http://www.apache.org/licenses/LICENSE-2.0 +# * +# * SPDX-License-Identifier: Apache-2.0 +# ********************************************************************************/ + +from __future__ import annotations + +import dataclasses +import datetime +import enum +from typing import Any +from typing import List +from typing import Optional + + +class DataType(enum.IntEnum): + """ + VSS data type of a signal. + + The enum values match ``kuksa.val.v2.DataType`` so that the codec can map + between them without an explicit conversion table for the enum itself. + + ``TIMESTAMP`` and ``TIMESTAMP_ARRAY`` are intentionally absent: the v2 + ``Value`` oneof has no timestamp field, so these types cannot be + represented as values. + """ + + UNSPECIFIED = 0 + STRING = 1 + BOOLEAN = 2 + INT8 = 3 + INT16 = 4 + INT32 = 5 + INT64 = 6 + UINT8 = 7 + UINT16 = 8 + UINT32 = 9 + UINT64 = 10 + FLOAT = 11 + DOUBLE = 12 + STRING_ARRAY = 20 + BOOLEAN_ARRAY = 21 + INT8_ARRAY = 22 + INT16_ARRAY = 23 + INT32_ARRAY = 24 + INT64_ARRAY = 25 + UINT8_ARRAY = 26 + UINT16_ARRAY = 27 + UINT32_ARRAY = 28 + UINT64_ARRAY = 29 + FLOAT_ARRAY = 30 + DOUBLE_ARRAY = 31 + + +class EntryType(enum.IntEnum): + UNSPECIFIED = 0 + ATTRIBUTE = 1 + SENSOR = 2 + ACTUATOR = 3 + + +@dataclasses.dataclass +class Datapoint: + """ + A timestamped value. + + ``value`` is a native Python value (int, float, str, bool, list, ...) or + ``None`` if the signal exists but has no value yet. + """ + + value: Any = None + timestamp: Optional[datetime.datetime] = None + + +@dataclasses.dataclass +class ValueRestriction: + allowed_values: Optional[List[Any]] = None + min: Optional[Any] = None + max: Optional[Any] = None + + +@dataclasses.dataclass +class Metadata: + """ + Read-only metadata of a signal, as returned by the databroker's + ``ListMetadata`` RPC. + """ + + path: Optional[str] = None + id: Optional[int] = None + data_type: DataType = DataType.UNSPECIFIED + entry_type: EntryType = EntryType.UNSPECIFIED + description: Optional[str] = None + comment: Optional[str] = None + deprecation: Optional[str] = None + unit: Optional[str] = None + value_restriction: Optional[ValueRestriction] = None + min_sample_interval: Optional[int] = None + + +@dataclasses.dataclass +class ServerInfo: + name: str + version: str + commit_hash: Optional[str] = None diff --git a/kuksa-client/tests/v2/__init__.py b/kuksa-client/tests/v2/__init__.py new file mode 100644 index 0000000..8f7e6bf --- /dev/null +++ b/kuksa-client/tests/v2/__init__.py @@ -0,0 +1,5 @@ +# /******************************************************************************** +# * Copyright (c) 2026 Contributors to the Eclipse Foundation +# * +# * SPDX-License-Identifier: Apache-2.0 +# ********************************************************************************/ diff --git a/kuksa-client/tests/v2/conftest.py b/kuksa-client/tests/v2/conftest.py new file mode 100644 index 0000000..8d3ccc8 --- /dev/null +++ b/kuksa-client/tests/v2/conftest.py @@ -0,0 +1,111 @@ +# /******************************************************************************** +# * Copyright (c) 2026 Contributors to the Eclipse Foundation +# * +# * See the NOTICE file(s) distributed with this work for additional +# * information regarding copyright ownership. +# * +# * This program and the accompanying materials are made available under the +# * terms of the Apache License 2.0 which is available at +# * http://www.apache.org/licenses/LICENSE-2.0 +# * +# * SPDX-License-Identifier: Apache-2.0 +# ********************************************************************************/ + +import asyncio +import threading + +import grpc +import pytest + +from kuksa.val.v2 import types_pb2 +from kuksa.val.v2 import val_pb2_grpc + +from .mock_databroker import MockDatabroker + + +def build_default_tree() -> MockDatabroker: + broker = MockDatabroker() + broker.add_signal( + "Vehicle.Speed", + types_pb2.DATA_TYPE_FLOAT, + types_pb2.ENTRY_TYPE_SENSOR, + description="Vehicle speed.", + ) + broker.add_signal( + "Vehicle.ADAS.ABS.IsActive", + types_pb2.DATA_TYPE_BOOLEAN, + types_pb2.ENTRY_TYPE_SENSOR, + ) + broker.add_signal( + "Vehicle.SomeString", + types_pb2.DATA_TYPE_STRING, + types_pb2.ENTRY_TYPE_ATTRIBUTE, + ) + broker.add_signal( + "Vehicle.Body.Windshield.Front.Wiping.System.TargetPosition", + types_pb2.DATA_TYPE_FLOAT, + types_pb2.ENTRY_TYPE_ACTUATOR, + ) + broker.add_signal( + "Vehicle.Body.Windshield.Front.Wiping.System.Mode", + types_pb2.DATA_TYPE_STRING, + types_pb2.ENTRY_TYPE_ACTUATOR, + ) + broker.add_signal( + "Vehicle.Cabin.Sunroof.Position", + types_pb2.DATA_TYPE_FLOAT, + types_pb2.ENTRY_TYPE_ACTUATOR, + ) + broker.add_signal( + "Vehicle.Cabin.Sunroof.Switch", + types_pb2.DATA_TYPE_BOOLEAN, + types_pb2.ENTRY_TYPE_ACTUATOR, + ) + broker.add_signal( + "Vehicle.Cabin.Sunroof.Shade.Position", + types_pb2.DATA_TYPE_FLOAT, + types_pb2.ENTRY_TYPE_ACTUATOR, + ) + broker.add_signal( + "Vehicle.Cabin.Sunroof.Shade.Switch", + types_pb2.DATA_TYPE_BOOLEAN, + types_pb2.ENTRY_TYPE_ACTUATOR, + ) + broker.add_signal( + "Vehicle.Cabin.Seat.Row1.Pos1.Position", + types_pb2.DATA_TYPE_FLOAT, + types_pb2.ENTRY_TYPE_ACTUATOR, + ) + return broker + + +@pytest.fixture +def broker(): + return build_default_tree() + + +@pytest.fixture +def server(broker, unused_tcp_port): + loop = asyncio.new_event_loop() + thread = threading.Thread(target=loop.run_forever, daemon=True) + thread.start() + + holder = {} + + async def _setup(): + grpc_server = grpc.aio.server() + val_pb2_grpc.add_VALServicer_to_server(broker, grpc_server) + grpc_server.add_insecure_port(f"127.0.0.1:{unused_tcp_port}") + await grpc_server.start() + holder["server"] = grpc_server + + asyncio.run_coroutine_threadsafe(_setup(), loop).result() + try: + yield unused_tcp_port + finally: + async def _teardown(): + await holder["server"].stop(grace=0.5) + + asyncio.run_coroutine_threadsafe(_teardown(), loop).result() + loop.call_soon_threadsafe(loop.stop) + thread.join(timeout=5) diff --git a/kuksa-client/tests/v2/mock_databroker.py b/kuksa-client/tests/v2/mock_databroker.py new file mode 100644 index 0000000..97d89a4 --- /dev/null +++ b/kuksa-client/tests/v2/mock_databroker.py @@ -0,0 +1,291 @@ +# /******************************************************************************** +# * Copyright (c) 2026 Contributors to the Eclipse Foundation +# * +# * See the NOTICE file(s) distributed with this work for additional +# * information regarding copyright ownership. +# * +# * This program and the accompanying materials are made available under the +# * terms of the Apache License 2.0 which is available at +# * http://www.apache.org/licenses/LICENSE-2.0 +# * +# * SPDX-License-Identifier: Apache-2.0 +# ********************************************************************************/ + +""" +In-memory ``kuksa.val.v2`` databroker used for testing the SDK. + +Implements the full VAL service over an in-memory VSS tree so that streaming +(subscribe) and provider (OpenProviderStream) paths are genuinely exercised. +""" + +from __future__ import annotations + +import asyncio +import dataclasses +import datetime +from typing import Dict +from typing import Optional + +import grpc + +from kuksa.val.v2 import types_pb2 +from kuksa.val.v2 import val_pb2 +from kuksa.val.v2 import val_pb2_grpc + +_END = object() + + +@dataclasses.dataclass +class _Signal: + path: str + signal_id: int + data_type: int + entry_type: int + description: str = "" + + +class _ProviderConnection: + def __init__(self): + self.outgoing: asyncio.Queue = asyncio.Queue() + + +class MockDatabroker(val_pb2_grpc.VALServicer): + def __init__(self): + self._signals: Dict[str, _Signal] = {} + self._id_to_path: Dict[int, str] = {} + self._values: Dict[str, types_pb2.Datapoint] = {} + self._subscribers = [] # list of (set(paths), asyncio.Queue) + self._providers: Dict[str, _ProviderConnection] = {} + self._next_id = 1 + + # ------------------------------------------------------------------ + # Tree construction helpers + # ------------------------------------------------------------------ + def add_signal( + self, + path: str, + data_type: int, + entry_type: int, + description: str = "", + value=None, + ) -> "_Signal": + signal = _Signal(path, self._next_id, data_type, entry_type, description) + self._next_id += 1 + self._signals[path] = signal + self._id_to_path[signal.signal_id] = path + if value is not None: + self._values[path] = _to_datapoint(value, data_type) + return signal + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + def _signal_path(self, signal_id: types_pb2.SignalID) -> Optional[str]: + if signal_id.HasField("path"): + return signal_id.path + if signal_id.HasField("id"): + return self._id_to_path.get(signal_id.id) + return None + + def _get_datapoint(self, path: str) -> types_pb2.Datapoint: + return self._values.get(path, types_pb2.Datapoint()) + + def _to_metadata(self, signal: _Signal) -> types_pb2.Metadata: + return types_pb2.Metadata( + path=signal.path, + id=signal.signal_id, + data_type=signal.data_type, + entry_type=signal.entry_type, + description=signal.description, + ) + + def _notify(self, entries: Dict[str, types_pb2.Datapoint]) -> None: + for subscribed_paths, queue in self._subscribers: + matched = { + path: dp for path, dp in entries.items() if path in subscribed_paths + } + if matched: + queue.put_nowait(matched) + + # ------------------------------------------------------------------ + # Unary RPCs + # ------------------------------------------------------------------ + async def GetValue(self, request, context): + path = self._signal_path(request.signal_id) + if path is None or path not in self._signals: + await context.abort(grpc.StatusCode.NOT_FOUND, "Path not found") + return val_pb2.GetValueResponse(data_point=self._get_datapoint(path)) + + async def GetValues(self, request, context): + data_points = [] + for signal_id in request.signal_ids: + path = self._signal_path(signal_id) + if path is None or path not in self._signals: + await context.abort(grpc.StatusCode.NOT_FOUND, "Path not found") + data_points.append(self._get_datapoint(path)) + return val_pb2.GetValuesResponse(data_points=data_points) + + async def ListMetadata(self, request, context): + root = request.root + if root == "": + metadata = [self._to_metadata(s) for s in self._signals.values()] + return val_pb2.ListMetadataResponse(metadata=metadata) + + if root in self._signals: + return val_pb2.ListMetadataResponse( + metadata=[self._to_metadata(self._signals[root])] + ) + + prefix = root + "." + metadata = [ + self._to_metadata(s) + for s in self._signals.values() + if s.path.startswith(prefix) + ] + if metadata: + return val_pb2.ListMetadataResponse(metadata=metadata) + + await context.abort(grpc.StatusCode.NOT_FOUND, "Path not found") + + async def PublishValue(self, request, context): + path = self._signal_path(request.signal_id) + if path is None or path not in self._signals: + await context.abort(grpc.StatusCode.NOT_FOUND, "Path not found") + self._values[path] = request.data_point + self._notify({path: request.data_point}) + return val_pb2.PublishValueResponse() + + async def Actuate(self, request, context): + await self._actuate([request], context) + return val_pb2.ActuateResponse() + + async def BatchActuate(self, request, context): + await self._actuate(request.actuate_requests, context) + return val_pb2.BatchActuateResponse() + + async def _actuate(self, actuate_requests, context): + for actuate_request in actuate_requests: + path = self._signal_path(actuate_request.signal_id) + if path is None or path not in self._signals: + await context.abort(grpc.StatusCode.NOT_FOUND, "Path not found") + if self._signals[path].entry_type != types_pb2.ENTRY_TYPE_ACTUATOR: + await context.abort( + grpc.StatusCode.INVALID_ARGUMENT, "Path is not an actuator" + ) + for actuate_request in actuate_requests: + path = self._signal_path(actuate_request.signal_id) + provider = self._providers.get(path) + if provider is None: + await context.abort( + grpc.StatusCode.UNAVAILABLE, "No provider for actuator" + ) + for actuate_request in actuate_requests: + path = self._signal_path(actuate_request.signal_id) + request = val_pb2.BatchActuateStreamRequest( + actuate_requests=[actuate_request] + ) + self._providers[path].outgoing.put_nowait( + val_pb2.OpenProviderStreamResponse( + batch_actuate_stream_request=request + ) + ) + + async def GetServerInfo(self, request, context): + return val_pb2.GetServerInfoResponse( + name="mock-databroker", version="0.0.0", commit_hash="deadbeef" + ) + + # ------------------------------------------------------------------ + # Streaming RPCs + # ------------------------------------------------------------------ + async def Subscribe(self, request, context): + for path in request.signal_paths: + if path not in self._signals: + await context.abort(grpc.StatusCode.NOT_FOUND, "Path not found") + + queue: asyncio.Queue = asyncio.Queue() + self._subscribers.append((set(request.signal_paths), queue)) + initial = { + path: self._get_datapoint(path) for path in request.signal_paths + } + yield val_pb2.SubscribeResponse(entries=initial) + while True: + entries = await queue.get() + yield val_pb2.SubscribeResponse(entries=entries) + + async def OpenProviderStream(self, request_iterator, context): + provider = _ProviderConnection() + + async def handle_requests(): + try: + async for request in request_iterator: + await self._handle_provider_request(provider, request, context) + except Exception: # noqa: BLE001 + pass + finally: + await provider.outgoing.put(_END) + + reader = asyncio.create_task(handle_requests()) + try: + while True: + item = await provider.outgoing.get() + if item is _END: + return + yield item + finally: + reader.cancel() + + async def _handle_provider_request(self, provider, request, context): + action = request.WhichOneof("action") + if action == "provide_signal_request": + for signal_id in request.provide_signal_request.signals_sample_intervals: + if signal_id not in self._id_to_path: + await context.abort( + grpc.StatusCode.NOT_FOUND, "Signal not found" + ) + await provider.outgoing.put( + val_pb2.OpenProviderStreamResponse( + provide_signal_response=val_pb2.ProvideSignalResponse() + ) + ) + elif action == "provide_actuation_request": + identifiers = request.provide_actuation_request.actuator_identifiers + for signal_id in identifiers: + path = self._signal_path(signal_id) + if path is None or path not in self._signals: + await context.abort( + grpc.StatusCode.NOT_FOUND, "Actuator not found" + ) + self._providers[path] = provider + await provider.outgoing.put( + val_pb2.OpenProviderStreamResponse( + provide_actuation_response=val_pb2.ProvideActuationResponse() + ) + ) + elif action == "publish_values_request": + entries = {} + for signal_id, datapoint in request.publish_values_request.data_points.items(): + path = self._id_to_path.get(signal_id) + if path is None: + continue + self._values[path] = datapoint + entries[path] = datapoint + self._notify(entries) + elif action == "batch_actuate_stream_response": + pass + + +def _to_datapoint(value, data_type: int) -> types_pb2.Datapoint: + message = types_pb2.Datapoint() + message.value.CopyFrom(_to_value(value, data_type)) + message.timestamp.FromDatetime( + datetime.datetime.now(tz=datetime.timezone.utc) + ) + return message + + +def _to_value(value, data_type: int) -> types_pb2.Value: + from kuksa_client.v2 import codec # noqa: PLC0415 + from kuksa_client.v2.types import DataType + + return codec.to_proto_value(value, DataType(data_type)) diff --git a/kuksa-client/tests/v2/test_cli.py b/kuksa-client/tests/v2/test_cli.py new file mode 100644 index 0000000..11ef43d --- /dev/null +++ b/kuksa-client/tests/v2/test_cli.py @@ -0,0 +1,180 @@ +# /******************************************************************************** +# * Copyright (c) 2026 Contributors to the Eclipse Foundation +# * +# * SPDX-License-Identifier: Apache-2.0 +# ********************************************************************************/ + +import io + +import pytest +from cmd2 import Cmd + +from kuksa_client.__main__ import _matching_paths +from kuksa_client.__main__ import coerce_assignments +from kuksa_client.__main__ import path_completer +from kuksa_client.__main__ import set_completer +from kuksa_client.__main__ import KuksaShell +from kuksa_client.v2 import Datapoint +from kuksa_client.v2 import DataType +from kuksa_client.v2 import KuksaError +from kuksa_client.v2 import Metadata + +PATHS = [ + "Vehicle.ADAS.ABS.IsActive", + "Vehicle.Cabin.Sunroof.Position", + "Vehicle.Cabin.Sunroof.Switch", + "Vehicle.SomeString", + "Vehicle.Speed", +] + + +class _FakeClient: + def __init__(self, paths): + self._paths = paths + + def expand(self, pattern): + assert pattern == "" + return self._paths + + +def _make_shell(paths=PATHS): + shell = Cmd(stdout=io.StringIO(), allow_cli_args=False) + shell.client = _FakeClient(paths) + shell._completion_paths = [] + return shell + + +def test_matching_paths_case_insensitive(): + shell = _make_shell() + assert _matching_paths(shell, "Vehicle.S") == ["Vehicle.SomeString", "Vehicle.Speed"] + assert _matching_paths(shell, "vehicle.s") == ["Vehicle.SomeString", "Vehicle.Speed"] + assert _matching_paths(shell, "Kuksa") == [] + + +def test_matching_paths_caches(): + shell = _make_shell() + _matching_paths(shell, "Vehicle.S") + assert shell._completion_paths == PATHS + + +def test_path_completer(): + shell = _make_shell() + completions = path_completer( + shell, "Vehicle.Cabin.Sunroof.", "", 0, len("Vehicle.Cabin.Sunroof.") + ) + assert sorted(item.text for item in completions.items) == [ + "Vehicle.Cabin.Sunroof.Position", + "Vehicle.Cabin.Sunroof.Switch", + ] + + +def test_path_completer_not_connected(): + shell = Cmd(stdout=io.StringIO(), allow_cli_args=False) + shell.client = None + shell._completion_paths = [] + completions = path_completer(shell, "Vehicle.", "", 0, 8) + assert list(completions.items) == [] + + +def test_set_completer_preserves_value_suffix(): + shell = _make_shell() + text = "Vehicle.S=42" + completions = set_completer(shell, text, "set Vehicle.S=42", 4, 4 + len(text)) + assert sorted(item.text for item in completions.items) == [ + "Vehicle.SomeString", + "Vehicle.Speed", + ] + + +def test_set_completer_without_equals(): + shell = _make_shell() + completions = set_completer(shell, "Vehicle.S", "set Vehicle.S", 4, 4 + len("Vehicle.S")) + assert sorted(item.text for item in completions.items) == [ + "Vehicle.SomeString", + "Vehicle.Speed", + ] + + +class _MetadataClient: + def __init__(self, data_types): + self._data_types = data_types + + def get_metadata(self, path): + if path not in self._data_types: + raise KuksaError(f"Path '{path}' does not exist") + return Metadata(path=path, data_type=self._data_types[path]) + + +def test_coerce_assignments(): + client = _MetadataClient({ + "Vehicle.Speed": DataType.FLOAT, + "Vehicle.ADAS.ABS.IsActive": DataType.BOOLEAN, + "Vehicle.OBD.DTCList": DataType.STRING_ARRAY, + }) + updates = coerce_assignments( + client, + ["Vehicle.Speed=42", "Vehicle.ADAS.ABS.IsActive=true", "Vehicle.OBD.DTCList=['a','b']"], + ) + assert updates == { + "Vehicle.Speed": 42.0, + "Vehicle.ADAS.ABS.IsActive": True, + "Vehicle.OBD.DTCList": ["a", "b"], + } + + +def test_coerce_assignments_missing_equals(): + client = _MetadataClient({}) + with pytest.raises(KuksaError): + coerce_assignments(client, ["Vehicle.Speed"]) + + +def test_coerce_assignments_unknown_path(): + client = _MetadataClient({}) + with pytest.raises(KuksaError): + coerce_assignments(client, ["Vehicle.NoSuch=1"]) + + +class _SubscribingClient: + def __init__(self, batches, connected=True): + self._batches = batches + self.connected = connected + + def subscribe(self, paths): + yield from self._batches + + +class _FailingClient: + def __init__(self, connected=True): + self.connected = connected + + def subscribe(self, paths): + if False: # pragma: no cover - make this a generator + yield + raise KuksaError("Path not found") + + +def _alert_shell(): + return Cmd(stdout=io.StringIO(), allow_cli_args=False) + + +def test_subscribe_background_queues_alert(): + shell = _alert_shell() + client = _SubscribingClient([{"Vehicle.Speed": Datapoint(42.0)}]) + KuksaShell._subscribe_background(shell, client, ["Vehicle.Speed"]) + assert len(shell._alert_queue) == 1 + assert "42.0" in shell._alert_queue[0].msg + + +def test_subscribe_background_error_alerts_when_connected(): + shell = _alert_shell() + client = _FailingClient(connected=True) + KuksaShell._subscribe_background(shell, client, ["Vehicle.NoSuch"]) + assert len(shell._alert_queue) == 1 + assert "Subscription error" in shell._alert_queue[0].msg + + +def test_subscribe_background_error_silent_when_disconnected(): + shell = _alert_shell() + client = _FailingClient(connected=False) + KuksaShell._subscribe_background(shell, client, ["Vehicle.NoSuch"]) + assert len(shell._alert_queue) == 0 diff --git a/kuksa-client/tests/v2/test_client.py b/kuksa-client/tests/v2/test_client.py new file mode 100644 index 0000000..9985914 --- /dev/null +++ b/kuksa-client/tests/v2/test_client.py @@ -0,0 +1,118 @@ +# /******************************************************************************** +# * Copyright (c) 2026 Contributors to the Eclipse Foundation +# * +# * SPDX-License-Identifier: Apache-2.0 +# ********************************************************************************/ + +import pytest + +from kuksa_client.v2 import DataType +from kuksa_client.v2 import Datapoint +from kuksa_client.v2 import EntryType +from kuksa_client.v2 import KuksaClient +from kuksa_client.v2 import NotFound + + +@pytest.fixture +def client(server): + with KuksaClient("127.0.0.1", server) as client: + yield client + + +def test_get_missing_raises_not_found(client): + with pytest.raises(NotFound): + client.get("Vehicle.DoesNotExist") + + +def test_get_no_value_returns_none(client): + datapoint = client.get("Vehicle.Speed") + assert datapoint.value is None + + +def test_set_and_get_round_trip(client): + client.set({"Vehicle.Speed": 42.0}) + datapoint = client.get("Vehicle.Speed") + assert datapoint.value == 42.0 + + +def test_set_with_datapoint(client): + client.set({"Vehicle.Speed": Datapoint(43.0)}) + assert client.get("Vehicle.Speed").value == 43.0 + + +def test_set_with_explicit_data_type(client): + client.set({"Vehicle.Speed": 44.0}, data_type=DataType.FLOAT) + assert client.get("Vehicle.Speed").value == 44.0 + + +def test_get_many(client): + client.set({"Vehicle.Speed": 1.0, "Vehicle.ADAS.ABS.IsActive": True}) + values = client.get(["Vehicle.Speed", "Vehicle.ADAS.ABS.IsActive"]) + assert values["Vehicle.Speed"].value == 1.0 + assert values["Vehicle.ADAS.ABS.IsActive"].value is True + + +def test_get_many_all_or_nothing(client): + with pytest.raises(NotFound): + client.get(["Vehicle.Speed", "Vehicle.DoesNotExist"]) + + +def test_get_metadata(client): + metadata = client.get_metadata("Vehicle.Speed") + assert metadata.data_type == DataType.FLOAT + assert metadata.entry_type == EntryType.SENSOR + assert metadata.description == "Vehicle speed." + + +def test_get_metadata_missing(client): + with pytest.raises(NotFound): + client.get_metadata("Vehicle.DoesNotExist") + + +def test_list_metadata_pattern(client): + metadatas = client.list_metadata("Vehicle.Cabin.Sunroof.*") + paths = {m.path for m in metadatas} + assert paths == {"Vehicle.Cabin.Sunroof.Position", "Vehicle.Cabin.Sunroof.Switch"} + + +def test_expand(client): + paths = client.expand("Vehicle.Cabin.Sunroof.**") + assert "Vehicle.Cabin.Sunroof.Shade.Position" in paths + assert "Vehicle.Cabin.Sunroof.Position" in paths + + +def test_expand_by_entry_type(client): + sensors = client.expand("Vehicle.**", entry_type=EntryType.SENSOR) + assert sensors == ["Vehicle.ADAS.ABS.IsActive", "Vehicle.Speed"] + + +def test_has_signal(client): + assert client.has_signal("Vehicle.Speed") is True + assert client.has_signal("Vehicle.DoesNotExist") is False + + +def test_has_signals(client): + assert client.has_signals(["Vehicle.Speed", "Vehicle.ADAS.ABS.IsActive"]) is True + assert client.has_signals(["Vehicle.Speed", "Vehicle.DoesNotExist"]) is False + + +def test_missing_signals(client): + missing = client.missing_signals(["Vehicle.Speed", "Vehicle.DoesNotExist"]) + assert missing == {"Vehicle.DoesNotExist"} + + +def test_get_server_info(client): + info = client.get_server_info() + assert info.name == "mock-databroker" + assert info.version == "0.0.0" + + +def test_subscribe(client): + client.set({"Vehicle.Speed": 10.0}) + iterator = client.subscribe(["Vehicle.Speed"]) + first = next(iterator) + assert first["Vehicle.Speed"].value == 10.0 + + client.set({"Vehicle.Speed": 20.0}) + second = next(iterator) + assert second["Vehicle.Speed"].value == 20.0 diff --git a/kuksa-client/tests/v2/test_client_async.py b/kuksa-client/tests/v2/test_client_async.py new file mode 100644 index 0000000..01c4f27 --- /dev/null +++ b/kuksa-client/tests/v2/test_client_async.py @@ -0,0 +1,103 @@ +# /******************************************************************************** +# * Copyright (c) 2026 Contributors to the Eclipse Foundation +# * +# * SPDX-License-Identifier: Apache-2.0 +# ********************************************************************************/ + +import pytest +import pytest_asyncio + +from kuksa_client.v2 import DataType +from kuksa_client.v2 import Datapoint +from kuksa_client.v2 import EntryType +from kuksa_client.v2 import NotFound +from kuksa_client.v2.aio import KuksaClient + + +@pytest_asyncio.fixture +async def client(server): + async with KuksaClient("127.0.0.1", server) as client: + yield client + + +@pytest.mark.asyncio +async def test_get_missing_raises_not_found(client): + with pytest.raises(NotFound): + await client.get("Vehicle.DoesNotExist") + + +@pytest.mark.asyncio +async def test_get_no_value_returns_none(client): + datapoint = await client.get("Vehicle.Speed") + assert datapoint.value is None + + +@pytest.mark.asyncio +async def test_set_and_get_round_trip(client): + await client.set({"Vehicle.Speed": 42.0}) + datapoint = await client.get("Vehicle.Speed") + assert datapoint.value == 42.0 + + +@pytest.mark.asyncio +async def test_set_with_datapoint(client): + await client.set({"Vehicle.Speed": Datapoint(43.0)}) + assert (await client.get("Vehicle.Speed")).value == 43.0 + + +@pytest.mark.asyncio +async def test_set_with_explicit_data_type(client): + await client.set({"Vehicle.Speed": 44.0}, data_type=DataType.FLOAT) + assert (await client.get("Vehicle.Speed")).value == 44.0 + + +@pytest.mark.asyncio +async def test_get_many(client): + await client.set({"Vehicle.Speed": 1.0, "Vehicle.ADAS.ABS.IsActive": True}) + values = await client.get(["Vehicle.Speed", "Vehicle.ADAS.ABS.IsActive"]) + assert values["Vehicle.Speed"].value == 1.0 + assert values["Vehicle.ADAS.ABS.IsActive"].value is True + + +@pytest.mark.asyncio +async def test_get_many_all_or_nothing(client): + with pytest.raises(NotFound): + await client.get(["Vehicle.Speed", "Vehicle.DoesNotExist"]) + + +@pytest.mark.asyncio +async def test_get_metadata(client): + metadata = await client.get_metadata("Vehicle.Speed") + assert metadata.data_type == DataType.FLOAT + assert metadata.entry_type == EntryType.SENSOR + + +@pytest.mark.asyncio +async def test_list_metadata_pattern(client): + metadatas = await client.list_metadata("Vehicle.Cabin.Sunroof.*") + paths = {m.path for m in metadatas} + assert paths == {"Vehicle.Cabin.Sunroof.Position", "Vehicle.Cabin.Sunroof.Switch"} + + +@pytest.mark.asyncio +async def test_expand_by_entry_type(client): + sensors = await client.expand("Vehicle.**", entry_type=EntryType.SENSOR) + assert sensors == ["Vehicle.ADAS.ABS.IsActive", "Vehicle.Speed"] + + +@pytest.mark.asyncio +async def test_missing_signals(client): + missing = await client.missing_signals(["Vehicle.Speed", "Vehicle.DoesNotExist"]) + assert missing == {"Vehicle.DoesNotExist"} + + +@pytest.mark.asyncio +async def test_subscribe(client): + await client.set({"Vehicle.Speed": 10.0}) + iterator = client.subscribe(["Vehicle.Speed"]) + first = await iterator.__anext__() + assert first["Vehicle.Speed"].value == 10.0 + + await client.set({"Vehicle.Speed": 20.0}) + second = await iterator.__anext__() + assert second["Vehicle.Speed"].value == 20.0 diff --git a/kuksa-client/tests/v2/test_codec.py b/kuksa-client/tests/v2/test_codec.py new file mode 100644 index 0000000..86468cd --- /dev/null +++ b/kuksa-client/tests/v2/test_codec.py @@ -0,0 +1,111 @@ +# /******************************************************************************** +# * Copyright (c) 2026 Contributors to the Eclipse Foundation +# * +# * SPDX-License-Identifier: Apache-2.0 +# ********************************************************************************/ + +import datetime + +import pytest + +from kuksa_client.v2 import codec +from kuksa_client.v2.types import DataType +from kuksa_client.v2.types import Datapoint + + +@pytest.mark.parametrize( + "data_type, value", + [ + (DataType.STRING, "hello"), + (DataType.BOOLEAN, True), + (DataType.INT8, -5), + (DataType.INT16, -500), + (DataType.INT32, -70000), + (DataType.INT64, -9000000000), + (DataType.UINT8, 200), + (DataType.UINT16, 60000), + (DataType.UINT32, 4000000000), + (DataType.UINT64, 9000000000), + (DataType.FLOAT, 1.5), + (DataType.DOUBLE, 1.5), + (DataType.STRING_ARRAY, ["a", "b"]), + (DataType.BOOLEAN_ARRAY, [True, False]), + (DataType.INT8_ARRAY, [-1, 2]), + (DataType.INT32_ARRAY, [1, -2]), + (DataType.INT64_ARRAY, [1, -2]), + (DataType.UINT8_ARRAY, [1, 2]), + (DataType.UINT32_ARRAY, [1, 2]), + (DataType.UINT64_ARRAY, [1, 2]), + (DataType.FLOAT_ARRAY, [1.5, 2.5]), + (DataType.DOUBLE_ARRAY, [1.5, 2.5]), + ], +) +def test_value_round_trip(data_type, value): + message = codec.to_proto_value(value, data_type) + assert codec.from_proto_value(message) == value + + +def test_value_none_round_trip(): + message = codec.to_proto_value(None, DataType.STRING) + assert codec.from_proto_value(message) is None + + +def test_datapoint_round_trip(): + timestamp = datetime.datetime(2024, 1, 17, 10, 2, 27, tzinfo=datetime.timezone.utc) + dp = Datapoint(value=42.0, timestamp=timestamp) + message = codec.to_proto_datapoint(dp, DataType.FLOAT) + decoded = codec.from_proto_datapoint(message) + assert decoded.value == 42.0 + assert decoded.timestamp == timestamp + + +def test_datapoint_no_value(): + message = codec.to_proto_datapoint(Datapoint(value=None), DataType.FLOAT) + decoded = codec.from_proto_datapoint(message) + assert decoded.value is None + assert decoded.timestamp is None + + +def test_unspecified_data_type_raises(): + with pytest.raises(ValueError): + codec.to_proto_value(1, DataType.UNSPECIFIED) + + +def test_type_mismatch_raises(): + with pytest.raises(TypeError): + codec.to_proto_value(1, DataType.STRING) + with pytest.raises(TypeError): + codec.to_proto_value("x", DataType.INT32) + with pytest.raises(TypeError): + codec.to_proto_value(True, DataType.INT32) + with pytest.raises(TypeError): + codec.to_proto_value("x", DataType.INT32_ARRAY) + + +def test_python_type_map(): + assert codec.data_type_to_python_type(DataType.FLOAT) is float + assert codec.data_type_to_python_type(DataType.STRING) is str + assert codec.data_type_to_python_type(DataType.BOOLEAN) is bool + assert codec.data_type_to_python_type(DataType.INT32_ARRAY) is list + + +def test_metadata_from_proto(): + from kuksa.val.v2 import types_pb2 + + message = types_pb2.Metadata( + path="Vehicle.Speed", + id=42, + data_type=types_pb2.DATA_TYPE_FLOAT, + entry_type=types_pb2.ENTRY_TYPE_SENSOR, + description="Vehicle speed.", + unit="km/h", + min=types_pb2.Value(float=0.0), + max=types_pb2.Value(float=300.0), + ) + metadata = codec.from_proto_metadata(message) + assert metadata.path == "Vehicle.Speed" + assert metadata.id == 42 + assert metadata.data_type == DataType.FLOAT + assert metadata.unit == "km/h" + assert metadata.value_restriction.min == 0.0 + assert metadata.value_restriction.max == 300.0 diff --git a/kuksa-client/tests/v2/test_patterns.py b/kuksa-client/tests/v2/test_patterns.py new file mode 100644 index 0000000..95b4031 --- /dev/null +++ b/kuksa-client/tests/v2/test_patterns.py @@ -0,0 +1,58 @@ +# /******************************************************************************** +# * Copyright (c) 2026 Contributors to the Eclipse Foundation +# * +# * SPDX-License-Identifier: Apache-2.0 +# ********************************************************************************/ + +import pytest + +from kuksa_client.v2 import patterns + + +@pytest.mark.parametrize( + "pattern, path, expected", + [ + ("", "Vehicle.Speed", True), + ("Vehicle", "Vehicle.Speed", True), + ("Vehicle", "Vehicle.Cabin.Sunroof.Position", True), + ("Vehicle.Speed", "Vehicle.Speed", True), + ("Vehicle.Speed", "Vehicle.SpeedX", False), + ("Vehicle.Cabin.Sunroof", "Vehicle.Cabin.Sunroof.Position", True), + ("Vehicle.Cabin.Sunroof.**", "Vehicle.Cabin.Sunroof.Position", True), + ("Vehicle.Cabin.Sunroof.**", "Vehicle.Cabin.Sunroof.Shade.Switch", True), + ("Vehicle.Cabin.Sunroof.*", "Vehicle.Cabin.Sunroof.Position", True), + ("Vehicle.Cabin.Sunroof.*", "Vehicle.Cabin.Sunroof.Shade.Position", False), + ("Vehicle.Cabin.Sunroof.*.Position", "Vehicle.Cabin.Sunroof.Shade.Position", True), + ("**.Sunroof.*.Position", "Vehicle.Cabin.Sunroof.Shade.Position", True), + ("*.*.*.*.Position", "Vehicle.Cabin.Sunroof.Shade.Position", True), + ("Vehicle.Cabin.Sunroof.**.Position", "Vehicle.Cabin.Sunroof.Position", True), + ("Vehicle.Cabin.Sunroof.**.Position", "Vehicle.Cabin.Sunroof.Shade.Position", True), + ("**.Sunroof", "Vehicle.Cabin.Sunroof.Position", False), + ("*.Sunroof", "Vehicle.Cabin.Sunroof.Position", False), + ("Sunroof", "Vehicle.Cabin.Sunroof.Position", False), + ("**.Sunroof.**", "Vehicle.Cabin.Sunroof.Shade.Switch", True), + ], +) +def test_matches(pattern, path, expected): + assert patterns.matches(pattern, path) == expected + + +@pytest.mark.parametrize( + "pattern, prefix", + [ + ("", ""), + ("Vehicle", "Vehicle"), + ("Vehicle.Speed", "Vehicle.Speed"), + ("Vehicle.*.Position", "Vehicle"), + ("Vehicle.Cabin.Sunroof.**", "Vehicle.Cabin.Sunroof"), + ("**.TyrePressure", ""), + ("*.Sunroof", ""), + ], +) +def test_literal_prefix(pattern, prefix): + assert patterns.literal_prefix(pattern) == prefix + + +def test_double_star_with_consecutive_star_unsupported(): + with pytest.raises(ValueError): + patterns.matches("**.*.*.*.Position", "Vehicle.Cabin.Door.Position") diff --git a/kuksa-client/tests/v2/test_provider.py b/kuksa-client/tests/v2/test_provider.py new file mode 100644 index 0000000..1cfd141 --- /dev/null +++ b/kuksa-client/tests/v2/test_provider.py @@ -0,0 +1,39 @@ +# /******************************************************************************** +# * Copyright (c) 2026 Contributors to the Eclipse Foundation +# * +# * SPDX-License-Identifier: Apache-2.0 +# ********************************************************************************/ + +from kuksa_client.v2 import KuksaClient +from kuksa_client.v2 import Provider + + +def test_provider_publish(server): + with KuksaClient("127.0.0.1", server) as client: + provider = Provider(client) + try: + provider.provide_signals({"Vehicle.Speed": None}) + provider.publish({"Vehicle.Speed": 42.5}) + assert client.get("Vehicle.Speed").value == 42.5 + finally: + provider.close() + + +def test_provider_actuation(server): + with KuksaClient("127.0.0.1", server) as client: + provider = Provider(client) + try: + provider.provide_actuators( + ["Vehicle.Body.Windshield.Front.Wiping.System.TargetPosition"] + ) + client.actuate( + {"Vehicle.Body.Windshield.Front.Wiping.System.TargetPosition": 45.0} + ) + requests = next(provider.actuation_requests()) + assert len(requests) == 1 + request = requests[0] + assert request.path == "Vehicle.Body.Windshield.Front.Wiping.System.TargetPosition" + assert request.value == 45.0 + provider.accept(request, ok=True) + finally: + provider.close() diff --git a/kuksa-client/tests/v2/test_provider_async.py b/kuksa-client/tests/v2/test_provider_async.py new file mode 100644 index 0000000..9661231 --- /dev/null +++ b/kuksa-client/tests/v2/test_provider_async.py @@ -0,0 +1,43 @@ +# /******************************************************************************** +# * Copyright (c) 2026 Contributors to the Eclipse Foundation +# * +# * SPDX-License-Identifier: Apache-2.0 +# ********************************************************************************/ + +import pytest + +from kuksa_client.v2.aio import KuksaClient +from kuksa_client.v2.aio import Provider + + +@pytest.mark.asyncio +async def test_provider_publish(server): + async with KuksaClient("127.0.0.1", server) as client: + provider = Provider(client) + try: + await provider.provide_signals({"Vehicle.Speed": None}) + await provider.publish({"Vehicle.Speed": 42.5}) + assert (await client.get("Vehicle.Speed")).value == 42.5 + finally: + await provider.close() + + +@pytest.mark.asyncio +async def test_provider_actuation(server): + async with KuksaClient("127.0.0.1", server) as client: + provider = Provider(client) + try: + await provider.provide_actuators( + ["Vehicle.Body.Windshield.Front.Wiping.System.TargetPosition"] + ) + await client.actuate( + {"Vehicle.Body.Windshield.Front.Wiping.System.TargetPosition": 45.0} + ) + requests = await provider.actuation_requests().__anext__() + assert len(requests) == 1 + request = requests[0] + assert request.path == "Vehicle.Body.Windshield.Front.Wiping.System.TargetPosition" + assert request.value == 45.0 + await provider.accept(request, ok=True) + finally: + await provider.close() From d4dfffceeabf7126e93ff8a287bf6395de779520 Mon Sep 17 00:00:00 2001 From: Sebastian Schildt Date: Tue, 1 Sep 2026 21:57:20 +0200 Subject: [PATCH 03/11] Add unsubscribe to lib and cli Signed-off-by: Sebastian Schildt --- .gitignore | 1 + docs/cli.md | 1 + docs/library.md | 25 +++++ kuksa-client/kuksa_client/__main__.py | 93 +++++++++++++++--- kuksa-client/kuksa_client/v2/__init__.py | 14 ++- kuksa-client/kuksa_client/v2/aio.py | 5 +- kuksa-client/tests/v2/test_cli.py | 114 +++++++++++++++++++---- 7 files changed, 219 insertions(+), 34 deletions(-) diff --git a/.gitignore b/.gitignore index c6098ec..a779bdd 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ venv **/*.egg-info kuksa-client/dist kuksa-client/kuksa/ +kuksa-client/build/ diff --git a/docs/cli.md b/docs/cli.md index 75f183a..301c60e 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -58,6 +58,7 @@ Available one-shot commands: | `actuate ` | Actuate actuators (target values) | | `subscribe ` | Subscribe to updates | | `subscribe -b ` | Subscribe in the background; updates print as alerts while the prompt stays usable | +| `unsubscribe ` | Stop a background subscription | | `get_metadata ` | Get the metadata of a path | | `list_metadata ` | List metadata matching a pattern | | `expand ` | Expand a wildcard pattern into paths | diff --git a/docs/library.md b/docs/library.md index bd4d45a..447c475 100644 --- a/docs/library.md +++ b/docs/library.md @@ -135,6 +135,31 @@ client.authorize(token) # attach token to subsequent requests info = client.get_server_info() # -> ServerInfo(name, version, commit_hash) ``` +### Subscribing and unsubscribing + +`subscribe(paths)` returns an iterator (sync) / async iterator (async). The +current value of every subscribed signal is yielded immediately, followed by +batches of updates. + +To **unsubscribe**, break out of the loop (or drop the iterator); the underlying +stream is cancelled automatically. + +```python +# synchronous +for updates in client.subscribe(["Vehicle.Speed"]): + print(updates) + if done: + break # unsubscribe +``` + +```python +# asynchronous +async for updates in client.subscribe(["Vehicle.Speed"]): + print(updates) + if done: + break # unsubscribe (or: await sub.aclose()) +``` + ## Providers A provider claims signals/actuators, publishes values at high frequency and diff --git a/kuksa-client/kuksa_client/__main__.py b/kuksa-client/kuksa_client/__main__.py index 437b397..7fb0f12 100755 --- a/kuksa-client/kuksa_client/__main__.py +++ b/kuksa-client/kuksa_client/__main__.py @@ -18,6 +18,7 @@ ######################################################################## import argparse +import dataclasses import json import logging import os @@ -26,8 +27,10 @@ import threading from urllib.parse import urlparse +import grpc from cmd2 import Cmd from cmd2 import Cmd2ArgumentParser +from cmd2 import CompletionItem from cmd2 import with_argparser from cmd2 import with_category from cmd2 import constants @@ -178,10 +181,28 @@ def set_completer(shell, text, line, begidx, endidx): ) +def unsubscribe_completer(shell, text, line, begidx, endidx): + """Complete active background subscription ids.""" + items = [] + with shell._subscription_lock: + for sub_id, info in shell._subscriptions.items(): + items.append( + CompletionItem(str(sub_id), display=f"{sub_id}: {', '.join(info.paths)}") + ) + return shell.basic_complete(text, line, begidx, endidx, items) + + # --------------------------------------------------------------------------- # Interactive shell # --------------------------------------------------------------------------- +@dataclasses.dataclass +class _BackgroundSubscription: + paths: list + stream: object = None + thread: threading.Thread = None + + class KuksaShell(Cmd): COMM_SETUP_COMMANDS = "Communication Set-up Commands" VSS_COMMANDS = "Kuksa Interaction Commands" @@ -228,6 +249,14 @@ class KuksaShell(Cmd): help="Subscribe in the background and print updates as alerts", ) + ap_unsubscribe = Cmd2ArgumentParser() + ap_unsubscribe.add_argument( + "SubscribeId", + type=int, + help="Id of a background subscription to stop", + completer=unsubscribe_completer, + ) + ap_get_metadata = Cmd2ArgumentParser() ap_get_metadata.add_argument( "Path", help="Path whose metadata is to be read", completer=path_completer @@ -267,7 +296,9 @@ def __init__(self, server, token_or_tokenfile=None, cacertificate=None, tls_serv self.tls_server_name = tls_server_name self.client = None self._completion_paths = [] - self._subscribe_threads = [] + self._subscriptions = {} + self._subscription_lock = threading.Lock() + self._subscription_counter = 0 with (pathlib.Path(scriptDir) / "logo").open("r", encoding="utf-8") as logo_file: print(logo_file.read().replace("%ver%", str(_metadata.__version__))) @@ -337,9 +368,15 @@ def _print_json(self, obj): ) def _stop_subscriptions(self): - for thread in self._subscribe_threads: - thread.join(timeout=1) - self._subscribe_threads = [] + with self._subscription_lock: + infos = list(self._subscriptions.values()) + self._subscriptions.clear() + for info in infos: + if info.stream is not None: + info.stream.cancel() + for info in infos: + if info.thread is not None: + info.thread.join(timeout=1) @with_category(COMM_SETUP_COMMANDS) @with_argparser(ap_connect) @@ -417,14 +454,21 @@ def do_subscribe(self, args): """Subscribe to updates of one or more paths""" client = self._require_client() if args.background: + stream = client._subscribe_stream(args.Path) + with self._subscription_lock: + self._subscription_counter += 1 + sub_id = self._subscription_counter thread = threading.Thread( target=self._subscribe_background, - args=(client, args.Path), + args=(sub_id, client, stream), daemon=True, ) - self._subscribe_threads.append(thread) + with self._subscription_lock: + self._subscriptions[sub_id] = _BackgroundSubscription( + paths=list(args.Path), stream=stream, thread=thread + ) thread.start() - print(f"Subscribed to {', '.join(args.Path)} (background)") + print(f"Subscribed to {', '.join(args.Path)} (subscription {sub_id})") return try: for updates in client.subscribe(args.Path): @@ -432,9 +476,10 @@ def do_subscribe(self, args): except KuksaError as exc: print(f"Error: {exc}") - def _subscribe_background(self, client, paths): + def _subscribe_background(self, sub_id, client, stream): try: - for updates in client.subscribe(paths): + for response in stream: + updates = client._parse_subscribe_response(response) message = highlight( json.dumps( {path: dp.value for path, dp in updates.items()}, @@ -445,12 +490,36 @@ def _subscribe_background(self, client, paths): formatters.TerminalFormatter(), ) self.add_alert(msg=message) - except KuksaError as exc: - if client.connected: - self.add_alert(msg=f"Subscription error: {exc}") + except grpc.RpcError as exc: + if exc.code() != grpc.StatusCode.CANCELLED and client.connected: + self.add_alert(msg=f"Subscription error: {client._translate_rpc_error(exc)}") except Exception: # The stream was terminated, e.g. by a disconnect. pass + finally: + with self._subscription_lock: + self._subscriptions.pop(sub_id, None) + + @with_category(VSS_COMMANDS) + @with_argparser(ap_unsubscribe) + def do_unsubscribe(self, args): + """Stop a background subscription""" + info = self._cancel_subscription(args.SubscribeId) + if info is None: + print(f"No active subscription with id {args.SubscribeId}") + return + print(f"Unsubscribed {args.SubscribeId} ({', '.join(info.paths)})") + + def _cancel_subscription(self, sub_id): + with self._subscription_lock: + info = self._subscriptions.pop(sub_id, None) + if info is None: + return None + if info.stream is not None: + info.stream.cancel() + if info.thread is not None: + info.thread.join(timeout=1) + return info @with_category(VSS_COMMANDS) @with_argparser(ap_get_metadata) diff --git a/kuksa-client/kuksa_client/v2/__init__.py b/kuksa-client/kuksa_client/v2/__init__.py index 78c33e9..75ac57d 100644 --- a/kuksa-client/kuksa_client/v2/__init__.py +++ b/kuksa-client/kuksa_client/v2/__init__.py @@ -310,6 +310,11 @@ def actuate( except grpc.RpcError as exc: raise from_grpc_error(exc) from exc + def _subscribe_stream(self, paths: Iterable[str], buffer_size: Optional[int] = None): + """Return the raw, cancellable ``Subscribe`` stream for ``paths``.""" + request = self._build_subscribe_request(paths, buffer_size) + return self._stream("Subscribe", request) + def subscribe( self, paths: Iterable[str], @@ -320,15 +325,20 @@ def subscribe( Yields ``Dict[str, Datapoint]`` for each batch of updates. The current value of every subscribed signal is yielded immediately. + + To unsubscribe, ``break`` out of the loop (or drop the generator); the + underlying stream is cancelled automatically. """ self._check_connected() - request = self._build_subscribe_request(paths, buffer_size) + stream = self._subscribe_stream(paths, buffer_size) try: - stream = self._stream("Subscribe", request) for response in stream: yield self._parse_subscribe_response(response) except grpc.RpcError as exc: raise from_grpc_error(exc) from exc + finally: + if hasattr(stream, "cancel"): + stream.cancel() def get_metadata(self, path: str) -> Metadata: """Return the metadata of a single signal.""" diff --git a/kuksa-client/kuksa_client/v2/aio.py b/kuksa-client/kuksa_client/v2/aio.py index 783179e..a9b6607 100644 --- a/kuksa-client/kuksa_client/v2/aio.py +++ b/kuksa-client/kuksa_client/v2/aio.py @@ -255,12 +255,15 @@ async def subscribe( ) -> AsyncIterator[Dict[str, Datapoint]]: self._check_connected() request = self._build_subscribe_request(paths, buffer_size) + stream = self._stream("Subscribe", request) try: - stream = self._stream("Subscribe", request) async for response in stream: yield self._parse_subscribe_response(response) except grpc.RpcError as exc: raise from_grpc_error(exc) from exc + finally: + if hasattr(stream, "cancel"): + stream.cancel() async def get_metadata(self, path: str) -> Metadata: self._check_connected() diff --git a/kuksa-client/tests/v2/test_cli.py b/kuksa-client/tests/v2/test_cli.py index 11ef43d..c7ba4af 100644 --- a/kuksa-client/tests/v2/test_cli.py +++ b/kuksa-client/tests/v2/test_cli.py @@ -5,14 +5,18 @@ # ********************************************************************************/ import io +import threading +import grpc import pytest from cmd2 import Cmd +from kuksa_client.__main__ import _BackgroundSubscription from kuksa_client.__main__ import _matching_paths from kuksa_client.__main__ import coerce_assignments from kuksa_client.__main__ import path_completer from kuksa_client.__main__ import set_completer +from kuksa_client.__main__ import unsubscribe_completer from kuksa_client.__main__ import KuksaShell from kuksa_client.v2 import Datapoint from kuksa_client.v2 import DataType @@ -134,47 +138,119 @@ def test_coerce_assignments_unknown_path(): coerce_assignments(client, ["Vehicle.NoSuch=1"]) -class _SubscribingClient: - def __init__(self, batches, connected=True): - self._batches = batches +class _ParseClient: + def __init__(self, connected=True): self.connected = connected - def subscribe(self, paths): - yield from self._batches + @staticmethod + def _parse_subscribe_response(response): + return {"Vehicle.Speed": Datapoint(42.0)} + @staticmethod + def _translate_rpc_error(exc): + return KuksaError(exc.details()) + + +class _FakeStream: + def __init__(self, n=1, error=None): + self._n = n + self._error = error + self.cancelled = False + + def __iter__(self): + for _ in range(self._n): + yield object() + if self._error is not None: + raise self._error + + def cancel(self): + self.cancelled = True -class _FailingClient: - def __init__(self, connected=True): - self.connected = connected - def subscribe(self, paths): - if False: # pragma: no cover - make this a generator - yield - raise KuksaError("Path not found") +class _FakeThread: + def __init__(self): + self.joined = False + + def join(self, timeout=None): + self.joined = True def _alert_shell(): - return Cmd(stdout=io.StringIO(), allow_cli_args=False) + shell = Cmd(stdout=io.StringIO(), allow_cli_args=False) + shell._subscriptions = {} + shell._subscription_lock = threading.Lock() + return shell + + +def _grpc_error(code, details): + return grpc.aio.AioRpcError( + code=code, + initial_metadata=grpc.aio.Metadata(), + trailing_metadata=grpc.aio.Metadata(), + details=details, + ) def test_subscribe_background_queues_alert(): shell = _alert_shell() - client = _SubscribingClient([{"Vehicle.Speed": Datapoint(42.0)}]) - KuksaShell._subscribe_background(shell, client, ["Vehicle.Speed"]) + client = _ParseClient() + KuksaShell._subscribe_background(shell, 1, client, _FakeStream(n=1)) assert len(shell._alert_queue) == 1 assert "42.0" in shell._alert_queue[0].msg +def test_subscribe_background_cancelled_silent(): + shell = _alert_shell() + client = _ParseClient(connected=True) + error = _grpc_error(grpc.StatusCode.CANCELLED, "cancelled") + KuksaShell._subscribe_background(shell, 1, client, _FakeStream(n=0, error=error)) + assert len(shell._alert_queue) == 0 + + def test_subscribe_background_error_alerts_when_connected(): shell = _alert_shell() - client = _FailingClient(connected=True) - KuksaShell._subscribe_background(shell, client, ["Vehicle.NoSuch"]) + client = _ParseClient(connected=True) + error = _grpc_error(grpc.StatusCode.NOT_FOUND, "Path not found") + KuksaShell._subscribe_background(shell, 1, client, _FakeStream(n=0, error=error)) assert len(shell._alert_queue) == 1 assert "Subscription error" in shell._alert_queue[0].msg def test_subscribe_background_error_silent_when_disconnected(): shell = _alert_shell() - client = _FailingClient(connected=False) - KuksaShell._subscribe_background(shell, client, ["Vehicle.NoSuch"]) + client = _ParseClient(connected=False) + error = _grpc_error(grpc.StatusCode.NOT_FOUND, "Path not found") + KuksaShell._subscribe_background(shell, 1, client, _FakeStream(n=0, error=error)) assert len(shell._alert_queue) == 0 + + +def test_unsubscribe_completer(): + shell = _alert_shell() + shell._subscriptions = { + 1: _BackgroundSubscription(paths=["Vehicle.Speed"]), + 2: _BackgroundSubscription(paths=["Vehicle.Speed", "Vehicle.ADAS.ABS.IsActive"]), + } + completions = unsubscribe_completer(shell, "", "", 0, 0) + by_text = {item.text: item.display for item in completions.items} + assert set(by_text) == {"1", "2"} + assert by_text["1"] == "1: Vehicle.Speed" + assert by_text["2"] == "2: Vehicle.Speed, Vehicle.ADAS.ABS.IsActive" + + +def test_cancel_subscription(): + shell = _alert_shell() + stream = _FakeStream() + thread = _FakeThread() + shell._subscriptions[3] = _BackgroundSubscription( + paths=["Vehicle.Speed"], stream=stream, thread=thread + ) + info = KuksaShell._cancel_subscription(shell, 3) + assert info.paths == ["Vehicle.Speed"] + assert stream.cancelled is True + assert thread.joined is True + assert 3 not in shell._subscriptions + + +def test_cancel_subscription_missing(): + shell = _alert_shell() + assert KuksaShell._cancel_subscription(shell, 99) is None From 82e430c7b6a58ac787c5125d8df8d45d3721f79a Mon Sep 17 00:00:00 2001 From: Sebastian Schildt Date: Tue, 1 Sep 2026 22:44:43 +0200 Subject: [PATCH 04/11] Mocking actuation providers Signed-off-by: Sebastian Schildt --- docs/cli.md | 3 + kuksa-client/kuksa_client/__main__.py | 168 +++++++++++++++++++++++ kuksa-client/kuksa_client/v2/aio.py | 16 ++- kuksa-client/kuksa_client/v2/provider.py | 28 +++- kuksa-client/tests/v2/test_cli.py | 89 ++++++++++++ kuksa-client/tests/v2/test_provider.py | 46 +++++++ 6 files changed, 343 insertions(+), 7 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index 301c60e..cb4f4d8 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -40,6 +40,7 @@ Available one-shot commands: | `set ` | Set values (e.g. `Vehicle.Speed=42`) | | `actuate ` | Actuate actuators (e.g. `Vehicle.Body.Wiper.Pos=45`) | | `subscribe ` | Subscribe to one or more paths | +| `mock-actuator ` | Provide a mock actuator that accepts and prints received actuations (until terminated) | | `get-metadata ` | Get the metadata of a path | | `list-metadata ` | List metadata matching a pattern | | `expand ` | Expand a wildcard pattern into paths | @@ -59,6 +60,8 @@ Available one-shot commands: | `subscribe ` | Subscribe to updates | | `subscribe -b ` | Subscribe in the background; updates print as alerts while the prompt stays usable | | `unsubscribe ` | Stop a background subscription | +| `mock_actuator ` | Register a mock provider that accepts and prints actuations | +| `remove_mock ` | Remove a mock actuator provider | | `get_metadata ` | Get the metadata of a path | | `list_metadata ` | List metadata matching a pattern | | `expand ` | Expand a wildcard pattern into paths | diff --git a/kuksa-client/kuksa_client/__main__.py b/kuksa-client/kuksa_client/__main__.py index 7fb0f12..872a0a2 100755 --- a/kuksa-client/kuksa_client/__main__.py +++ b/kuksa-client/kuksa_client/__main__.py @@ -45,6 +45,7 @@ from kuksa_client.v2 import KuksaClient from kuksa_client.v2 import KuksaError from kuksa_client.v2 import NotFound +from kuksa_client.v2 import Provider scriptDir = os.path.dirname(os.path.realpath(__file__)) @@ -143,6 +144,20 @@ def coerce_assignments(client, assignments): return updates +def _check_actuator_paths(client, paths): + """ + Return an error message if any of ``paths`` is not an actuator, else None. + + The databroker does not reliably reject non-actuator paths on + ``ProvideActuationRequest``, so the CLI validates this client-side. + """ + for path in paths: + metadata = client.get_metadata(path) + if metadata.entry_type != EntryType.ACTUATOR: + return f"{path} is not an actuator" + return None + + # --------------------------------------------------------------------------- # Path completion (interactive shell) # --------------------------------------------------------------------------- @@ -192,6 +207,17 @@ def unsubscribe_completer(shell, text, line, begidx, endidx): return shell.basic_complete(text, line, begidx, endidx, items) +def remove_mock_completer(shell, text, line, begidx, endidx): + """Complete active mock actuator provider ids.""" + items = [] + with shell._mock_lock: + for mock_id, info in shell._mocks.items(): + items.append( + CompletionItem(str(mock_id), display=f"{mock_id}: {', '.join(info.paths)}") + ) + return shell.basic_complete(text, line, begidx, endidx, items) + + # --------------------------------------------------------------------------- # Interactive shell # --------------------------------------------------------------------------- @@ -203,6 +229,13 @@ class _BackgroundSubscription: thread: threading.Thread = None +@dataclasses.dataclass +class _MockActuator: + paths: list + provider: object = None + thread: threading.Thread = None + + class KuksaShell(Cmd): COMM_SETUP_COMMANDS = "Communication Set-up Commands" VSS_COMMANDS = "Kuksa Interaction Commands" @@ -257,6 +290,22 @@ class KuksaShell(Cmd): completer=unsubscribe_completer, ) + ap_mock_actuator = Cmd2ArgumentParser() + ap_mock_actuator.add_argument( + "Path", + help="Actuator path to provide", + nargs="+", + completer=path_completer, + ) + + ap_remove_mock = Cmd2ArgumentParser() + ap_remove_mock.add_argument( + "Id", + type=int, + help="Id of a mock actuator provider to remove", + completer=remove_mock_completer, + ) + ap_get_metadata = Cmd2ArgumentParser() ap_get_metadata.add_argument( "Path", help="Path whose metadata is to be read", completer=path_completer @@ -299,6 +348,9 @@ def __init__(self, server, token_or_tokenfile=None, cacertificate=None, tls_serv self._subscriptions = {} self._subscription_lock = threading.Lock() self._subscription_counter = 0 + self._mocks = {} + self._mock_lock = threading.Lock() + self._mock_counter = 0 with (pathlib.Path(scriptDir) / "logo").open("r", encoding="utf-8") as logo_file: print(logo_file.read().replace("%ver%", str(_metadata.__version__))) @@ -378,6 +430,17 @@ def _stop_subscriptions(self): if info.thread is not None: info.thread.join(timeout=1) + def _stop_mocks(self): + with self._mock_lock: + infos = list(self._mocks.values()) + self._mocks.clear() + for info in infos: + if info.provider is not None: + info.provider.close() + for info in infos: + if info.thread is not None: + info.thread.join(timeout=1) + @with_category(COMM_SETUP_COMMANDS) @with_argparser(ap_connect) def do_connect(self, args): @@ -393,6 +456,7 @@ def do_disconnect(self, _args): self.client = None self._completion_paths = [] self._stop_subscriptions() + self._stop_mocks() @with_category(COMM_SETUP_COMMANDS) @with_argparser(ap_authorize) @@ -521,6 +585,89 @@ def _cancel_subscription(self, sub_id): info.thread.join(timeout=1) return info + @with_category(VSS_COMMANDS) + @with_argparser(ap_mock_actuator) + def do_mock_actuator(self, args): + """Register a mock provider that accepts and prints actuations""" + client = self._require_client() + try: + error = _check_actuator_paths(client, args.Path) + except KuksaError as exc: + print(f"Error: {exc}") + return + if error is not None: + print(f"Error: {error}") + return + + provider = Provider(client) + try: + provider.provide_actuators(args.Path) + except KuksaError as exc: + provider.close() + print(f"Error: {exc}") + return + + with self._mock_lock: + self._mock_counter += 1 + mock_id = self._mock_counter + thread = threading.Thread( + target=self._mock_actuator_loop, + args=(mock_id, provider), + daemon=True, + ) + with self._mock_lock: + self._mocks[mock_id] = _MockActuator( + paths=list(args.Path), provider=provider, thread=thread + ) + thread.start() + print(f"Registered mock actuator {mock_id} for {', '.join(args.Path)}") + + def _mock_actuator_loop(self, mock_id, provider): + try: + for requests in provider.actuation_requests(): + for request in requests: + message = highlight( + json.dumps( + {"path": request.path, "value": request.value}, + indent=2, + default=str, + ), + lexers.JsonLexer(), + formatters.TerminalFormatter(), + ) + self.add_alert(msg=message) + try: + provider.accept(request, ok=True) + except Exception: + pass + except Exception: + # The stream was terminated, e.g. by a disconnect or removal. + pass + finally: + with self._mock_lock: + self._mocks.pop(mock_id, None) + + @with_category(VSS_COMMANDS) + @with_argparser(ap_remove_mock) + def do_remove_mock(self, args): + """Remove a mock actuator provider""" + info = self._cancel_mock(args.Id) + if info is None: + print(f"No active mock actuator with id {args.Id}") + return + print(f"Removed mock actuator {args.Id} ({', '.join(info.paths)})") + + def _cancel_mock(self, mock_id): + with self._mock_lock: + info = self._mocks.pop(mock_id, None) + if info is None: + return None + if info.provider is not None: + info.provider.close() + if info.thread is not None: + info.thread.join(timeout=1) + return info + @with_category(VSS_COMMANDS) @with_argparser(ap_get_metadata) def do_get_metadata(self, args): @@ -583,6 +730,7 @@ def stop(self): self.client.disconnect() self.client = None self._stop_subscriptions() + self._stop_mocks() def _metadata_to_dict(metadata): @@ -633,6 +781,9 @@ def _build_one_shot_parser(): p_sub = subparsers.add_parser("subscribe", help="Subscribe to one or more paths") p_sub.add_argument("paths", nargs="+") + p_mock = subparsers.add_parser("mock-actuator", help="Provide a mock actuator that prints received actuations") + p_mock.add_argument("paths", nargs="+", help="Actuator paths to provide") + p_md = subparsers.add_parser("get-metadata", help="Get the metadata of a path") p_md.add_argument("path") @@ -686,6 +837,23 @@ def _run_one_shot(args): elif command == "subscribe": for updates in client.subscribe(args.paths): print(json.dumps({p: dp.value for p, dp in updates.items()}, default=str)) + elif command == "mock-actuator": + provider = Provider(client) + provider.provide_actuators(args.paths) + try: + for requests in provider.actuation_requests(): + for request in requests: + print( + json.dumps( + {"path": request.path, "value": request.value}, + default=str, + ) + ) + provider.accept(request, ok=True) + except KeyboardInterrupt: + pass + finally: + provider.close() elif command == "get-metadata": print(json.dumps(_metadata_to_dict(client.get_metadata(args.path)), indent=2)) elif command == "list-metadata": diff --git a/kuksa-client/kuksa_client/v2/aio.py b/kuksa-client/kuksa_client/v2/aio.py index a9b6607..5873cc4 100644 --- a/kuksa-client/kuksa_client/v2/aio.py +++ b/kuksa-client/kuksa_client/v2/aio.py @@ -355,7 +355,12 @@ async def _run(self) -> None: async for response in self._stream: await self._dispatch(response) except Exception as exc: # noqa: BLE001 - self._stream_error = exc + # Cancellation (e.g. close()) is expected and not an error. + if not ( + isinstance(exc, grpc.RpcError) + and exc.code() == grpc.StatusCode.CANCELLED + ): + self._stream_error = exc finally: await self._actuation_queue.put(_STOP) for event in self._pending.values(): @@ -416,7 +421,14 @@ async def provide_signals( async def provide_actuators( self, paths: Iterable[str], timeout: Optional[float] = None ) -> None: + # NOTE: does not verify that each path is an actuator (see the sync + # Provider.provide_actuators for details); callers should check + # Metadata.entry_type if they care. await self._open() + paths = list(paths) + # Resolve ids so incoming (id-keyed) actuation requests can be mapped + # back to their path, and so non-existent paths fail early. + await self._client._resolve_signal_ids(paths) request = self._build_provide_actuation_request(paths) self._register("provide_actuation_response") await self._send(request) @@ -456,6 +468,8 @@ async def close(self) -> None: if self._closed: return self._closed = True + if self._stream is not None: + self._stream.cancel() if self._reader_task is not None: self._reader_task.cancel() try: diff --git a/kuksa-client/kuksa_client/v2/provider.py b/kuksa-client/kuksa_client/v2/provider.py index 28204b2..e674e78 100644 --- a/kuksa-client/kuksa_client/v2/provider.py +++ b/kuksa-client/kuksa_client/v2/provider.py @@ -175,6 +175,7 @@ def __init__(self, client): self._send_queue: queue.Queue = queue.Queue() self._actuation_queue: queue.Queue = queue.Queue() self._reader_thread = None + self._stream = None self._pending: Dict[str, threading.Event] = {} self._stream_error = None @@ -192,6 +193,10 @@ def _open(self) -> None: self._check_not_closed() if self._reader_thread is not None: return + self._stream = self._client._stub.OpenProviderStream( + self._request_iterator(), + metadata=self._client._metadata_kwargs(), + ) self._reader_thread = threading.Thread( target=self._run, name="kuksa-provider", daemon=True ) @@ -206,14 +211,15 @@ def _request_iterator(self) -> Iterator[val_pb2.OpenProviderStreamRequest]: def _run(self) -> None: try: - responses = self._client._stub.OpenProviderStream( - self._request_iterator(), - metadata=self._client._metadata_kwargs(), - ) - for response in responses: + for response in self._stream: self._dispatch(response) except Exception as exc: # noqa: BLE001 - self._stream_error = exc + # Cancellation (e.g. close()) is expected and not an error. + if not ( + isinstance(exc, grpc.RpcError) + and exc.code() == grpc.StatusCode.CANCELLED + ): + self._stream_error = exc finally: self._actuation_queue.put(_STOP) for event in self._pending.values(): @@ -280,7 +286,15 @@ def provide_actuators( self, paths: Iterable[str], timeout: Optional[float] = None ) -> None: """Claim ownership of the actuators identified by ``paths``.""" + # NOTE: this does not verify that each path is an actuator. The + # databroker may accept (or ignore) non-actuator paths, so callers + # that care should check ``Metadata.entry_type == EntryType.ACTUATOR`` + # first (the CLI does this for its mock-provider command). self._open() + paths = list(paths) + # Resolve ids so incoming (id-keyed) actuation requests can be mapped + # back to their path, and so non-existent paths fail early. + self._client._resolve_signal_ids(paths) request = self._build_provide_actuation_request(paths) self._register("provide_actuation_response") self._send(request) @@ -324,6 +338,8 @@ def close(self) -> None: return self._closed = True self._send_queue.put(_STOP) + if self._stream is not None: + self._stream.cancel() if self._reader_thread is not None: self._reader_thread.join(timeout=5) self._reader_thread = None diff --git a/kuksa-client/tests/v2/test_cli.py b/kuksa-client/tests/v2/test_cli.py index c7ba4af..e81e56c 100644 --- a/kuksa-client/tests/v2/test_cli.py +++ b/kuksa-client/tests/v2/test_cli.py @@ -12,14 +12,18 @@ from cmd2 import Cmd from kuksa_client.__main__ import _BackgroundSubscription +from kuksa_client.__main__ import _check_actuator_paths +from kuksa_client.__main__ import _MockActuator from kuksa_client.__main__ import _matching_paths from kuksa_client.__main__ import coerce_assignments from kuksa_client.__main__ import path_completer from kuksa_client.__main__ import set_completer +from kuksa_client.__main__ import remove_mock_completer from kuksa_client.__main__ import unsubscribe_completer from kuksa_client.__main__ import KuksaShell from kuksa_client.v2 import Datapoint from kuksa_client.v2 import DataType +from kuksa_client.v2 import EntryType from kuksa_client.v2 import KuksaError from kuksa_client.v2 import Metadata @@ -138,6 +142,26 @@ def test_coerce_assignments_unknown_path(): coerce_assignments(client, ["Vehicle.NoSuch=1"]) +class _EntryTypeClient: + def __init__(self, entry_types): + self._entry_types = entry_types + + def get_metadata(self, path): + if path not in self._entry_types: + raise KuksaError(f"Path '{path}' does not exist") + return Metadata(path=path, entry_type=self._entry_types[path]) + + +def test_check_actuator_paths_rejects_non_actuator(): + client = _EntryTypeClient({"Vehicle.Speed": EntryType.SENSOR}) + assert _check_actuator_paths(client, ["Vehicle.Speed"]) == "Vehicle.Speed is not an actuator" + + +def test_check_actuator_paths_accepts_actuator(): + client = _EntryTypeClient({"Vehicle.Body.Wiper.Pos": EntryType.ACTUATOR}) + assert _check_actuator_paths(client, ["Vehicle.Body.Wiper.Pos"]) is None + + class _ParseClient: def __init__(self, connected=True): self.connected = connected @@ -179,6 +203,8 @@ def _alert_shell(): shell = Cmd(stdout=io.StringIO(), allow_cli_args=False) shell._subscriptions = {} shell._subscription_lock = threading.Lock() + shell._mocks = {} + shell._mock_lock = threading.Lock() return shell @@ -254,3 +280,66 @@ def test_cancel_subscription(): def test_cancel_subscription_missing(): shell = _alert_shell() assert KuksaShell._cancel_subscription(shell, 99) is None + + +class _FakeRequest: + def __init__(self, path, value): + self.path = path + self.value = value + + +class _FakeProvider: + def __init__(self, batches): + self._batches = batches + self.closed = False + self.accepted = [] + + def actuation_requests(self): + yield from self._batches + + def accept(self, request, ok=True): + self.accepted.append(request) + + def close(self): + self.closed = True + + +def test_remove_mock_completer(): + shell = _alert_shell() + shell._mocks = { + 1: _MockActuator(paths=["Vehicle.Body.Wiper.Pos"]), + 2: _MockActuator(paths=["Vehicle.Body.Wiper.Pos", "Vehicle.Cabin.Sunroof.Position"]), + } + completions = remove_mock_completer(shell, "", "", 0, 0) + by_text = {item.text: item.display for item in completions.items} + assert set(by_text) == {"1", "2"} + assert by_text["1"] == "1: Vehicle.Body.Wiper.Pos" + assert by_text["2"] == "2: Vehicle.Body.Wiper.Pos, Vehicle.Cabin.Sunroof.Position" + + +def test_cancel_mock(): + shell = _alert_shell() + provider = _FakeProvider(batches=[]) + thread = _FakeThread() + shell._mocks[5] = _MockActuator(paths=["Vehicle.Body.Wiper.Pos"], provider=provider, thread=thread) + info = KuksaShell._cancel_mock(shell, 5) + assert info.paths == ["Vehicle.Body.Wiper.Pos"] + assert provider.closed is True + assert thread.joined is True + assert 5 not in shell._mocks + + +def test_cancel_mock_missing(): + shell = _alert_shell() + assert KuksaShell._cancel_mock(shell, 99) is None + + +def test_mock_actuator_loop_accepts_and_alerts(): + shell = _alert_shell() + request = _FakeRequest("Vehicle.Body.Wiper.Pos", 45.0) + provider = _FakeProvider(batches=[[request]]) + KuksaShell._mock_actuator_loop(shell, 1, provider) + assert len(shell._alert_queue) == 1 + assert "Vehicle.Body.Wiper.Pos" in shell._alert_queue[0].msg + assert "45.0" in shell._alert_queue[0].msg + assert provider.accepted == [request] diff --git a/kuksa-client/tests/v2/test_provider.py b/kuksa-client/tests/v2/test_provider.py index 1cfd141..d3ccae4 100644 --- a/kuksa-client/tests/v2/test_provider.py +++ b/kuksa-client/tests/v2/test_provider.py @@ -4,10 +4,56 @@ # * SPDX-License-Identifier: Apache-2.0 # ********************************************************************************/ +import threading +import time + from kuksa_client.v2 import KuksaClient from kuksa_client.v2 import Provider +class _BlockingBidiStream: + def __init__(self): + self.cancelled = False + self._stop = threading.Event() + + def __iter__(self): + self._stop.wait() + return iter(()) + + def cancel(self): + self.cancelled = True + self._stop.set() + + +class _FakeStub: + def __init__(self, stream): + self._stream = stream + + def OpenProviderStream(self, request_iterator, metadata=None): + return self._stream + + +class _FakeClient: + def __init__(self, stream): + self._stub = _FakeStub(stream) + + def _metadata_kwargs(self): + return [] + + +def test_provider_close_cancels_stream(): + stream = _BlockingBidiStream() + provider = Provider(_FakeClient(stream)) + provider._open() + + start = time.monotonic() + provider.close() + elapsed = time.monotonic() - start + + assert stream.cancelled is True + assert elapsed < 2.0 + + def test_provider_publish(server): with KuksaClient("127.0.0.1", server) as client: provider = Provider(client) From af5b8b2cc95a466f82ca063ca9e24fcbe4925d3b Mon Sep 17 00:00:00 2001 From: Sebastian Schildt Date: Tue, 1 Sep 2026 22:59:29 +0200 Subject: [PATCH 05/11] Auto expansion in CLI set/subscribe Signed-off-by: Sebastian Schildt --- docs/cli.md | 10 +-- kuksa-client/kuksa_client/__main__.py | 105 ++++++++++++++++---------- kuksa-client/tests/v2/test_cli.py | 36 +++++++++ 3 files changed, 105 insertions(+), 46 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index cb4f4d8..5372c4a 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -36,14 +36,13 @@ Available one-shot commands: | Command | Description | |---------|-------------| -| `get ` | Get the value of one or more paths | +| `get ` | Get the value of one or more paths (wildcards are expanded) | | `set ` | Set values (e.g. `Vehicle.Speed=42`) | | `actuate ` | Actuate actuators (e.g. `Vehicle.Body.Wiper.Pos=45`) | -| `subscribe ` | Subscribe to one or more paths | +| `subscribe ` | Subscribe to one or more paths (wildcards are expanded) | | `mock-actuator ` | Provide a mock actuator that accepts and prints received actuations (until terminated) | | `get-metadata ` | Get the metadata of a path | | `list-metadata ` | List metadata matching a pattern | -| `expand ` | Expand a wildcard pattern into paths | | `has-signal ` | Check whether a signal exists | | `server-info` | Show databroker info | @@ -54,17 +53,16 @@ Available one-shot commands: | `connect ` | Connect to a databroker | | `disconnect` | Disconnect from the databroker | | `authorize ` | Authorize with a JWT token or token file | -| `get ` | Get the value of one or more paths | +| `get ` | Get the value of one or more paths (wildcards are expanded) | | `set ` | Set values | | `actuate ` | Actuate actuators (target values) | -| `subscribe ` | Subscribe to updates | +| `subscribe ` | Subscribe to updates (wildcards are expanded) | | `subscribe -b ` | Subscribe in the background; updates print as alerts while the prompt stays usable | | `unsubscribe ` | Stop a background subscription | | `mock_actuator ` | Register a mock provider that accepts and prints actuations | | `remove_mock ` | Remove a mock actuator provider | | `get_metadata ` | Get the metadata of a path | | `list_metadata ` | List metadata matching a pattern | -| `expand ` | Expand a wildcard pattern into paths | | `has_signal ` | Check whether a signal exists | | `info` / `version` | Show client info / version | diff --git a/kuksa-client/kuksa_client/__main__.py b/kuksa-client/kuksa_client/__main__.py index 872a0a2..61b46e4 100755 --- a/kuksa-client/kuksa_client/__main__.py +++ b/kuksa-client/kuksa_client/__main__.py @@ -158,6 +158,28 @@ def _check_actuator_paths(client, paths): return None +def _expand_wildcard_paths(client, paths): + """ + Resolve ``paths`` into concrete signal paths. + + Paths containing ``*`` are expanded via ``client.expand``; exact paths are + passed through unchanged. Duplicates are removed, order is preserved. + """ + resolved = [] + for path in paths: + if "*" in path: + resolved.extend(client.expand(path)) + else: + resolved.append(path) + seen = set() + result = [] + for path in resolved: + if path not in seen: + seen.add(path) + result.append(path) + return result + + # --------------------------------------------------------------------------- # Path completion (interactive shell) # --------------------------------------------------------------------------- @@ -316,16 +338,6 @@ class KuksaShell(Cmd): "Pattern", help="Exact path or wildcard pattern", completer=path_completer ) - ap_expand = Cmd2ArgumentParser() - ap_expand.add_argument("Pattern", help="Wildcard pattern", completer=path_completer) - ap_expand.add_argument( - "-t", - "--entry-type", - choices=[e.name for e in EntryType], - default=None, - help="Only list signals of this entry type", - ) - ap_has_signal = Cmd2ArgumentParser() ap_has_signal.add_argument("Path", help="Path to check", completer=path_completer) @@ -470,17 +482,29 @@ def do_authorize(self, args): @with_category(VSS_COMMANDS) @with_argparser(ap_get) def do_get(self, args): - """Get the value of one or more paths""" + """Get the value of one or more paths (wildcards are expanded)""" client = self._require_client() + single = len(args.Path) == 1 and not any("*" in path for path in args.Path) try: - result = client.get(args.Path if len(args.Path) > 1 else args.Path[0]) + resolved = _expand_wildcard_paths(client, args.Path) except KuksaError as exc: print(f"Error: {exc}") return - if isinstance(result, dict): - self._print_json({path: dp.value for path, dp in result.items()}) - else: + if not resolved: + print("No signals match the given path(s)") + return + try: + if single: + result = client.get(resolved[0]) + else: + result = client.get(resolved) + except KuksaError as exc: + print(f"Error: {exc}") + return + if single: self._print_json({"value": result.value, "timestamp": result.timestamp}) + else: + self._print_json({path: dp.value for path, dp in result.items()}) @with_category(VSS_COMMANDS) @with_argparser(ap_set) @@ -515,10 +539,18 @@ def do_actuate(self, args): @with_category(VSS_COMMANDS) @with_argparser(ap_subscribe) def do_subscribe(self, args): - """Subscribe to updates of one or more paths""" + """Subscribe to updates of one or more paths (wildcards are expanded)""" client = self._require_client() + try: + resolved = _expand_wildcard_paths(client, args.Path) + except KuksaError as exc: + print(f"Error: {exc}") + return + if not resolved: + print("No signals match the given path(s)") + return if args.background: - stream = client._subscribe_stream(args.Path) + stream = client._subscribe_stream(resolved) with self._subscription_lock: self._subscription_counter += 1 sub_id = self._subscription_counter @@ -535,7 +567,7 @@ def do_subscribe(self, args): print(f"Subscribed to {', '.join(args.Path)} (subscription {sub_id})") return try: - for updates in client.subscribe(args.Path): + for updates in client.subscribe(resolved): self._print_json({path: dp.value for path, dp in updates.items()}) except KuksaError as exc: print(f"Error: {exc}") @@ -690,18 +722,6 @@ def do_list_metadata(self, args): except KuksaError as exc: print(f"Error: {exc}") - @with_category(VSS_COMMANDS) - @with_argparser(ap_expand) - def do_expand(self, args): - """Expand a wildcard pattern into concrete signal paths""" - client = self._require_client() - try: - entry_type = EntryType[args.entry_type] if args.entry_type else None - paths = client.expand(args.Pattern, entry_type=entry_type) - self._print_json(paths) - except KuksaError as exc: - print(f"Error: {exc}") - @with_category(VSS_COMMANDS) @with_argparser(ap_has_signal) def do_has_signal(self, args): @@ -790,9 +810,6 @@ def _build_one_shot_parser(): p_lmd = subparsers.add_parser("list-metadata", help="List metadata matching a pattern") p_lmd.add_argument("pattern") - p_exp = subparsers.add_parser("expand", help="Expand a wildcard pattern into paths") - p_exp.add_argument("pattern") - p_has = subparsers.add_parser("has-signal", help="Check whether a signal exists") p_has.add_argument("path") @@ -825,17 +842,27 @@ def _run_one_shot(args): command = args.command if command == "get": paths = args.paths - result = client.get(paths if len(paths) > 1 else paths[0]) - if isinstance(result, dict): - print(json.dumps({p: dp.value for p, dp in result.items()}, indent=2, default=str)) - else: + single = len(paths) == 1 and not any("*" in p for p in paths) + resolved = _expand_wildcard_paths(client, paths) + if not resolved: + print("No signals match the given path(s)", file=sys.stderr) + return 1 + if single: + result = client.get(resolved[0]) print(json.dumps({"value": result.value, "timestamp": result.timestamp}, indent=2, default=str)) + else: + result = client.get(resolved) + print(json.dumps({p: dp.value for p, dp in result.items()}, indent=2, default=str)) elif command == "set": client.set(coerce_assignments(client, args.assignments)) elif command == "actuate": client.actuate(coerce_assignments(client, args.assignments)) elif command == "subscribe": - for updates in client.subscribe(args.paths): + resolved = _expand_wildcard_paths(client, args.paths) + if not resolved: + print("No signals match the given path(s)", file=sys.stderr) + return 1 + for updates in client.subscribe(resolved): print(json.dumps({p: dp.value for p, dp in updates.items()}, default=str)) elif command == "mock-actuator": provider = Provider(client) @@ -858,8 +885,6 @@ def _run_one_shot(args): print(json.dumps(_metadata_to_dict(client.get_metadata(args.path)), indent=2)) elif command == "list-metadata": print(json.dumps([_metadata_to_dict(m) for m in client.list_metadata(args.pattern)], indent=2)) - elif command == "expand": - print("\n".join(client.expand(args.pattern))) elif command == "has-signal": print(client.has_signal(args.path)) elif command == "server-info": diff --git a/kuksa-client/tests/v2/test_cli.py b/kuksa-client/tests/v2/test_cli.py index e81e56c..5f6c1e6 100644 --- a/kuksa-client/tests/v2/test_cli.py +++ b/kuksa-client/tests/v2/test_cli.py @@ -13,6 +13,7 @@ from kuksa_client.__main__ import _BackgroundSubscription from kuksa_client.__main__ import _check_actuator_paths +from kuksa_client.__main__ import _expand_wildcard_paths from kuksa_client.__main__ import _MockActuator from kuksa_client.__main__ import _matching_paths from kuksa_client.__main__ import coerce_assignments @@ -162,6 +163,41 @@ def test_check_actuator_paths_accepts_actuator(): assert _check_actuator_paths(client, ["Vehicle.Body.Wiper.Pos"]) is None +class _ExpandClient: + def __init__(self, expansions): + self._expansions = expansions + + def expand(self, pattern): + return self._expansions.get(pattern, []) + + +def test_expand_wildcard_paths_exact_passthrough(): + client = _ExpandClient({}) + assert _expand_wildcard_paths(client, ["Vehicle.Speed"]) == ["Vehicle.Speed"] + + +def test_expand_wildcard_paths_expands_and_dedupes(): + client = _ExpandClient({ + "Vehicle.Cabin.**": [ + "Vehicle.Cabin.Sunroof.Position", + "Vehicle.Cabin.Sunroof.Switch", + ], + "Vehicle.*": ["Vehicle.Speed", "Vehicle.SomeString"], + }) + assert _expand_wildcard_paths( + client, ["Vehicle.Cabin.**", "Vehicle.Speed", "Vehicle.Cabin.**"] + ) == [ + "Vehicle.Cabin.Sunroof.Position", + "Vehicle.Cabin.Sunroof.Switch", + "Vehicle.Speed", + ] + + +def test_expand_wildcard_paths_empty(): + client = _ExpandClient({"Vehicle.NoSuch.**": []}) + assert _expand_wildcard_paths(client, ["Vehicle.NoSuch.**"]) == [] + + class _ParseClient: def __init__(self, connected=True): self.connected = connected From 0bf139d9d15731403b3040906cd1b648ecd61574 Mon Sep 17 00:00:00 2001 From: Sebastian Schildt Date: Tue, 1 Sep 2026 23:11:31 +0200 Subject: [PATCH 06/11] Developer docs added Signed-off-by: Sebastian Schildt --- README.md | 3 + docs/architecture.md | 217 +++++++++++++++++++++++++++ docs/library.md | 6 + kuksa-client/kuksa_client/v2/core.py | 6 +- 4 files changed, 229 insertions(+), 3 deletions(-) create mode 100644 docs/architecture.md diff --git a/README.md b/README.md index b50f37b..6c37c4b 100644 --- a/README.md +++ b/README.md @@ -80,3 +80,6 @@ See the KUKSA Python SDK [Contribition document](https://github.com/eclipse-kuks For information on tools useful for KUKSA Python SDK development environment and help on troubleshooting frequent problems please visit the KUKSA Python SDK [development and troubleshooting documentation](https://github.com/eclipse-kuksa/kuksa-python-sdk/blob/main/docs/development_troubleshoot.md). + +For an overview of the `kuksa_client.v2` library internals and its design +patterns, see the [architecture documentation](https://github.com/eclipse-kuksa/kuksa-python-sdk/blob/main/docs/architecture.md). diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..12818fc --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,217 @@ +# Architecture of the `kuksa_client.v2` library + +This document describes how the redesigned `kuksa_client.v2` package is +structured and the patterns it follows. It is intended as a starting point for +anyone extending or understanding the implementation. It deliberately does **not** +cover the CLI (`kuksa_client.__main__`), which is a consumer of this library. + +See also the design rationale in [`Redesign.md`](../Redesign.md) and the +user-facing [`library.md`](library.md). + +## Goals in one paragraph + +The v2 SDK talks to a **`kuksa.val.v2`** databroker only. It exposes two +first-class clients — synchronous (`kuksa_client.v2.KuksaClient`) and +asynchronous (`kuksa_client.v2.aio.KuksaClient`) — with the same API, plus a +`Provider` for claiming/publishing/actuating signals. The currency of the API is +native Python values. Every power-user need that is not covered by the typed API +is reachable through a raw-proto "escape hatch". + +## Module map + +| Module | Responsibility | +|--------|----------------| +| `types.py` | Pure dataclasses/enums (`Datapoint`, `Metadata`, `DataType`, `EntryType`, `ServerInfo`, …). No proto imports. | +| `codec.py` | The single source of truth for `native value ↔ kuksa.val.v2 Value/Datapoint` encoding. | +| `patterns.py` | Client-side wildcard matching (databroker semantics pinned locally). | +| `metadata.py` | `MetadataStore`: an in-memory, per-connection metadata/id cache. | +| `errors.py` | `KuksaError` hierarchy + mappers from gRPC status codes and v2 `ErrorCode`s. | +| `transport.py` | Channel + TLS construction helpers for sync and aio. | +| `core.py` | `_KuksaCore`: transport-agnostic shared state and protocol logic. | +| `__init__.py` | The synchronous `KuksaClient`. | +| `aio.py` | The asynchronous `KuksaClient` and asynchronous `Provider`. | +| `provider.py` | `Provider` (shared base + sync implementation) and `ActuationRequest`. | + +Dependency direction is one-way: `types`/`errors` are leaf modules; `codec`, +`patterns`, `metadata`, `transport` depend only on them; `core` composes those; +`__init__`/`aio`/`provider` sit on top. + +## Core design: shared core + thin I/O + +The central pattern is a **transport-agnostic core** (`_KuksaCore` in `core.py`) +plus two **thin I/O backends** (sync in `__init__.py`, async in `aio.py`). + +`_KuksaCore` owns everything that is *not* I/O: + +- connection parameters, the `authorization` header, and the `MetadataStore`; +- **request builders** (`_build_get_value_request`, `_build_subscribe_request`, …); +- **response parsers** (`_parse_get_value_response`, `_parse_subscribe_response`, …); +- pure helpers for path expansion, error translation, and connection checks. + +The concrete clients are expected to implement only a handful of primitives: + +```python +class _KuksaCore: + def _call(self, rpc_name, request, timeout=None): ... # unary + def _stream(self, rpc_name, request, timeout=None): ... # server-streaming + def connect(self): ... + def disconnect(self): ... +``` + +The sync client implements these against `grpc` + the generated `VALStub`; the +async client implements them against `grpc.aio` + the same `VALStub`. + +### Why the public method bodies are thin twins + +gRPC ships distinct synchronous and asynchronous stubs, and Python cannot share +an `await`/`async for` with a plain `for`/return without introducing a background +event loop. To keep both clients genuinely native, the public methods (`get`, +`set`, `subscribe`, `actuate`, `get_metadata`, …) exist in both clients and are +small, differing only in `await`/`async for`. All the *protocol* logic they need +— building requests, parsing responses, resolving data types, mapping errors — +lives once in `core.py` / `codec.py` / `metadata.py`. + +For example, `subscribe` is written once per client as: + +```python +# sync +def subscribe(self, paths, buffer_size=None): + stream = self._subscribe_stream(paths, buffer_size) + try: + for response in stream: + yield self._parse_subscribe_response(response) + except grpc.RpcError as exc: + raise from_grpc_error(exc) from exc + finally: + stream.cancel() +``` + +```python +# async +async def subscribe(self, paths, buffer_size=None): + stream = self._stream("Subscribe", self._build_subscribe_request(paths, buffer_size)) + try: + async for response in stream: + yield self._parse_subscribe_response(response) + except grpc.RpcError as exc: + raise from_grpc_error(exc) from exc + finally: + stream.cancel() +``` + +`_parse_subscribe_response` and `_build_subscribe_request` are shared (in +`core.py`); only the loop and `await` differ. The `finally: stream.cancel()` +makes "unsubscribing" (breaking out of the loop) cancel the underlying gRPC call +cleanly. + +## Value encoding (`codec.py`) + +`codec.py` holds the single mapping `DataType → (proto Value field, Python type)` +(see `_FIELD_MAP`). Everything that turns native values into `kuksa.val.v2.Value` +or `Datapoint` (and back) goes through it. + +Notable points: + +- protobuf has no `int8`/`int16`/`uint8`/`uint16`, so those `DataType`s alias to + `int32`/`uint32` fields — this aliasing is encoded in the map, not scattered. +- `TIMESTAMP`/`TIMESTAMP_ARRAY` are intentionally absent (the v2 `Value` oneof + has no timestamp field). +- No string casting happens here — values are expected to be native Python values + of the right type. (The CLI does its own coercion.) +- `to_proto_value` / `from_proto_value` are the public escape hatch for raw + value encoding. + +Adding a new data type is a one-line change to `_FIELD_MAP`. + +## Metadata caching (`metadata.py`) + +`MetadataStore` is an in-memory cache bound to a connection, mapping a signal +path to its `Metadata` (which carries `id`, `data_type`, `entry_type`, etc.). It +caches both positively (known signals) and negatively (paths known not to exist) +and is cleared on reconnect. + +There is deliberately **no TTL**: VSS metadata is assumed static while a system +runs. This is what makes `set`/`actuate` without an explicit data type a cached +lookup rather than a round-trip, and it also warms the `id ↔ path` mapping that +the `Provider` relies on. + +## Client-side wildcard matching (`patterns.py`) + +`get`/`set`/`subscribe` operate on exact paths. Wildcards are handled entirely +client-side, pinning the databroker's `wildcard_matching.md` semantics so the +client never depends on server-side matching: + +- `*` matches exactly one segment, `**` zero-or-more segments, both valid anywhere; +- a plain branch path matches the branch and everything below it; +- `**` combined with consecutive `*` segments is unsupported (raises `ValueError`). + +`literal_prefix(pattern)` returns the longest literal prefix, which is used to +bound a `ListMetadata(root=)` call; `*` is never sent to the broker. +`expand(pattern, entry_type=...)` and `list_metadata(pattern)` fetch the bounded +subtree once and then match client-side via `patterns.compile_pattern`. + +## Error hierarchy (`errors.py`) + +Two kinds of failures are modelled: + +- **transport/gRPC errors** — unary/stream RPC status codes — mapped by + `from_grpc_error(exc)` to subclasses such as `NotFound`, `PermissionDenied`, + `Unauthenticated`, `Unavailable`, `AlreadyExists`, `Aborted`, `DataLoss`, plus + the generic `KuksaTransportError`. +- **application/stream errors** — in-stream v2 `Error` messages and provider + errors — mapped by `from_error_message(error)` to `KuksaStreamError` and + friends. + +All exceptions derive from `KuksaError`, so `except KuksaError` catches +everything the SDK raises. + +## Providers (`provider.py`) + +A `Provider` is backed by the bidirectional `OpenProviderStream` RPC. The +message types are id-keyed (`ProvideSignalRequest` = `map`, +`PublishValuesRequest` = `map`), so the provider depends on the +`MetadataStore` id↔path mapping (populated via `ListMetadata`). + +Shared protocol logic lives in `_ProviderBase` (request building, id/type +resolution, request_id generation, actuation-request parsing). The two concrete +implementations differ only in stream plumbing: + +- **sync** (`provider.py`): a reader thread iterates the stream and dispatches + responses; requests are sent through a `queue.Queue`; `close()` cancels the + underlying stream so the databroker releases the provider's claims. +- **async** (`aio.py`): an asyncio task reads the stream into an + `asyncio.Queue`; `await _send()` writes; `close()` cancels the stream and the + reader task. + +A connection drop terminates the stream, so provider iteration ends/raises rather +than hanging; re-registration after a reconnect is the caller's explicit action. + +## Authentication + +`authorize(token)` just stores an `authorization: Bearer ` header that is +attached as per-call gRPC metadata on every subsequent RPC. The old v1 +`GetServerInfo` pseudo-auth round-trip is not carried over. + +## Escape hatch + +For features the typed API does not expose: + +- `client.stub` — the raw generated `kuksa.val.v2.VALStub`; +- `codec.to_proto_value` / `codec.from_proto_value` — raw value encoding. + +## How to extend + +- **Add a unary RPC**: add `_build_*` / `_parse_*` helpers to `core.py`, then a + thin public method to both `__init__.py` (sync) and `aio.py` (async) that + calls `self._call(...)` and the parser. +- **Add a streaming RPC**: same, but use `self._stream(...)` and mirror the + `for`/`async for` + `finally: stream.cancel()` shape of `subscribe`. +- **Add a provider feature**: add the message builder to `_ProviderBase` and the + response handling to the sync `_dispatch` and async `_dispatch`. +- **Add a data type**: add one row to `codec._FIELD_MAP`. + +## References + +- [`Redesign.md`](../Redesign.md) — the design decisions and rationale. +- [`library.md`](library.md) — the public API documentation. +- [`examples/`](examples/) — synchronous, asynchronous, and provider examples. diff --git a/docs/library.md b/docs/library.md index 447c475..6e63039 100644 --- a/docs/library.md +++ b/docs/library.md @@ -207,3 +207,9 @@ codec.from_proto_value(...) # proto -> native The legacy APIs remain available but emit a `DeprecationWarning`. Their examples are kept under [`examples/legacy/`](examples/legacy/). + +## Further reading + +- [`architecture.md`](architecture.md) — how the `kuksa_client.v2` library is + structured and its design patterns. +- [`Redesign.md`](../Redesign.md) — the design decisions behind the redesign. diff --git a/kuksa-client/kuksa_client/v2/core.py b/kuksa-client/kuksa_client/v2/core.py index 72f4f47..c20a357 100644 --- a/kuksa-client/kuksa_client/v2/core.py +++ b/kuksa-client/kuksa_client/v2/core.py @@ -16,9 +16,9 @@ clients. This module contains no I/O of its own. Concrete clients supply the low level -``_call``, ``_stream`` and ``_open_provider_stream`` primitives; everything else -(request building, response parsing, type/id resolution, error mapping, path -expansion) lives here so it is written exactly once. +``_call`` and ``_stream`` primitives (plus ``connect``/``disconnect``); +everything else (request building, response parsing, type/id resolution, error +mapping, path expansion) lives here so it is written exactly once. """ from __future__ import annotations From 0e2c78efa9215eded39ce8f4d04f00376cbd94fa Mon Sep 17 00:00:00 2001 From: Sebastian Schildt Date: Wed, 2 Sep 2026 09:38:09 +0200 Subject: [PATCH 07/11] Comment not yet existing link in Readme Signed-off-by: Sebastian Schildt --- README.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 6c37c4b..9116583 100644 --- a/README.md +++ b/README.md @@ -81,5 +81,8 @@ See the KUKSA Python SDK [Contribition document](https://github.com/eclipse-kuks For information on tools useful for KUKSA Python SDK development environment and help on troubleshooting frequent problems please visit the KUKSA Python SDK [development and troubleshooting documentation](https://github.com/eclipse-kuksa/kuksa-python-sdk/blob/main/docs/development_troubleshoot.md). + From 8489671ee3f5c4b0bc294e9109ce5d6ab0e9ec4b Mon Sep 17 00:00:00 2001 From: Sebastian Schildt Date: Wed, 2 Sep 2026 10:29:29 +0200 Subject: [PATCH 08/11] Add Unix Domain socket support Signed-off-by: Sebastian Schildt --- docs/architecture.md | 2 +- docs/cli.md | 14 ++++++++- docs/library.md | 8 +++++ kuksa-client/kuksa_client/__main__.py | 26 ++++++++++++++-- kuksa-client/kuksa_client/v2/__init__.py | 8 ++++- kuksa-client/kuksa_client/v2/aio.py | 8 ++++- kuksa-client/kuksa_client/v2/core.py | 2 ++ kuksa-client/kuksa_client/v2/transport.py | 35 +++++++++++++++------- kuksa-client/tests/v2/conftest.py | 34 +++++++++++++++++++++ kuksa-client/tests/v2/test_client.py | 9 +++++- kuksa-client/tests/v2/test_client_async.py | 9 ++++++ 11 files changed, 136 insertions(+), 19 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 12818fc..1c57f75 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -26,7 +26,7 @@ is reachable through a raw-proto "escape hatch". | `patterns.py` | Client-side wildcard matching (databroker semantics pinned locally). | | `metadata.py` | `MetadataStore`: an in-memory, per-connection metadata/id cache. | | `errors.py` | `KuksaError` hierarchy + mappers from gRPC status codes and v2 `ErrorCode`s. | -| `transport.py` | Channel + TLS construction helpers for sync and aio. | +| `transport.py` | Channel + TLS construction helpers for sync and aio (TCP or unix socket). | | `core.py` | `_KuksaCore`: transport-agnostic shared state and protocol logic. | | `__init__.py` | The synchronous `KuksaClient`. | | `aio.py` | The asynchronous `KuksaClient` and asynchronous `Provider`. | diff --git a/docs/cli.md b/docs/cli.md index 5372c4a..83fd4b9 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -50,7 +50,8 @@ Available one-shot commands: | Command | Description | |---------|-------------| -| `connect ` | Connect to a databroker | +| `connect ` | Connect to a databroker over TCP | +| `connect ` | Connect to a databroker over a unix domain socket | | `disconnect` | Disconnect from the databroker | | `authorize ` | Authorize with a JWT token or token file | | `get ` | Get the value of one or more paths (wildcards are expanded) | @@ -92,6 +93,17 @@ If connecting by IP address, `--tls-server-name` may also be required: kuksa-client --server grpcs://127.0.0.1:55555 --cacertificate ~/kuksa-common/tls/CA.pem --tls-server-name Server ``` +## Unix domain sockets + +KUKSA Client can connect to a databroker listening on a unix domain socket +using the `unix://` scheme (note the triple slash for an absolute path): + +```console +kuksa-client --server unix:///tmp/kuksa.sock get Vehicle.Speed +``` + +The databroker is started with e.g. `--enable-unix-socket --unix-socket /tmp/kuksa.sock`. + ## Authorization If the databroker requires authorization, authorize with a token or token file: diff --git a/docs/library.md b/docs/library.md index 6e63039..db4d816 100644 --- a/docs/library.md +++ b/docs/library.md @@ -73,12 +73,20 @@ KuksaClient( token=None, # optional JWT token root_certificates=None, # optional pathlib.Path to a CA for TLS tls_server_name=None, # optional TLS server name override + unix_socket=None, # optional path to a unix domain socket (ignores host/port) ) ``` Both clients are context managers; entering them connects, exiting disconnects. You may also call `connect()` / `disconnect()` explicitly. +To connect over a unix domain socket, pass `unix_socket`: + +```python +with KuksaClient(unix_socket="/tmp/kuksa.sock") as client: + ... +``` + ### Values (`get` / `set`) - `get(path)` returns a `Datapoint`. A non-existent path raises `NotFound`; diff --git a/kuksa-client/kuksa_client/__main__.py b/kuksa-client/kuksa_client/__main__.py index 61b46e4..daf9ca0 100755 --- a/kuksa-client/kuksa_client/__main__.py +++ b/kuksa-client/kuksa_client/__main__.py @@ -266,7 +266,7 @@ class KuksaShell(Cmd): ap_connect = Cmd2ArgumentParser() ap_connect.add_argument( "server", - help="Databroker to connect to. Format: grpc://host[:port] or grpcs://host[:port].", + help="Databroker to connect to. Format: grpc://host[:port], grpcs://host[:port] or unix:///path/to/socket.", ) ap_authorize = Cmd2ArgumentParser() @@ -380,6 +380,15 @@ def _load_token(self, token_or_tokenfile): def _connect_kwargs(self): srv = urlparse(self.server) + if srv.scheme == "unix": + kwargs = { + "unix_socket": srv.path, + "tls_server_name": self.tls_server_name, + } + token = self._load_token(self.token_or_tokenfile) + if token: + kwargs["token"] = token + return kwargs host = srv.hostname or "127.0.0.1" port = srv.port or 55555 kwargs = { @@ -413,7 +422,10 @@ def connect(self): kwargs = self._connect_kwargs() if kwargs is None: return - print(f"Connecting to databroker at {kwargs['host']} port {kwargs['port']}...") + if "unix_socket" in kwargs: + print(f"Connecting to databroker at unix://{kwargs['unix_socket']}...") + else: + print(f"Connecting to databroker at {kwargs['host']} port {kwargs['port']}...") self.client = KuksaClient(**kwargs) self.client.connect() try: @@ -781,7 +793,7 @@ def _build_one_shot_parser(): parser.add_argument( "--server", default=DEFAULT_KUKSA_ADDRESS, - help="Databroker to connect to. Format: grpc://host[:port] or grpcs://host[:port].", + help="Databroker to connect to. Format: grpc://host[:port], grpcs://host[:port] or unix:///path/to/socket.", ) parser.add_argument("--token", default=DEFAULT_TOKEN_OR_TOKENFILE, help="JWT token or path to a .token file") parser.add_argument("--cacertificate", default=DEFAULT_CACERTIFICATE, help="Client root cert file (.pem)") @@ -820,6 +832,14 @@ def _build_one_shot_parser(): def _open_client(args): srv = urlparse(args.server) + if srv.scheme == "unix": + kwargs = {"unix_socket": srv.path, "tls_server_name": args.tls_server_name} + token = args.token + if token and pathlib.Path(token).is_file(): + token = pathlib.Path(token).read_text(encoding="utf-8").rstrip("\n") + if token: + kwargs["token"] = token + return KuksaClient(**kwargs) host = srv.hostname or "127.0.0.1" port = srv.port or 55555 kwargs = {"host": host, "port": port, "tls_server_name": args.tls_server_name} diff --git a/kuksa-client/kuksa_client/v2/__init__.py b/kuksa-client/kuksa_client/v2/__init__.py index 75ac57d..a7bd8e9 100644 --- a/kuksa-client/kuksa_client/v2/__init__.py +++ b/kuksa-client/kuksa_client/v2/__init__.py @@ -88,6 +88,7 @@ def __init__( token: Optional[str] = None, root_certificates: Optional[Path] = None, tls_server_name: Optional[str] = None, + unix_socket: Optional[Path] = None, ensure_startup_connection: bool = True, ): super().__init__( @@ -96,6 +97,7 @@ def __init__( token=token, root_certificates=root_certificates, tls_server_name=tls_server_name, + unix_socket=unix_socket, ensure_startup_connection=ensure_startup_connection, ) self._channel = None @@ -116,7 +118,11 @@ def connect(self) -> None: self.disconnect() self._channel = self._exit_stack.enter_context( transport.create_sync_channel( - self.host, self.port, self.root_certificates, self.tls_server_name + self.host, + self.port, + self.root_certificates, + self.tls_server_name, + self.unix_socket, ) ) self._stub = val_pb2_grpc.VALStub(self._channel) diff --git a/kuksa-client/kuksa_client/v2/aio.py b/kuksa-client/kuksa_client/v2/aio.py index 5873cc4..f339eaa 100644 --- a/kuksa-client/kuksa_client/v2/aio.py +++ b/kuksa-client/kuksa_client/v2/aio.py @@ -57,6 +57,7 @@ def __init__( token: Optional[str] = None, root_certificates: Optional[Path] = None, tls_server_name: Optional[str] = None, + unix_socket: Optional[Path] = None, ensure_startup_connection: bool = True, ): super().__init__( @@ -65,6 +66,7 @@ def __init__( token=token, root_certificates=root_certificates, tls_server_name=tls_server_name, + unix_socket=unix_socket, ensure_startup_connection=ensure_startup_connection, ) self._channel = None @@ -82,7 +84,11 @@ async def connect(self) -> None: await self.disconnect() self._channel = await self._exit_stack.enter_async_context( transport.create_aio_channel( - self.host, self.port, self.root_certificates, self.tls_server_name + self.host, + self.port, + self.root_certificates, + self.tls_server_name, + self.unix_socket, ) ) self._stub = val_pb2_grpc.VALStub(self._channel) diff --git a/kuksa-client/kuksa_client/v2/core.py b/kuksa-client/kuksa_client/v2/core.py index c20a357..661c830 100644 --- a/kuksa-client/kuksa_client/v2/core.py +++ b/kuksa-client/kuksa_client/v2/core.py @@ -60,6 +60,7 @@ def __init__( token: Optional[str] = None, root_certificates: Optional[Path] = None, tls_server_name: Optional[str] = None, + unix_socket: Optional[Path] = None, ensure_startup_connection: bool = True, ): self.host = host @@ -67,6 +68,7 @@ def __init__( self.token = token self.root_certificates = root_certificates self.tls_server_name = tls_server_name + self.unix_socket = unix_socket self.ensure_startup_connection = ensure_startup_connection self._authorization_header = self._get_authorization_header(token) self._metadata_store = MetadataStore() diff --git a/kuksa-client/kuksa_client/v2/transport.py b/kuksa-client/kuksa_client/v2/transport.py index 99fdd02..9fbbb9d 100644 --- a/kuksa-client/kuksa_client/v2/transport.py +++ b/kuksa-client/kuksa_client/v2/transport.py @@ -30,10 +30,21 @@ def _build_credentials(root_certificates: Optional[Path]): return grpc.ssl_channel_credentials(root_certificates.read_bytes()) -def _channel_options(tls_server_name: Optional[str]): +def _channel_options( + tls_server_name: Optional[str] = None, unix_socket: Optional[Path] = None +): + options = [] if tls_server_name: - return [("grpc.ssl_target_name_override", tls_server_name)] - return None + options.append(("grpc.ssl_target_name_override", tls_server_name)) + if unix_socket is not None: + options.append(("grpc.default_authority", "localhost")) + return options or None + + +def _target(host: str, port: int, unix_socket: Optional[Path]) -> str: + if unix_socket is not None: + return f"unix:{unix_socket}" + return f"{host}:{port}" def create_sync_channel( @@ -41,12 +52,14 @@ def create_sync_channel( port: int, root_certificates: Optional[Path] = None, tls_server_name: Optional[str] = None, + unix_socket: Optional[Path] = None, ) -> grpc.Channel: - target = f"{host}:{port}" + target = _target(host, port, unix_socket) credentials = _build_credentials(root_certificates) + options = _channel_options(tls_server_name, unix_socket) if credentials is not None: - return grpc.secure_channel(target, credentials, _channel_options(tls_server_name)) - return grpc.insecure_channel(target) + return grpc.secure_channel(target, credentials, options) + return grpc.insecure_channel(target, options) def create_aio_channel( @@ -54,11 +67,11 @@ def create_aio_channel( port: int, root_certificates: Optional[Path] = None, tls_server_name: Optional[str] = None, + unix_socket: Optional[Path] = None, ) -> grpc.aio.Channel: - target = f"{host}:{port}" + target = _target(host, port, unix_socket) credentials = _build_credentials(root_certificates) + options = _channel_options(tls_server_name, unix_socket) if credentials is not None: - return grpc.aio.secure_channel( - target, credentials, _channel_options(tls_server_name) - ) - return grpc.aio.insecure_channel(target) + return grpc.aio.secure_channel(target, credentials, options) + return grpc.aio.insecure_channel(target, options) diff --git a/kuksa-client/tests/v2/conftest.py b/kuksa-client/tests/v2/conftest.py index 8d3ccc8..4529483 100644 --- a/kuksa-client/tests/v2/conftest.py +++ b/kuksa-client/tests/v2/conftest.py @@ -12,6 +12,8 @@ # ********************************************************************************/ import asyncio +import os +import shutil import threading import grpc @@ -109,3 +111,35 @@ async def _teardown(): asyncio.run_coroutine_threadsafe(_teardown(), loop).result() loop.call_soon_threadsafe(loop.stop) thread.join(timeout=5) + + +@pytest.fixture +def unix_server(broker): + import tempfile + + tmp_dir = tempfile.mkdtemp(prefix="kuksa", dir="/tmp") + socket_path = os.path.join(tmp_dir, "k.sock") + loop = asyncio.new_event_loop() + thread = threading.Thread(target=loop.run_forever, daemon=True) + thread.start() + + holder = {} + + async def _setup(): + grpc_server = grpc.aio.server() + val_pb2_grpc.add_VALServicer_to_server(broker, grpc_server) + grpc_server.add_insecure_port(f"unix:{socket_path}") + await grpc_server.start() + holder["server"] = grpc_server + + asyncio.run_coroutine_threadsafe(_setup(), loop).result() + try: + yield socket_path + finally: + async def _teardown(): + await holder["server"].stop(grace=0.5) + + asyncio.run_coroutine_threadsafe(_teardown(), loop).result() + loop.call_soon_threadsafe(loop.stop) + thread.join(timeout=5) + shutil.rmtree(tmp_dir, ignore_errors=True) diff --git a/kuksa-client/tests/v2/test_client.py b/kuksa-client/tests/v2/test_client.py index 9985914..bf3f27b 100644 --- a/kuksa-client/tests/v2/test_client.py +++ b/kuksa-client/tests/v2/test_client.py @@ -78,7 +78,6 @@ def test_list_metadata_pattern(client): def test_expand(client): paths = client.expand("Vehicle.Cabin.Sunroof.**") assert "Vehicle.Cabin.Sunroof.Shade.Position" in paths - assert "Vehicle.Cabin.Sunroof.Position" in paths def test_expand_by_entry_type(client): @@ -116,3 +115,11 @@ def test_subscribe(client): client.set({"Vehicle.Speed": 20.0}) second = next(iterator) assert second["Vehicle.Speed"].value == 20.0 + + +def test_connect_via_unix_socket(unix_server): + with KuksaClient(unix_socket=unix_server) as client: + info = client.get_server_info() + assert info.name == "mock-databroker" + client.set({"Vehicle.Speed": 42.0}) + assert client.get("Vehicle.Speed").value == 42.0 diff --git a/kuksa-client/tests/v2/test_client_async.py b/kuksa-client/tests/v2/test_client_async.py index 01c4f27..c5346d2 100644 --- a/kuksa-client/tests/v2/test_client_async.py +++ b/kuksa-client/tests/v2/test_client_async.py @@ -101,3 +101,12 @@ async def test_subscribe(client): await client.set({"Vehicle.Speed": 20.0}) second = await iterator.__anext__() assert second["Vehicle.Speed"].value == 20.0 + + +@pytest.mark.asyncio +async def test_connect_via_unix_socket(unix_server): + async with KuksaClient(unix_socket=unix_server) as client: + info = await client.get_server_info() + assert info.name == "mock-databroker" + await client.set({"Vehicle.Speed": 42.0}) + assert (await client.get("Vehicle.Speed")).value == 42.0 From 87eec7a95ba55236f9f38e76c36c6fc3a3ec3604 Mon Sep 17 00:00:00 2001 From: Sebastian Schildt Date: Wed, 2 Sep 2026 14:10:28 +0200 Subject: [PATCH 09/11] Refactor: String coercion in library now. May be useful for usecases besides CLI Signed-off-by: Sebastian Schildt --- docs/architecture.md | 21 +++- docs/library.md | 23 ++++ kuksa-client/kuksa_client/__main__.py | 71 +----------- kuksa-client/kuksa_client/v2/__init__.py | 17 +++ kuksa-client/kuksa_client/v2/aio.py | 13 +++ kuksa-client/kuksa_client/v2/coercion.py | 122 +++++++++++++++++++++ kuksa-client/tests/v2/test_coercion.py | 134 +++++++++++++++++++++++ 7 files changed, 329 insertions(+), 72 deletions(-) create mode 100644 kuksa-client/kuksa_client/v2/coercion.py create mode 100644 kuksa-client/tests/v2/test_coercion.py diff --git a/docs/architecture.md b/docs/architecture.md index 1c57f75..db8c36f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -23,6 +23,7 @@ is reachable through a raw-proto "escape hatch". |--------|----------------| | `types.py` | Pure dataclasses/enums (`Datapoint`, `Metadata`, `DataType`, `EntryType`, `ServerInfo`, …). No proto imports. | | `codec.py` | The single source of truth for `native value ↔ kuksa.val.v2 Value/Datapoint` encoding. | +| `coercion.py` | String → native value coercion (`coerce_value`, `coerce_values`); the boundary for CSV/CLI/config input. | | `patterns.py` | Client-side wildcard matching (databroker semantics pinned locally). | | `metadata.py` | `MetadataStore`: an in-memory, per-connection metadata/id cache. | | `errors.py` | `KuksaError` hierarchy + mappers from gRPC status codes and v2 `ErrorCode`s. | @@ -33,8 +34,8 @@ is reachable through a raw-proto "escape hatch". | `provider.py` | `Provider` (shared base + sync implementation) and `ActuationRequest`. | Dependency direction is one-way: `types`/`errors` are leaf modules; `codec`, -`patterns`, `metadata`, `transport` depend only on them; `core` composes those; -`__init__`/`aio`/`provider` sit on top. +`coercion`, `patterns`, `metadata`, `transport` depend only on them; `core` +composes those; `__init__`/`aio`/`provider` sit on top. ## Core design: shared core + thin I/O @@ -117,12 +118,26 @@ Notable points: - `TIMESTAMP`/`TIMESTAMP_ARRAY` are intentionally absent (the v2 `Value` oneof has no timestamp field). - No string casting happens here — values are expected to be native Python values - of the right type. (The CLI does its own coercion.) + of the right type. String input (CSV, CLI, config files, ...) is handled by + `coercion.py` (see below); the CLI is just one consumer of it. - `to_proto_value` / `from_proto_value` are the public escape hatch for raw value encoding. Adding a new data type is a one-line change to `_FIELD_MAP`. +## String coercion (`coercion.py`) + +`coercion.py` is the sibling of `codec.py` for the *other* direction: turning +strings (from a CSV file, a config file, or the CLI) into native Python values. +`coerce_value(value, data_type)` handles booleans, numbers and arrays; non-string +values pass through unchanged. `coerce_values(values, data_types)` applies it to +a `{path: value}` mapping. The clients expose `coerce_updates(values)`, which +resolves each path's data type via the metadata cache and then coerces. + +The split is deliberate: `codec.py` stays strict (no casting), while `coercion.py` +owns the fuzziness of string parsing so the two concerns don't leak into each +other. + ## Metadata caching (`metadata.py`) `MetadataStore` is an in-memory cache bound to a connection, mapping a signal diff --git a/docs/library.md b/docs/library.md index db4d816..213637b 100644 --- a/docs/library.md +++ b/docs/library.md @@ -108,6 +108,29 @@ with KuksaClient("127.0.0.1", 55555) as client: client.actuate({"Vehicle.Body.Windshield.Front.Wiping.System.TargetPosition": 45}) ``` +### String / external data + +`set`/`actuate` expect native Python values. For data that arrives as strings +(CSV, config files, JSON, ...) use the coercion helpers instead of parsing by +hand: + +```python +from kuksa_client.v2 import KuksaClient, DataType, coerce_value + +with KuksaClient("127.0.0.1", 55555) as client: + # explicit: fetch the type, then coerce + data_type = client.get_metadata("Vehicle.ParkingBrake.IsEngaged").data_type + client.actuate({"Vehicle.ParkingBrake.IsEngaged": coerce_value("false", data_type)}) + + # convenient: let the client resolve types from metadata + client.set(client.coerce_updates({"Vehicle.Speed": "42.5"})) +``` + +`coerce_value` parses booleans (`true`/`false`, `1`/`0`, `yes`/`no`, `on`/`off`, +case-insensitive), numbers (`int`/`float`), and arrays (`"[1,2,3]"` or +`"1,2,3"`). Non-string values are returned unchanged. `coerce_values` does the +same for a `{path: value}` mapping given a `{path: DataType}` mapping. + ### Metadata ```python diff --git a/kuksa-client/kuksa_client/__main__.py b/kuksa-client/kuksa_client/__main__.py index daf9ca0..b297f78 100755 --- a/kuksa-client/kuksa_client/__main__.py +++ b/kuksa-client/kuksa_client/__main__.py @@ -40,12 +40,12 @@ from kuksa_client import _metadata from kuksa_client.kuksa_logger import KuksaLogger -from kuksa_client.v2 import DataType from kuksa_client.v2 import EntryType from kuksa_client.v2 import KuksaClient from kuksa_client.v2 import KuksaError from kuksa_client.v2 import NotFound from kuksa_client.v2 import Provider +from kuksa_client.v2.coercion import coerce_value scriptDir = os.path.dirname(os.path.realpath(__file__)) @@ -58,76 +58,9 @@ # --------------------------------------------------------------------------- -# Value coercion (CLI layer only) +# Value coercion (thin wrapper over kuksa_client.v2.coercion) # --------------------------------------------------------------------------- -_BOOL_TRUE = {"true", "t", "1", "yes", "on"} -_BOOL_FALSE = {"false", "f", "0", "no", "off"} -_INT_TYPES = { - DataType.INT8, - DataType.INT16, - DataType.INT32, - DataType.INT64, - DataType.UINT8, - DataType.UINT16, - DataType.UINT32, - DataType.UINT64, -} -_FLOAT_TYPES = {DataType.FLOAT, DataType.DOUBLE} -_INT_ARRAYS = { - DataType.INT8_ARRAY, - DataType.INT16_ARRAY, - DataType.INT32_ARRAY, - DataType.INT64_ARRAY, - DataType.UINT8_ARRAY, - DataType.UINT16_ARRAY, - DataType.UINT32_ARRAY, - DataType.UINT64_ARRAY, -} -_FLOAT_ARRAYS = {DataType.FLOAT_ARRAY, DataType.DOUBLE_ARRAY} - - -def _parse_array(text, data_type): - stripped = text.strip() - if stripped.startswith("[") and stripped.endswith("]"): - stripped = stripped[1:-1] - items = [item.strip() for item in stripped.split(",") if item.strip() != ""] - if data_type == DataType.STRING_ARRAY: - def cast(s): - return s.strip("\"'") - elif data_type == DataType.BOOLEAN_ARRAY: - cast = _coerce_bool - elif data_type in _INT_ARRAYS: - cast = int - elif data_type in _FLOAT_ARRAYS: - cast = float - else: - cast = str - return [cast(item) for item in items] - - -def _coerce_bool(text): - lowered = text.strip().lower() - if lowered in _BOOL_TRUE: - return True - if lowered in _BOOL_FALSE: - return False - raise ValueError(f"Invalid boolean value: {text}") - - -def coerce_value(text, data_type): - if data_type is None or data_type == DataType.UNSPECIFIED: - return text - if data_type == DataType.BOOLEAN: - return _coerce_bool(text) - if data_type in _FLOAT_TYPES: - return float(text) - if data_type in _INT_TYPES: - return int(text) - if data_type.name.endswith("_ARRAY"): - return _parse_array(text, data_type) - return text - def coerce_assignments(client, assignments): """ diff --git a/kuksa-client/kuksa_client/v2/__init__.py b/kuksa-client/kuksa_client/v2/__init__.py index a7bd8e9..3a1255d 100644 --- a/kuksa-client/kuksa_client/v2/__init__.py +++ b/kuksa-client/kuksa_client/v2/__init__.py @@ -32,8 +32,11 @@ import grpc from kuksa.val.v2 import val_pb2_grpc +from . import coercion from . import patterns from . import transport +from .coercion import coerce_value +from .coercion import coerce_values from .core import _KuksaCore from .errors import Aborted from .errors import AlreadyExists @@ -64,6 +67,8 @@ "Metadata", "ValueRestriction", "ServerInfo", + "coerce_value", + "coerce_values", "KuksaError", "KuksaTransportError", "KuksaStreamError", @@ -239,6 +244,18 @@ def _normalize_updates(values: Dict[str, Any]) -> Dict[str, Datapoint]: for path, value in values.items() } + def coerce_updates(self, values: Dict[str, Any]) -> Dict[str, Any]: + """ + Coerce string values to each signal's data type. + + ``values`` maps signal paths to values (typically strings, e.g. from a + CSV file). The data type of every path is resolved from the databroker + and cached, then each value is coerced accordingly. Non-string values + are returned unchanged. + """ + data_types = self._resolve_data_types(values.keys()) + return coercion.coerce_values(values, data_types) + def get(self, path_or_paths): """ Get the current value of a signal, or of several signals. diff --git a/kuksa-client/kuksa_client/v2/aio.py b/kuksa-client/kuksa_client/v2/aio.py index f339eaa..947f257 100644 --- a/kuksa-client/kuksa_client/v2/aio.py +++ b/kuksa-client/kuksa_client/v2/aio.py @@ -30,6 +30,7 @@ import grpc from kuksa.val.v2 import val_pb2_grpc +from . import coercion from . import patterns from . import transport from .core import _KuksaCore @@ -199,6 +200,18 @@ def _normalize_updates(values: Dict[str, Any]) -> Dict[str, Datapoint]: for path, value in values.items() } + async def coerce_updates(self, values: Dict[str, Any]) -> Dict[str, Any]: + """ + Coerce string values to each signal's data type. + + ``values`` maps signal paths to values (typically strings, e.g. from a + CSV file). The data type of every path is resolved from the databroker + and cached, then each value is coerced accordingly. Non-string values + are returned unchanged. + """ + data_types = await self._resolve_data_types(values.keys()) + return coercion.coerce_values(values, data_types) + async def get(self, path_or_paths): self._check_connected() if isinstance(path_or_paths, str): diff --git a/kuksa-client/kuksa_client/v2/coercion.py b/kuksa-client/kuksa_client/v2/coercion.py new file mode 100644 index 0000000..2ab384d --- /dev/null +++ b/kuksa-client/kuksa_client/v2/coercion.py @@ -0,0 +1,122 @@ +# /******************************************************************************** +# * Copyright (c) 2026 Contributors to the Eclipse Foundation +# * +# * See the NOTICE file(s) distributed with this work for additional +# * information regarding copyright ownership. +# * +# * This program and the accompanying materials are made available under the +# * terms of the Apache License 2.0 which is available at +# * http://www.apache.org/licenses/LICENSE-2.0 +# * +# * SPDX-License-Identifier: Apache-2.0 +# ********************************************************************************/ + +""" +Helpers for turning strings (e.g. from a CSV file, a config file or the CLI) +into the native Python values the :mod:`kuksa_client.v2` clients expect. + +Unlike :mod:`kuksa_client.v2.codec`, which deliberately does no string casting, +this module exists exactly for the string-input boundary. Values that are +already native Python values are passed through unchanged. +""" + +from __future__ import annotations + +from typing import Any +from typing import Mapping + +from .types import DataType + +_BOOL_TRUE = {"true", "t", "1", "yes", "on"} +_BOOL_FALSE = {"false", "f", "0", "no", "off"} +_INT_TYPES = { + DataType.INT8, + DataType.INT16, + DataType.INT32, + DataType.INT64, + DataType.UINT8, + DataType.UINT16, + DataType.UINT32, + DataType.UINT64, +} +_FLOAT_TYPES = {DataType.FLOAT, DataType.DOUBLE} +_INT_ARRAYS = { + DataType.INT8_ARRAY, + DataType.INT16_ARRAY, + DataType.INT32_ARRAY, + DataType.INT64_ARRAY, + DataType.UINT8_ARRAY, + DataType.UINT16_ARRAY, + DataType.UINT32_ARRAY, + DataType.UINT64_ARRAY, +} +_FLOAT_ARRAYS = {DataType.FLOAT_ARRAY, DataType.DOUBLE_ARRAY} + + +def _coerce_bool(text: str) -> bool: + lowered = text.strip().lower() + if lowered in _BOOL_TRUE: + return True + if lowered in _BOOL_FALSE: + return False + raise ValueError(f"Invalid boolean value: {text}") + + +def _parse_array(text: str, data_type: DataType) -> list: + stripped = text.strip() + if stripped.startswith("[") and stripped.endswith("]"): + stripped = stripped[1:-1] + items = [item.strip() for item in stripped.split(",") if item.strip() != ""] + if data_type == DataType.STRING_ARRAY: + def cast(s): + return s.strip("\"'") + elif data_type == DataType.BOOLEAN_ARRAY: + cast = _coerce_bool + elif data_type in _INT_ARRAYS: + cast = int + elif data_type in _FLOAT_ARRAYS: + cast = float + else: + cast = str + return [cast(item) for item in items] + + +def coerce_value(value: Any, data_type: DataType) -> Any: + """ + Coerce ``value`` into the native Python value for ``data_type``. + + Strings are parsed according to ``data_type`` (booleans accept + ``true``/``false`` and friends, numbers use ``int``/``float``, arrays use a + comma-separated / JSON-like ``[...]`` syntax). Non-string values are + returned unchanged so this is safe to apply to mixed input. + """ + if not isinstance(value, str): + return value + if data_type is None or data_type == DataType.UNSPECIFIED: + return value + if data_type == DataType.BOOLEAN: + return _coerce_bool(value) + if data_type in _FLOAT_TYPES: + return float(value) + if data_type in _INT_TYPES: + return int(value) + if data_type.name.endswith("_ARRAY"): + return _parse_array(value, data_type) + return value + + +def coerce_values( + values: Mapping[str, Any], data_types: Mapping[str, DataType] +) -> dict: + """ + Coerce a ``{path: value}`` mapping using each path's ``DataType``. + + Raises ``ValueError`` if a path has no known data type. + """ + result = {} + for path, value in values.items(): + data_type = data_types.get(path) + if data_type is None: + raise ValueError(f"No data type for path '{path}'") + result[path] = coerce_value(value, data_type) + return result diff --git a/kuksa-client/tests/v2/test_coercion.py b/kuksa-client/tests/v2/test_coercion.py new file mode 100644 index 0000000..7c4858d --- /dev/null +++ b/kuksa-client/tests/v2/test_coercion.py @@ -0,0 +1,134 @@ +# /******************************************************************************** +# * Copyright (c) 2026 Contributors to the Eclipse Foundation +# * +# * SPDX-License-Identifier: Apache-2.0 +# ********************************************************************************/ + +import pytest + +from kuksa_client.v2 import DataType +from kuksa_client.v2 import KuksaClient +from kuksa_client.v2 import coerce_value +from kuksa_client.v2 import coerce_values +from kuksa_client.v2.aio import KuksaClient as AioKuksaClient + + +@pytest.mark.parametrize( + "text,expected", + [ + ("true", True), + ("TRUE", True), + ("t", True), + ("1", True), + ("yes", True), + ("on", True), + ("false", False), + ("FALSE", False), + ("f", False), + ("0", False), + ("no", False), + ("off", False), + (" false ", False), + ], +) +def test_coerce_bool(text, expected): + assert coerce_value(text, DataType.BOOLEAN) is expected + + +def test_coerce_bool_invalid(): + with pytest.raises(ValueError): + coerce_value("not-a-bool", DataType.BOOLEAN) + + +@pytest.mark.parametrize( + "data_type", [DataType.INT8, DataType.INT32, DataType.INT64, DataType.UINT32] +) +def test_coerce_int(data_type): + assert coerce_value("42", data_type) == 42 + + +@pytest.mark.parametrize("data_type", [DataType.FLOAT, DataType.DOUBLE]) +def test_coerce_float(data_type): + assert coerce_value("42.5", data_type) == 42.5 + + +def test_coerce_string(): + assert coerce_value("hello", DataType.STRING) == "hello" + + +def test_coerce_unspecified_passthrough(): + assert coerce_value("hello", DataType.UNSPECIFIED) == "hello" + assert coerce_value("hello", None) == "hello" + + +def test_coerce_non_string_passthrough(): + assert coerce_value(42, DataType.FLOAT) == 42 + assert coerce_value(True, DataType.BOOLEAN) is True + assert coerce_value([1, 2], DataType.INT32_ARRAY) == [1, 2] + + +def test_coerce_int_array(): + assert coerce_value("[1,2,3]", DataType.INT32_ARRAY) == [1, 2, 3] + assert coerce_value("1,2,3", DataType.INT32_ARRAY) == [1, 2, 3] + + +def test_coerce_float_array(): + assert coerce_value("[1.5, 2.5]", DataType.FLOAT_ARRAY) == [1.5, 2.5] + + +def test_coerce_bool_array(): + assert coerce_value("[true,false]", DataType.BOOLEAN_ARRAY) == [True, False] + + +def test_coerce_string_array(): + assert coerce_value("['a','b']", DataType.STRING_ARRAY) == ["a", "b"] + assert coerce_value('["a","b"]', DataType.STRING_ARRAY) == ["a", "b"] + + +def test_coerce_values(): + data_types = { + "Vehicle.Speed": DataType.FLOAT, + "Vehicle.ADAS.ABS.IsActive": DataType.BOOLEAN, + "Vehicle.OBD.DTCList": DataType.STRING_ARRAY, + } + result = coerce_values( + { + "Vehicle.Speed": "42.5", + "Vehicle.ADAS.ABS.IsActive": "true", + "Vehicle.OBD.DTCList": "['a','b']", + }, + data_types, + ) + assert result == { + "Vehicle.Speed": 42.5, + "Vehicle.ADAS.ABS.IsActive": True, + "Vehicle.OBD.DTCList": ["a", "b"], + } + + +def test_coerce_values_missing_type(): + with pytest.raises(ValueError): + coerce_values({"Vehicle.Speed": "42"}, {}) + + +def test_sync_client_coerce_updates(): + client = KuksaClient(ensure_startup_connection=False) + client._resolve_data_types = lambda paths: { + path: DataType.BOOLEAN for path in paths + } + assert client.coerce_updates({"Vehicle.ParkingBrake.IsEngaged": "false"}) == { + "Vehicle.ParkingBrake.IsEngaged": False + } + + +@pytest.mark.asyncio +async def test_async_client_coerce_updates(): + client = AioKuksaClient(ensure_startup_connection=False) + + async def resolve(paths): + return {path: DataType.BOOLEAN for path in paths} + + client._resolve_data_types = resolve + assert await client.coerce_updates({"Vehicle.ParkingBrake.IsEngaged": "false"}) == { + "Vehicle.ParkingBrake.IsEngaged": False + } From 340f31669b317d7022bbb1cc180e2c944a8ae57f Mon Sep 17 00:00:00 2001 From: Sebastian Schildt Date: Thu, 3 Sep 2026 13:43:33 +0200 Subject: [PATCH 10/11] Better examples in docs Signed-off-by: Sebastian Schildt --- docs/library.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/docs/library.md b/docs/library.md index 213637b..5e3baae 100644 --- a/docs/library.md +++ b/docs/library.md @@ -108,6 +108,23 @@ with KuksaClient("127.0.0.1", 55555) as client: client.actuate({"Vehicle.Body.Windshield.Front.Wiping.System.TargetPosition": 45}) ``` +`get` returns different shapes depending on its argument — a single +`Datapoint` for one path, or a `{path: Datapoint}` dict for several. Iterate +the dict when you have multiple signals: + +```python +from kuksa_client.v2 import KuksaClient + +with KuksaClient("127.0.0.1", 55555) as client: + datapoint = client.get("Vehicle.Speed") # single path -> Datapoint + print(datapoint.value) # 42.0 + print(datapoint.timestamp) # datetime.datetime(...) + + datapoints = client.get(["Vehicle.Speed", "Vehicle.ADAS.ABS.IsActive"]) + for path, datapoint in datapoints.items(): # several paths -> dict + print(f"{path} = {datapoint.value}") +``` + ### String / external data `set`/`actuate` expect native Python values. For data that arrives as strings @@ -138,6 +155,18 @@ md = client.get_metadata("Vehicle.Speed") # -> Metadata (raises NotFound) tree = client.list_metadata("Vehicle.Cabin") # -> list[Metadata] ``` +`Metadata.data_type` and `Metadata.entry_type` are `IntEnum`s, so printing +them shows an integer. Use `.name` for the human-readable member name: + +```python +md = client.get_metadata("Vehicle.Speed") +print(md.data_type) # 11 (IntEnum -> prints its int value) +print(md.data_type.name) # 'FLOAT' +print(md.entry_type.name) # 'SENSOR' +print(md.unit) # 'km/h' +print(md.description) +``` + ### Wildcards and path expansion `get`/`set`/`subscribe` operate on exact paths only. To work with a branch use From cbee1b1ef8b345b787dbc8218d208698d6efe364 Mon Sep 17 00:00:00 2001 From: Sebastian Schildt Date: Fri, 11 Sep 2026 22:05:47 +0200 Subject: [PATCH 11/11] Enable loopback in mock provider Signed-off-by: Sebastian Schildt --- docs/cli.md | 4 +-- kuksa-client/kuksa_client/__main__.py | 43 ++++++++++++++++++++++++--- kuksa-client/tests/v2/test_cli.py | 28 +++++++++++++++++ 3 files changed, 69 insertions(+), 6 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index 83fd4b9..98b8f5a 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -40,7 +40,7 @@ Available one-shot commands: | `set ` | Set values (e.g. `Vehicle.Speed=42`) | | `actuate ` | Actuate actuators (e.g. `Vehicle.Body.Wiper.Pos=45`) | | `subscribe ` | Subscribe to one or more paths (wildcards are expanded) | -| `mock-actuator ` | Provide a mock actuator that accepts and prints received actuations (until terminated) | +| `mock-actuator [-l] ` | Provide a mock actuator that accepts and prints received actuations (until terminated); `-l`/`--loopback` also sets the received value as the current value | | `get-metadata ` | Get the metadata of a path | | `list-metadata ` | List metadata matching a pattern | | `has-signal ` | Check whether a signal exists | @@ -60,7 +60,7 @@ Available one-shot commands: | `subscribe ` | Subscribe to updates (wildcards are expanded) | | `subscribe -b ` | Subscribe in the background; updates print as alerts while the prompt stays usable | | `unsubscribe ` | Stop a background subscription | -| `mock_actuator ` | Register a mock provider that accepts and prints actuations | +| `mock_actuator [-l] ` | Register a mock provider that accepts and prints actuations; `-l`/`--loopback` also sets the received value as the current value | | `remove_mock ` | Remove a mock actuator provider | | `get_metadata ` | Get the metadata of a path | | `list_metadata ` | List metadata matching a pattern | diff --git a/kuksa-client/kuksa_client/__main__.py b/kuksa-client/kuksa_client/__main__.py index b297f78..98c1621 100755 --- a/kuksa-client/kuksa_client/__main__.py +++ b/kuksa-client/kuksa_client/__main__.py @@ -189,6 +189,7 @@ class _MockActuator: paths: list provider: object = None thread: threading.Thread = None + loopback: bool = False class KuksaShell(Cmd): @@ -252,6 +253,12 @@ class KuksaShell(Cmd): nargs="+", completer=path_completer, ) + ap_mock_actuator.add_argument( + "-l", + "--loopback", + action="store_true", + help="Also set the received value as the signal's current value", + ) ap_remove_mock = Cmd2ArgumentParser() ap_remove_mock.add_argument( @@ -589,19 +596,23 @@ def do_mock_actuator(self, args): mock_id = self._mock_counter thread = threading.Thread( target=self._mock_actuator_loop, - args=(mock_id, provider), + args=(mock_id, provider, client, args.loopback), daemon=True, ) with self._mock_lock: self._mocks[mock_id] = _MockActuator( - paths=list(args.Path), provider=provider, thread=thread + paths=list(args.Path), + provider=provider, + thread=thread, + loopback=args.loopback, ) thread.start() print(f"Registered mock actuator {mock_id} for {', '.join(args.Path)}") - def _mock_actuator_loop(self, mock_id, provider): + def _mock_actuator_loop(self, mock_id, provider, client=None, loopback=False): try: for requests in provider.actuation_requests(): + updates = {} for request in requests: message = highlight( json.dumps( @@ -617,6 +628,13 @@ def _mock_actuator_loop(self, mock_id, provider): provider.accept(request, ok=True) except Exception: pass + if loopback: + updates[request.path] = request.value + if loopback and updates and client is not None: + try: + client.set(updates) + except Exception as exc: + self.add_alert(msg=f"Loopback error: {exc}") except Exception: # The stream was terminated, e.g. by a disconnect or removal. pass @@ -746,8 +764,17 @@ def _build_one_shot_parser(): p_sub = subparsers.add_parser("subscribe", help="Subscribe to one or more paths") p_sub.add_argument("paths", nargs="+") - p_mock = subparsers.add_parser("mock-actuator", help="Provide a mock actuator that prints received actuations") + p_mock = subparsers.add_parser( + "mock-actuator", + help="Provide a mock actuator that prints received actuations", + ) p_mock.add_argument("paths", nargs="+", help="Actuator paths to provide") + p_mock.add_argument( + "-l", + "--loopback", + action="store_true", + help="Also set the received value as the signal's current value", + ) p_md = subparsers.add_parser("get-metadata", help="Get the metadata of a path") p_md.add_argument("path") @@ -822,6 +849,7 @@ def _run_one_shot(args): provider.provide_actuators(args.paths) try: for requests in provider.actuation_requests(): + updates = {} for request in requests: print( json.dumps( @@ -830,6 +858,13 @@ def _run_one_shot(args): ) ) provider.accept(request, ok=True) + if args.loopback: + updates[request.path] = request.value + if args.loopback and updates: + try: + client.set(updates) + except Exception as exc: + print(f"Loopback error: {exc}", file=sys.stderr) except KeyboardInterrupt: pass finally: diff --git a/kuksa-client/tests/v2/test_cli.py b/kuksa-client/tests/v2/test_cli.py index 5f6c1e6..71f1db6 100644 --- a/kuksa-client/tests/v2/test_cli.py +++ b/kuksa-client/tests/v2/test_cli.py @@ -340,6 +340,14 @@ def close(self): self.closed = True +class _FakeLoopbackClient: + def __init__(self): + self.sets = [] + + def set(self, updates): + self.sets.append(updates) + + def test_remove_mock_completer(): shell = _alert_shell() shell._mocks = { @@ -379,3 +387,23 @@ def test_mock_actuator_loop_accepts_and_alerts(): assert "Vehicle.Body.Wiper.Pos" in shell._alert_queue[0].msg assert "45.0" in shell._alert_queue[0].msg assert provider.accepted == [request] + + +def test_mock_actuator_loop_loopback_sets_values(): + shell = _alert_shell() + request = _FakeRequest("Vehicle.Body.Wiper.Pos", 45.0) + provider = _FakeProvider(batches=[[request]]) + client = _FakeLoopbackClient() + KuksaShell._mock_actuator_loop(shell, 1, provider, client, loopback=True) + assert provider.accepted == [request] + assert client.sets == [{"Vehicle.Body.Wiper.Pos": 45.0}] + + +def test_mock_actuator_loop_without_loopback_does_not_set_values(): + shell = _alert_shell() + request = _FakeRequest("Vehicle.Body.Wiper.Pos", 45.0) + provider = _FakeProvider(batches=[[request]]) + client = _FakeLoopbackClient() + KuksaShell._mock_actuator_loop(shell, 1, provider, client, loopback=False) + assert provider.accepted == [request] + assert client.sets == []