Skip to content

Make the meter-driver client conform to the Meter Driver Specification v1.4.0 - #33

Merged
tescalada merged 18 commits into
mainfrom
driver-spec-conformance
Sep 24, 2026
Merged

tescalada merged 18 commits into
mainfrom
driver-spec-conformance

Conversation

@tescalada

Copy link
Copy Markdown
Contributor

The driver client followed the reference driver's routes rather than the specification: init went to /v1/sparknet/init, registration required a /v1/commands route the spec does not define, discovery read a non-spec extension block, and /v1/requirements was only probed when that vendor route existed. A driver serving exactly the spec could not be registered or initialized.

Now the client validates a driver's openapi.json against the spec's required routes and methods, reads x-meter-driver, always discovers init fields from /v1/requirements typed by the document's InitRequest, posts init to /v1/init with those fields, and recognizes exactly the spec's event names. /v1/commands vendor options remain as an optional extra. A gRPC selection with no advertised target, or with a contract that lacks the proto's fixed init fields, is refused at registration; if such a provider is already saved, startup logs it and continues with metering inactive instead of aborting.

Tests cover every changed behavior against a fixture that is the spec's own OpenAPI document. The README's Meter drivers section describes registration and configuration against the specification. pydantic, imported directly by the contract validation, is now a declared dependency.

@github-actions

github-actions Bot commented Sep 22, 2026 •

Copy link
Copy Markdown

Coverage

Coverage report
FileStmtsMissCoverMissing
sparkmeter
   __version__.py16288%32, 38
   _wsgi_mount_smoketest.py97970%19–152
   app.py3151197%19–20, 111, 368–375, 552–554, 675, 682–683
   asgi.py792766%45, 63–66, 86, 151–176, 183, 187
   cli.py40685%57–68
   cli_app.py880%4–20
   cli_prompts.py151220%8–12, 17–21, 26–28
   conftest.py2121095%92, 121–122, 155–156, 178–190, 279
   controller.py2434283%181, 205–206, 529, 583–640
   debug_memory.py32320%6–63
   periodic.py563145%60–66, 71–75, 80–84, 93–98, 103–111, 124–128
   sentry_proxy.py342235%38, 44–47, 56–65, 73–82
   servercommand.py1127236%47, 56–60, 64–69, 73–78, 82–85, 89–90, 101–103, 112–181
sparkmeter/alembic/versions
   0.32_merge_sms_tables.py46393%61–67
   0.77_snapshot_support.py128298%340, 401
sparkmeter/api
   customerviews0.py283199%301
   historyviews0.py1149219%49–55, 69–72, 81–131, 144–175, 185–216, 230–257
sparkmeter/config
   configparameter.py58297%80, 111
   configviews.py100397%157–159
   provider_settings.py673299%1269–1270
sparkmeter/database
   alchemy.py54493%61–63, 74
   database.py232399%497, 579–580
   ormobject.py55198%65
   session.py21210%12–62
   types.py54394%91–92, 125
sparkmeter/ground
   groundcommand.py201050%20–35
sparkmeter/history
   historyview.py351654%30–31, 41–54, 65–72
sparkmeter/meter
   meterdomain.py7732797%895, 1793–1794, 1805–1856, 1899
sparkmeter/metering
   api.py27967%66–76
   events.py164498%94, 294–295, 328
   reconcile.py123497%154–155, 268, 275
sparkmeter/system
   systemcommand.py67593%35, 130–132, 134–135
sparkmeter/tariff
   tariffcommand.py821582%27–44
sparkmeter/transaction
   transactionview.py167199%71
sparkmeter/user
   userdomain.py1871294%416–434
sparkmeter/web
   unittestutils.py1951194%73–81, 330, 339, 405, 407
   views.py72199%71
TOTAL1400462496% 

Tests Skipped Failures Errors Time
1679 0 💤 0 ❌ 0 🔥 2m 37s ⏱️

@github-actions

github-actions Bot commented Sep 22, 2026 •

Copy link
Copy Markdown

Diff Coverage

Diff: origin/main...HEAD, staged and unstaged changes

  • sparkmeter/config/provider_settings.py (99.4%): Missing lines 1269-1270
  • sparkmeter/config/providerform.py (100%)
  • sparkmeter/conftest.py (96.8%): Missing lines 92
  • sparkmeter/metering/http_sse.py (100%)
  • sparkmeter/metering/lifespan.py (100%)
  • sparkmeter/metering/reconcile.py (100%)
  • sparkmeter/metering/runtime_client.py (100%)

Summary

  • Total: 428 lines
  • Missing: 3 lines
  • Coverage: 99%

sparkmeter/config/provider_settings.py

Lines 1265-1274

  1265     if not provider:
  1266         return []
  1267     try:
  1268         return list(_stored_field_specs(load_provider_runtime_settings(provider)).values())
! 1269     except DriverConfigError:
! 1270         return []
  1271 
  1272 
  1273 def get_live_interface_details(service_url, selected_interface=None, timeout=2.0, provider=None):
  1274     """Fetch the interface inventory the driver advertises right now.

sparkmeter/conftest.py

Lines 88-96

  88                     raise requirements_error
  89                 return FakeJsonResponse({"required_fields": list(required_fields)})
  90             if url.endswith("/openapi.json"):
  91                 return FakeJsonResponse(document)
! 92             raise AssertionError("unexpected GET {}".format(url))
  93 
  94         fake_get.calls = calls
  95         return fake_get

@jranson

jranson commented Sep 22, 2026

Copy link
Copy Markdown
Contributor

Identified by Claude Fable 5.1, reviewed and verified by human.

Separately, a test is failing intermittently when running locally (threaded concurrency test in test_controllers.py), but it does not look like it's part of this changeset, so we should file issue and fix in a separate PR.

P1 — fix before merge

  • Optional init fields can never reach the driver. provider_settings.py:796. Discovery only takes names from /v1/requirements plus the non-spec /v1/commands vendor block. The spec's own InitRequest declares channel as optional, so a spec-conformant driver never advertises it. Reproduced: with the spec's own OpenAPI document and a requirements list of heartbeat_period_duration, aes_key, an operator-supplied "channel": 15 is dropped with a "not sent" warning and the init body goes out without it. The old reconcile code sent channel whenever present, so radio channel configuration silently regresses. Every PR test lists channel in required_fields, which is why none catch it. Fix: also offer non-required InitRequest properties as optional fields, like vendor options.

P2 — should fix, or explicitly accept

  • sparknet_configuration_applied becomes an unknown-event warning. events.py:78. The removed comment said the live SparkNet-Http driver emits this name. Every init against it will now log a WARNING. Intentional per the PR text, but confirm no deployed driver still emits it.
  • Healthy driver reported offline on odd /v1/status body. provider_settings.py:1337. Reproduced: healthz answers {"ok": true} but status returns [], and the result is online: False. A transport error or invalid JSON on the same call leaves the driver online with no gateway. The two branches should agree.
  • gRPC heartbeat parsing depends on the driver setting both nested stats messages. runtime_client.py:445. Reproduced with the real proto: a HeartbeatStatistics message whose two millisecond_*_stats submessages are unset renders without those keys, and the pydantic model rejects it, so the reading consumer never gets its flush marker. When the driver sets them it validates fine. The PR's test mocks MessageToDict, so this path is untested with real protobuf output. Pre-existing behavior was no better, but this PR rewrote the function and is the moment to cover it.

P3 — minor

  • Legacy-config warning repeats on every read. provider_settings.py:667. Reproduced: three validate calls, three identical warnings. It fires on every page render and reconcile until the driver is re-registered.
  • pattern is checked with unanchored search. provider_settings.py:729. Reproduced: pattern [0-9]+ accepts abc-12. This matches JSON Schema semantics, so it is defensible, but the spec's own patterns are anchored and a driver author may expect full-match.
  • aes_key is special-cased by name regardless of the schema the driver declares, which slightly contradicts the "nothing hardcoded" goal. The spec does fix its shape, so it is fine today.
  • client_id is no longer sent on /v1/events, and the /health fallback is gone. Both spec-driven, but a driver that relied on either will behave differently.

@tescalada
tescalada force-pushed the configurable-meter-drivers branch from 1f5758a to 790419b Compare September 22, 2026 18:06
The README directs contributors to the groundbolt-dev workspace for the
development environment, links it wherever it was named, and marks the
webapp-only local setup as the exception rather than the default.
… HTTP host

HttpCommandClient.init_driver posts to the spec's /v1/init route.
GrpcCommandClient.init_driver raises a ValueError naming whichever of
heartbeat_period_duration and aes_key the payload lacks, since the
ConfigureDriver message has fixed fields. _grpc_target returns only an
advertised or saved target; build_command_client and build_event_client
raise GrpcTargetUnavailable when gRPC is selected without one instead of
guessing host:50051 or silently falling back to HTTP. Docstrings now
point at the meter_driver_spec.grpc stubs from the meter-driver-spec
wheel.
…ter-driver block

validate_contract requires every route the Meter Driver Specification
v1.4.0 marks required (/v1/requirements, /v1/init, /v1/nodes/register,
/v1/nodes/{node_id}, /v1/nodes/{node_id}/configure-meter,
/v1/meters/configure, /v1/events, /v1/status, /v1/healthz) and reads
interfaces from the x-meter-driver block, synthesizing an http interface
when the block is absent.

Init fields come from GET /v1/requirements, which is now mandatory and
raises ProviderRegistrationError on failure; each name is typed from the
document's InitRequest schema (the /v1/init request body, else
components.schemas.InitRequest), with a oneOf such as AesKeyInput reduced
to its string alternative, and undocumented names typed as string. The
/v1/commands configure_provider vendor-option schema remains as an
optional extension whose fields follow the required ones with
required=False; validate_provider_config_payload no longer demands
values for such fields and omits blank optional values from the init
payload.

get_runtime_status probes only /v1/healthz; the legacy /health leg is
gone.

Tests use spec-shaped documents plus meter_driver_spec_openapi.json, the
spec's own openapi/meter-driver.yaml converted to JSON.
_load_driver_init_payload no longer hardcodes aes_key and
heartbeat_period_duration. It validates the driver's config file with
validate_provider_config_payload, so the payload is exactly the fields
the driver reported on GET /v1/requirements (persisted as
required_fields, typed from its InitRequest schema) with the stored
field_values coerced to those types; blank optional extras are omitted
and a driver requiring no fields gets an empty body. A missing required
value or an uncoercible value skips init with a warning instead of
posting a partial body. Only aes_key, when present as a string, is
format-checked as 32 hex characters; a byte-array key passes through.
The spec's GET /v1/events declares no parameters, so stream_json_events
sends no client_id query string; the client identifies itself through
the X-Client-Id header alone.
The side-channel set in events.py is exactly the Meter Driver
Specification's event type constants; the reference driver's own alias
for driver_configuration_applied is no longer accepted and is logged as
an unknown type like any other non-spec name.
A driver serving the spec's own OpenAPI document is registered from its
base URL, its generated config lists the fields from /v1/requirements,
and reconcile then sends exactly one POST /v1/init carrying those fields
and nothing else. The events tests pin the side-channel set to the spec's
event names and check that a vendor alias is reported as unknown; the
lifespan tests use a spec event name for their "other event" case.
… discovery from requirements

Contract validation now checks the spec's required operations rather
than path keys: each of GET /v1/requirements, POST /v1/init, POST
/v1/nodes/register, DELETE /v1/nodes/{node_id}, POST
/v1/nodes/{node_id}/configure-meter, POST /v1/meters/configure, GET
/v1/events, GET /v1/status and GET /v1/healthz must be documented under
its path with that method. Path parameters match by position, so
/v1/nodes/{id} is accepted; a trailing slash is not. Every document
access is type-guarded (document, info, paths, x-meter-driver, $ref
targets, oneOf/anyOf alternatives, allOf parts) so a malformed document
raises ProviderRegistrationError, never AttributeError.

Init-field discovery validates GET /v1/requirements with the wheel's
RequirementsResponse model (required_fields is array[string]; nothing is
stringified, blank names are rejected), names the transport error in its
message, warns for a field absent from InitRequest.properties, follows
$ref chains with a cycle guard, merges allOf composition, and reads
OpenAPI 3.1 type arrays.

validate_contract is split: inspect_contract fetches and validates the
document and discovers interfaces in one round trip, and is what
get_live_interface_details uses, so an advertised gRPC target is never
lost to a slow or failing /v1/requirements; validate_contract adds the
requirements round trip for registration. get_live_interface_details
takes an optional provider and reports the fields recorded in its config
file. save_provider_settings loses the unread aes_key and channel
parameters.

get_runtime_status is offline unless /v1/healthz answers a JSON object
with "ok": true, and when /v1/status answers something other than a
JSON object.

Config payloads: whitespace-only values count as missing; values are
checked against the recorded pattern, minimum and maximum; only fields
the driver listed are sent; aes_key must be 32 hex characters or an
array of 16 byte values; bare-name required_fields entries from configs
written before types were recorded are treated as required strings with
a warning pointing at re-registration.

The spec-document fixture, a fake response class and a fake_driver
factory move to sparkmeter/conftest.py; the fixture's regeneration
command is documented there and a test checks its version against the
installed meter-driver-spec wheel.
check_grpc_selection requires an advertised grpc interface with a
target and, because the gRPC ConfigureDriver message has fixed fields,
heartbeat_period_duration and aes_key among the driver's required
/v1/requirements fields. save_provider_settings applies it to a gRPC
selection instead of silently falling back to http, and the settings
form's validate_selected_interface reports the same message.

The driver-field helpers on the forms are named for what they list
(driver_field_spec, driver_fields), both templates label the list
"Driver fields" and mark each entry required or optional, and the views
pass the saved provider so its recorded fields are shown without a
/v1/requirements round trip.
ensure_metering_runtime catches GrpcTargetUnavailable from the client
builders, logs an error naming the provider, closes a command client
built before the event client failed, leaves app.state.metering unset
and returns False instead of aborting startup.
_load_driver_init_payload no longer applies its own AES hex regex; the
spec's AesKeyInput forms, the recorded pattern/minimum/maximum and the
field filter are enforced by validate_provider_config_payload. Tests
cover an empty payload initializing the driver with {} and a ValueError
from init_driver propagating out of reconcile_all.
_grpc_event_to_raw_dict no longer adds renamed convenience fields
(event_type, meter_id, energy_wh, ...) that nothing reads. Readings are
rendered with every spec field, the phased reading with all per-phase
fields, phases and computed_fields_version, so both parse as the wheel's
models; pass-through messages keep default-valued fields and restore a
64-bit node_id to an integer.

The http_sse docstring states that X-Client-Id is ThunderCloud's own
convention, and events.py records that the OpenAPI event type constants
are normative over the differently named prose table in the spec's
section 6.
The README's Meter drivers section describes what a meter driver is here,
that any driver implementing the Meter Driver Specification v1.4.0 works,
how one is registered and configured from Global Settings > Meter Drivers,
where its config file lives, and that the development workspace runs the
meter-driver-emulator so no gateway hardware is needed.
@AGLJCM

AGLJCM commented Sep 24, 2026

Copy link
Copy Markdown
Contributor

This branch is based on configurable-meter-drivers, which has drifted quite far from main (20 commits each way). Digging in with git cherry origin/main origin/driver-spec-conformance, most of that divergence turns out to be noise — configurable-meter-drivers's commits are patch-identical to ones already on main under different SHAs (looks like main was rebased independently at some point). Only 13 commits here are actually new on top of main:

  • Point development setup at the groundbolt-dev workspace
  • Post driver init to /v1/init and stop deriving a gRPC target from the HTTP host
  • Validate driver contracts against the spec's required routes and x-meter-driver block
  • Build the driver init payload from the discovered required fields
  • Subscribe to /v1/events with only the X-Client-Id header
  • Recognize only the spec's event type names on the SSE stream
  • Test the driver client end to end against a spec-only driver
  • Check contract operations, guard document shapes, and split interface discovery from requirements
  • Refuse a gRPC selection the contract cannot honor at registration time
  • Keep ASGI startup alive when a saved gRPC selection has no target
  • Leave init-value checks to the config validator
  • Emit only type, event_id and data from the gRPC event stream
  • Document meter drivers against the Meter Driver Specification

I rebased just those onto current main (git rebase --onto origin/main origin/configurable-meter-drivers origin/driver-spec-conformance) and pushed the result as driver-spec-conformance-on-main for reference — zero conflicts, full test suite passes (395 tests).

I also verified it against the real meter-driver-emulator end to end: ran the emulator locally, rebuilt ground from this branch, and registered it through the actual web form (not just the test fixtures). It correctly discovers aes_key and heartbeat_period_duration from /v1/requirements with the right types/patterns and writes meter_driver_configs/<id>.json — the exact "driver contract missing required paths: /v1/commands" error this PR fixes is gone.

Not proposing to force-push over this branch — just offering driver-spec-conformance-on-main as a tested option if it's useful for retargeting this PR at main directly instead of the stale configurable-meter-drivers base.

@tescalada
tescalada changed the base branch from configurable-meter-drivers to main September 24, 2026 18:51
@tescalada
tescalada force-pushed the driver-spec-conformance branch from 792811c to 5cbe4da Compare September 24, 2026 19:24
Registration lists the /v1/requirements fields as required, then every
other property of the contract's InitRequest schema as an optional
field typed from that schema, then the /v1/commands vendor-option
extras for names not already listed. A spec-conformant driver that
leaves the optional channel out of /v1/requirements now receives an
operator-supplied channel in its init body; a blank optional value is
still omitted.

InitRequest typing takes precedence over vendor-option typing for a
name present in both, as it already did for aes_key.
MessageToDict omits an unset submessage field even with
always_print_fields_with_no_presence, so a heartbeat whose driver never
set millisecond_read_reply_stats or millisecond_set_config_reply_stats,
or a gateway status without firmware_version, failed the spec model and
was dropped. Pass-through events now render every singular submessage
outside a oneof from its value, which is the zeroed default when unset;
oneof members and google.protobuf wrappers stay absent when unset.

The gRPC event tests build real meter_driver_pb2 messages and validate
the heartbeat and gateway status payloads against the spec models
instead of replacing MessageToDict.
get_runtime_status reported a driver offline when /v1/status answered
JSON that is not an object, while a transport failure or invalid JSON
on the same call left it online with no gateway. Liveness comes from
/v1/healthz alone; any unusable /v1/status answer now reports online
with no gateway.
Registration now lists the InitRequest channel property as an optional
field after the /v1/requirements fields. The reconcile test asserts it
in the generated config, leaves it blank, and checks the single
POST /v1/init still carries only the filled-in fields.
@tescalada
tescalada merged commit 2c982cd into main Sep 24, 2026
11 checks passed
@tescalada
tescalada deleted the driver-spec-conformance branch September 25, 2026 17:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants